Compare commits

...

11 Commits

Author SHA1 Message Date
kl
3d0b14515f test(e2e): align preview URL encoding and docs 2026-03-04 17:24:27 +08:00
kl
e845846d30 test(e2e): guard E2E_MAX_PREVIEW_MS against sub-second values 2026-03-04 15:39:35 +08:00
kl
a7ad4b4fe4 test(e2e): follow-up fixes for post-merge copilot review feedback 2026-03-04 15:16:49 +08:00
kl
68d4d23a4b test(e2e): phase-3 add nightly full run and perf smoke checks (#717)
* test(e2e): phase-3 add nightly workflow and perf smoke suite

* test(e2e): address copilot review for phase-3 fixture and readiness flow
2026-03-04 15:06:15 +08:00
kl
bb457924cd test(e2e): phase-2 add Office and zip smoke automation (#714)
* test(e2e): phase-2 add office and zip smoke coverage

* test(e2e): address copilot review for fixture stability and CI python setup

* test(e2e): fix preflight fixture scope and path handling

* test(e2e): harden fixture preflight and remove duplicate generation

* test(e2e): remove redundant zip install and cleanup temp zip dir

* test(e2e): ensure zip dependency and unify python command in docs

* docs(e2e): align README with npm gen scripts and python3 usage
2026-03-04 14:34:32 +08:00
kl
a0d78c57e3 test(e2e): add MVP end-to-end automation suite and CI workflow (#713)
* test(e2e): add mvp playwright suite and PR workflow

* ci(e2e): use JDK 21 for kkFileView build
2026-03-04 10:46:41 +08:00
kl
7f16243270 chore(github): add accelerated support notice for urgent issues (#712)
* chore(github): add support acceleration notice to issue templates and auto-comments

* chore(actions): make copilot auto-comment bilingual and single-message
2026-03-03 19:28:23 +08:00
kl
36a75e86ac chore(github): add bilingual feature request issue template (#711)
* chore(github): add bilingual feature request issue template

* chore(github): refine feature template wording and split intake path
2026-03-03 19:10:05 +08:00
kl
7c41200028 chore(actions): auto-close >1y issues and trigger copilot triage comments 2026-03-03 18:57:59 +08:00
kl
3da0c523e8 chore(github): add bilingual required issue template 2026-03-03 18:26:39 +08:00
kl
8c3bc81e08 fix(security): support wildcard/cidr host pattern matching (#710)
* fix(security): support wildcard/cidr host pattern matching

* fix(security): harden host matching against null and DNS rebinding

* fix(security): handle ipv4 unsigned range and deny template fallback

* test(security): verify CIDR matching for IPv4 upper boundary

* fix(security): set UTF-8 deny response and use Locale.ROOT

* fix(security): enforce whitelist with blacklist and harden wildcard rules
2026-03-03 15:26:35 +08:00
29 changed files with 1256 additions and 7 deletions

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: "Security Report / 安全漏洞报告"
url: "https://github.com/kekingcn/kkFileView/security/advisories/new"
about: "For sensitive security issues, please use private security report. / 涉及敏感安全问题请使用私密安全报告。"

View File

@@ -0,0 +1,76 @@
name: "Feature Request / 功能建议"
description: "Propose a new feature with clear use case and acceptance criteria. / 提交功能建议,请明确场景与验收标准。"
title: "[FEATURE] "
labels: ["type/feature", "priority/p2", "status/needs-info"]
body:
- type: markdown
attributes:
value: |
Thanks for your idea! / 感谢你的建议
Please provide concrete business scenarios and the expected behavior.
请尽量提供明确业务场景和期望行为便于评估优先级与实现方案
For urgent production issues, you can use our Knowledge Planet channel for faster processing:
https://wx.zsxq.com/group/48844125114258
如为线上紧急问题可通过知识星球渠道加速处理
https://wx.zsxq.com/group/48844125114258
- type: textarea
id: background
attributes:
label: "Background / 背景"
description: "What problem are you trying to solve? / 你要解决什么问题?"
placeholder: "Describe current pain points... / 描述当前痛点..."
validations:
required: true
- type: textarea
id: proposal
attributes:
label: "Proposal / 建议方案"
description: "What do you expect kkFileView to support? / 期望 kkFileView 支持什么?"
placeholder: "Describe expected feature behavior... / 描述期望功能行为..."
validations:
required: true
- type: textarea
id: use_case
attributes:
label: "Use Case / 使用场景"
description: "Provide 1-3 concrete scenarios. / 提供 1-3 个具体场景"
placeholder: |
Scenario 1:
Scenario 2:
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: "Alternatives / 备选方案"
description: "What alternatives have you considered? / 是否考虑过替代方案?"
placeholder: "Existing workaround or alternative... / 当前替代做法..."
validations:
required: false
- type: textarea
id: acceptance
attributes:
label: "Acceptance Criteria / 验收标准"
description: "How do we know this feature is done? / 如何判断该功能完成?"
placeholder: |
- [ ] Criterion 1
- [ ] Criterion 2
validations:
required: true
- type: checkboxes
id: checklist
attributes:
label: "Checklist / 提交前检查"
options:
- label: "I have searched existing issues and did not find a duplicate feature request. / 我已搜索现有 issue未发现重复功能建议"
required: true
- label: "I provided concrete use cases and expected behavior. / 我已提供具体使用场景和期望行为"
required: true

121
.github/ISSUE_TEMPLATE/issue-report.yml vendored Normal file
View File

@@ -0,0 +1,121 @@
name: "Issue Report / 问题反馈"
description: "Please provide complete required information to help us reproduce and follow up. / 请完整填写必填信息,便于复现与跟进。"
title: "[ISSUE] "
labels: ["status/needs-info"]
body:
- type: markdown
attributes:
value: |
Thanks for your report! / 感谢反馈
**Please fill in all required fields.**
**请完整填写所有必填项**
Incomplete issues may be closed and asked to resubmit.
信息不完整的问题可能会被关闭并要求重新提交
For urgent production issues, you can use our Knowledge Planet channel for faster processing:
https://wx.zsxq.com/group/48844125114258
如为线上紧急问题可通过知识星球渠道加速处理
https://wx.zsxq.com/group/48844125114258
- type: dropdown
id: issue_type
attributes:
label: "Issue Type / 问题类型"
description: "Select the closest type. / 请选择最接近的问题类型"
options:
- "Bug / 缺陷"
- "Performance / 性能问题"
- "Security / 安全问题"
- "Question / 使用咨询"
validations:
required: true
- type: input
id: kkfileview_version
attributes:
label: "kkFileView Version / kkFileView 版本"
placeholder: "e.g. 4.4.0"
validations:
required: true
- type: input
id: deploy_mode
attributes:
label: "Deployment Mode / 部署方式"
description: "jar / docker / k8s / source, etc. / jar / docker / k8s / 源码部署等"
placeholder: "e.g. docker"
validations:
required: true
- type: textarea
id: environment
attributes:
label: "Environment / 环境信息"
description: "OS, JDK, LibreOffice/OpenOffice, browser, reverse proxy, etc. / 操作系统、JDK、Office组件、浏览器、反向代理等"
placeholder: |
- OS:
- JDK:
- LibreOffice/OpenOffice:
- Browser:
- Proxy (Nginx/Ingress):
validations:
required: true
- type: textarea
id: reproduce_steps
attributes:
label: "Steps to Reproduce / 复现步骤"
description: "Provide clear, minimal, reproducible steps. / 提供清晰、最小可复现步骤"
placeholder: |
1) ...
2) ...
3) ...
validations:
required: true
- type: textarea
id: expected_result
attributes:
label: "Expected Result / 期望结果"
placeholder: "What should happen? / 期望实际应该出现什么结果?"
validations:
required: true
- type: textarea
id: actual_result
attributes:
label: "Actual Result / 实际结果"
placeholder: "What happened instead? / 实际发生了什么?"
validations:
required: true
- type: textarea
id: logs
attributes:
label: "Logs & Screenshots / 日志与截图"
description: "Paste key logs/error stack and attach screenshots (mask sensitive data). / 粘贴关键日志或异常堆栈,并上传截图(请脱敏)"
render: shell
validations:
required: true
- type: textarea
id: sample_file
attributes:
label: "Sample File / 样例文件(可选)"
description: "If possible, provide a minimal sample file or reproducible URL (desensitized). / 如可提供,请附最小样例文件或可复现 URL脱敏"
validations:
required: false
- type: checkboxes
id: checklist
attributes:
label: "Checklist / 提交前检查"
options:
- label: "I have searched existing issues and did not find a duplicate. / 我已搜索现有 issue未发现重复问题"
required: true
- label: "I can reproduce this issue on the stated version/environment. / 我可在上述版本与环境复现该问题"
required: true
- label: "I have masked sensitive information in logs/screenshots. / 我已对日志与截图中的敏感信息做脱敏处理"
required: true

View File

@@ -0,0 +1,78 @@
name: Auto Close Old Issues (1y)
on:
schedule:
# Daily at 02:20 UTC
- cron: '20 2 * * *'
workflow_dispatch:
permissions:
issues: write
jobs:
close_old_issues:
runs-on: ubuntu-latest
steps:
- name: Close issues older than 1 year
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const now = new Date();
const cutoff = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
const closeComment = ` Issue 已超过 1 年未活跃为便于维护当前问题队列先做关闭处理\n\n如该问题在最新版本仍存在欢迎直接 **Reopen** Issue或新建 Issue 并关联本单并补充\n1. 版本与部署方式\n2. 最小复现步骤\n3. 关键日志/截图请脱敏\n\n我们会优先跟进`;
let page = 1;
let processed = 0;
while (true) {
const { data: issues } = await github.rest.issues.listForRepo({
owner,
repo,
state: 'open',
per_page: 100,
page,
sort: 'created',
direction: 'asc'
});
if (!issues.length) break;
for (const issue of issues) {
// skip pull requests
if (issue.pull_request) continue;
const createdAt = new Date(issue.created_at);
if (createdAt > cutoff) {
// list is sorted asc by created time; remaining items are newer
core.info('Reached issues newer than cutoff, stop scanning.');
return;
}
try {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body: closeComment,
});
await github.rest.issues.update({
owner,
repo,
issue_number: issue.number,
state: 'closed',
});
processed += 1;
core.info(`Closed #${issue.number}`);
} catch (e) {
core.warning(`Failed to close #${issue.number}: ${e.message}`);
}
}
page += 1;
}
core.info(`Done. Closed ${processed} old issues.`);

View File

@@ -0,0 +1,34 @@
name: Copilot Issue Auto Comment
on:
issues:
types: [opened]
workflow_dispatch:
permissions:
issues: write
jobs:
copilot_auto_comment:
runs-on: ubuntu-latest
steps:
- name: Ask Copilot to triage issue automatically
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
if (!issue) return;
const owner = context.repo.owner;
const repo = context.repo.repo;
const body = `@copilot 请自动分诊并直接给出可执行建议无需人工先介入\n\n- 先判断类型Bug / Performance / Security / Question / Feature\n- 检查 Issue 信息是否完整版本部署方式复现步骤日志\n- 若信息不完整请直接按模板列出缺失项并引导补充\n- 若信息较完整请给出下一步排查建议与最小复现建议\n- 若判断为已知问题或已修复请给出对应版本/修复方向\n\nIssue #${issue.number}\n标题${issue.title}\n链接${issue.html_url}\n\n---\n\n补充说明 / Support Notice:\n- GitHub Issues 会持续跟进处理 / We will continue to follow up through GitHub Issues.\n- 如为线上紧急问题可通过知识星球渠道加速处理 / For urgent production issues, you can use our Knowledge Planet channel for faster processing:\n https://wx.zsxq.com/group/48844125114258`;
await github.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body,
});
core.info(`Copilot prompt comment posted to #${issue.number}`);

118
.github/workflows/nightly-e2e.yml vendored Normal file
View File

@@ -0,0 +1,118 @@
name: Nightly E2E Full
on:
schedule:
- cron: '30 18 * * *' # 02:30 Asia/Shanghai
workflow_dispatch:
permissions:
contents: read
jobs:
e2e-nightly:
runs-on: ubuntu-latest
timeout-minutes: 50
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
cache: maven
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: tests/e2e/package-lock.json
- name: Setup Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install LibreOffice + zip
run: |
sudo apt-get update
sudo apt-get install -y libreoffice zip
- name: Setup Python deps for office fixtures
run: |
python -m pip install --upgrade pip
pip install -r tests/e2e/requirements.txt
- name: Build kkFileView
run: mvn -q -pl server -DskipTests package
- name: Install E2E deps
working-directory: tests/e2e
run: |
npm ci
npx playwright install --with-deps chromium
- name: Start fixture server
run: |
cd tests/e2e/fixtures
python3 -m http.server 18080 > /tmp/fixture-server.log 2>&1 &
- name: Start kkFileView
run: |
JAR_PATH=$(ls server/target/kkFileView-*.jar | head -n 1)
nohup env KK_TRUST_HOST='*' KK_NOT_TRUST_HOST='10.*,172.16.*,192.168.*' java -jar "$JAR_PATH" > /tmp/kkfileview.log 2>&1 &
- name: Wait for services
run: |
fixture_ready=false
for i in {1..60}; do
if curl -fsS http://127.0.0.1:18080/sample.txt >/dev/null; then
fixture_ready=true
break
fi
sleep 1
done
if [ "$fixture_ready" != "true" ]; then
echo "Error: fixture server did not become ready within 60 seconds." >&2
exit 1
fi
kkfileview_ready=false
for i in {1..120}; do
if curl -fsS http://127.0.0.1:8012/ >/dev/null; then
kkfileview_ready=true
break
fi
sleep 1
done
if [ "$kkfileview_ready" != "true" ]; then
echo "Error: kkFileView service did not become ready within 120 seconds." >&2
exit 1
fi
- name: Run nightly E2E suites
working-directory: tests/e2e
env:
KK_BASE_URL: http://127.0.0.1:8012
FIXTURE_BASE_URL: http://127.0.0.1:18080
E2E_MAX_PREVIEW_MS: 20000
run: npm run test:ci
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: nightly-playwright-report
path: tests/e2e/playwright-report
- name: Upload service logs
if: always()
uses: actions/upload-artifact@v4
with:
name: nightly-e2e-service-logs
path: |
/tmp/kkfileview.log
/tmp/fixture-server.log

100
.github/workflows/pr-e2e-mvp.yml vendored Normal file
View File

@@ -0,0 +1,100 @@
name: PR E2E MVP
on:
pull_request:
branches: [master]
workflow_dispatch:
permissions:
contents: read
jobs:
e2e-mvp:
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
cache: maven
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: tests/e2e/package-lock.json
- name: Setup Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install LibreOffice + zip
run: |
sudo apt-get update
sudo apt-get install -y libreoffice zip
- name: Setup Python deps for office fixtures
run: |
python -m pip install --upgrade pip
pip install -r tests/e2e/requirements.txt
- name: Build kkFileView
run: mvn -q -pl server -DskipTests package
- name: Install E2E deps
working-directory: tests/e2e
run: |
npm install
npx playwright install --with-deps chromium
- name: Start fixture server
run: |
cd tests/e2e/fixtures
python3 -m http.server 18080 > /tmp/fixture-server.log 2>&1 &
- name: Start kkFileView
run: |
JAR_PATH=$(ls server/target/kkFileView-*.jar | head -n 1)
nohup env KK_TRUST_HOST='*' KK_NOT_TRUST_HOST='10.*,172.16.*,192.168.*' java -jar "$JAR_PATH" > /tmp/kkfileview.log 2>&1 &
- name: Wait for services
run: |
for i in {1..60}; do
curl -fsS http://127.0.0.1:18080/sample.txt >/dev/null && break
sleep 1
done
for i in {1..120}; do
curl -fsS http://127.0.0.1:8012/ >/dev/null && break
sleep 1
done
- name: Run E2E
working-directory: tests/e2e
env:
KK_BASE_URL: http://127.0.0.1:8012
FIXTURE_BASE_URL: http://127.0.0.1:18080
run: npm test
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: tests/e2e/playwright-report
- name: Upload service logs
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-service-logs
path: |
/tmp/kkfileview.log
/tmp/fixture-server.log

View File

@@ -146,11 +146,15 @@ trust.host = *
### Q4: 如何允许子域名
目前不支持通配符域名匹配需要明确列出每个子域名
支持通配符域名匹配可使用 `*.example.com`
```properties
trust.host = cdn.example.com,api.example.com,storage.example.com
trust.host = *.example.com
```
说明
- `*.example.com` 会匹配 `cdn.example.com``api.internal.example.com`但不匹配根域 `example.com`
- 对于 IP 风格通配 `192.168.*``10.*`仅匹配字面量 IPv4 地址不匹配域名
## 🚨 安全事件响应
如果发现可疑的预览请求

View File

@@ -4,13 +4,19 @@ import cn.keking.config.ConfigConstants;
import cn.keking.utils.WebUtils;
import java.io.IOException;
import java.util.Map;
import java.util.Locale;
import java.util.concurrent.ConcurrentHashMap;
import java.nio.charset.StandardCharsets;
import java.util.Set;
import java.util.regex.Pattern;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.FilterConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.collections4.CollectionUtils;
import org.slf4j.Logger;
@@ -25,6 +31,7 @@ import org.springframework.util.FileCopyUtils;
public class TrustHostFilter implements Filter {
private static final Logger logger = LoggerFactory.getLogger(TrustHostFilter.class);
private final Map<String, Pattern> wildcardPatternCache = new ConcurrentHashMap<>();
private String notTrustHostHtmlView;
@Override
@@ -43,9 +50,16 @@ public class TrustHostFilter implements Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
String url = WebUtils.getSourceUrl(request);
String host = WebUtils.getHost(url);
assert host != null;
if (isNotTrustHost(host)) {
String html = this.notTrustHostHtmlView.replace("${current_host}", host);
String currentHost = host == null ? "UNKNOWN" : host;
if (response instanceof HttpServletResponse httpServletResponse) {
httpServletResponse.setStatus(HttpServletResponse.SC_FORBIDDEN);
}
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType("text/html;charset=UTF-8");
String html = this.notTrustHostHtmlView == null
? "<html><head><meta charset=\"utf-8\"></head><body>当前预览文件来自不受信任的站点:" + currentHost + "</body></html>"
: this.notTrustHostHtmlView.replace("${current_host}", currentHost);
response.getWriter().write(html);
response.getWriter().close();
} else {
@@ -54,9 +68,15 @@ public class TrustHostFilter implements Filter {
}
public boolean isNotTrustHost(String host) {
if (host == null || host.trim().isEmpty()) {
logger.warn("主机名为空或无效,拒绝访问");
return true;
}
// 如果配置了黑名单,优先检查黑名单
if (CollectionUtils.isNotEmpty(ConfigConstants.getNotTrustHostSet())) {
return ConfigConstants.getNotTrustHostSet().contains(host);
if (CollectionUtils.isNotEmpty(ConfigConstants.getNotTrustHostSet())
&& matchAnyPattern(host, ConfigConstants.getNotTrustHostSet())) {
return true;
}
// 如果配置了白名单,检查是否在白名单中
@@ -66,7 +86,7 @@ public class TrustHostFilter implements Filter {
logger.debug("允许所有主机访问(通配符模式): {}", host);
return false;
}
return !ConfigConstants.getTrustHostSet().contains(host);
return !matchAnyPattern(host, ConfigConstants.getTrustHostSet());
}
// 安全加固默认拒绝所有未配置的主机防止SSRF攻击
@@ -75,6 +95,136 @@ public class TrustHostFilter implements Filter {
return true;
}
private boolean matchAnyPattern(String host, Set<String> hostPatterns) {
String normalizedHost = host.toLowerCase(Locale.ROOT);
for (String hostPattern : hostPatterns) {
if (matchHostPattern(normalizedHost, hostPattern)) {
return true;
}
}
return false;
}
/**
* 支持三种匹配方式:
* 1. 精确匹配example.com
* 2. 通配符匹配:*.example.com、192.168.*
* 3. IPv4 CIDR192.168.0.0/16
*/
private boolean matchHostPattern(String host, String hostPattern) {
if (hostPattern == null || hostPattern.trim().isEmpty()) {
return false;
}
String pattern = hostPattern.trim().toLowerCase(Locale.ROOT);
if ("*".equals(pattern)) {
return true;
}
if (pattern.contains("/")) {
return matchIpv4Cidr(host, pattern);
}
if (pattern.contains("*")) {
if (isIpv4WildcardPattern(pattern)) {
return matchIpv4Wildcard(host, pattern);
}
Pattern compiledPattern = wildcardPatternCache.computeIfAbsent(pattern, key -> Pattern.compile(wildcardToRegex(key)));
return compiledPattern.matcher(host).matches();
}
return host.equals(pattern);
}
private boolean isIpv4WildcardPattern(String pattern) {
return pattern.matches("^[0-9.*]+$") && pattern.contains(".");
}
private boolean matchIpv4Wildcard(String host, String pattern) {
if (parseLiteralIpv4(host) == null) {
return false;
}
String[] hostParts = host.split("\\.");
String[] patternParts = pattern.split("\\.");
if (hostParts.length != 4 || patternParts.length < 1 || patternParts.length > 4) {
return false;
}
for (int i = 0; i < patternParts.length; i++) {
String p = patternParts[i];
if ("*".equals(p)) {
continue;
}
if (!p.equals(hostParts[i])) {
return false;
}
}
return true;
}
private String wildcardToRegex(String wildcard) {
StringBuilder regexBuilder = new StringBuilder("^");
String[] parts = wildcard.split("\\*", -1);
for (int i = 0; i < parts.length; i++) {
regexBuilder.append(Pattern.quote(parts[i]));
if (i < parts.length - 1) {
regexBuilder.append(".*");
}
}
regexBuilder.append("$");
return regexBuilder.toString();
}
private boolean matchIpv4Cidr(String host, String cidr) {
try {
String[] parts = cidr.split("/");
if (parts.length != 2) {
return false;
}
Long hostInt = parseLiteralIpv4(host);
Long networkInt = parseLiteralIpv4(parts[0]);
int prefixLength = Integer.parseInt(parts[1]);
if (hostInt == null || networkInt == null || prefixLength < 0 || prefixLength > 32) {
return false;
}
long mask = prefixLength == 0 ? 0L : (0xFFFFFFFFL << (32 - prefixLength)) & 0xFFFFFFFFL;
return (hostInt & mask) == (networkInt & mask);
} catch (NumberFormatException e) {
return false;
}
}
/**
* 仅解析字面量 IPv4 地址(不做 DNS 解析),防止 DNS rebinding/TOCTOU 风险。
*/
private Long parseLiteralIpv4(String input) {
if (input == null || input.trim().isEmpty()) {
return null;
}
String[] parts = input.trim().split("\\.");
if (parts.length != 4) {
return null;
}
long result = 0L;
for (String part : parts) {
if (part.isEmpty() || part.length() > 3) {
return null;
}
int value;
try {
value = Integer.parseInt(part);
} catch (NumberFormatException e) {
return null;
}
if (value < 0 || value > 255) {
return null;
}
result = (result << 8) | value;
}
return result;
}
@Override
public void destroy() {

View File

@@ -0,0 +1,92 @@
package cn.keking.web.filter;
import cn.keking.config.ConfigConstants;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
public class TrustHostFilterTests {
private final TrustHostFilter trustHostFilter = new TrustHostFilter();
@AfterEach
void tearDown() {
ConfigConstants.setTrustHostValue("default");
ConfigConstants.setNotTrustHostValue("default");
}
@Test
void shouldBlockWildcardNotTrustHostPattern() {
ConfigConstants.setTrustHostValue("*");
ConfigConstants.setNotTrustHostValue("192.168.*");
assert trustHostFilter.isNotTrustHost("192.168.1.10");
assert !trustHostFilter.isNotTrustHost("8.8.8.8");
assert !trustHostFilter.isNotTrustHost("192.168.evil.com");
}
@Test
void shouldBlockCidrNotTrustHostPattern() {
ConfigConstants.setTrustHostValue("*");
ConfigConstants.setNotTrustHostValue("10.0.0.0/8");
assert trustHostFilter.isNotTrustHost("10.1.2.3");
assert !trustHostFilter.isNotTrustHost("11.1.2.3");
// Ensure hostnames are not matched by CIDR-based not-trust rules (no DNS resolution)
assert !trustHostFilter.isNotTrustHost("localhost");
}
@Test
void shouldSupportHighBitIpv4InCidrMatching() {
ConfigConstants.setTrustHostValue("*");
ConfigConstants.setNotTrustHostValue("200.0.0.0/8");
assert trustHostFilter.isNotTrustHost("200.1.2.3");
assert !trustHostFilter.isNotTrustHost("199.1.2.3");
}
@Test
void shouldSupportIpv4UpperBoundaryCidrMatching() {
ConfigConstants.setTrustHostValue("*");
ConfigConstants.setNotTrustHostValue("255.255.255.255/32");
assert trustHostFilter.isNotTrustHost("255.255.255.255");
assert !trustHostFilter.isNotTrustHost("255.255.255.254");
}
@Test
void shouldDenyWhenHostIsBlankOrNull() {
ConfigConstants.setTrustHostValue("*");
ConfigConstants.setNotTrustHostValue("default");
assert trustHostFilter.isNotTrustHost(null);
assert trustHostFilter.isNotTrustHost(" ");
}
@Test
void shouldAllowWildcardTrustHostPattern() {
ConfigConstants.setTrustHostValue("*.trusted.com");
ConfigConstants.setNotTrustHostValue("default");
assert !trustHostFilter.isNotTrustHost("api.trusted.com");
assert trustHostFilter.isNotTrustHost("api.evil.com");
}
@Test
void shouldKeepBlacklistHigherPriorityThanWhitelist() {
ConfigConstants.setTrustHostValue("*");
ConfigConstants.setNotTrustHostValue("127.0.0.1,10.*");
assert trustHostFilter.isNotTrustHost("127.0.0.1");
assert trustHostFilter.isNotTrustHost("10.1.2.3");
assert !trustHostFilter.isNotTrustHost("8.8.8.8");
}
@Test
void shouldStillEnforceWhitelistWhenBlacklistConfigured() {
ConfigConstants.setTrustHostValue("internal.example.com");
ConfigConstants.setNotTrustHostValue("127.0.0.1");
assert !trustHostFilter.isNotTrustHost("internal.example.com");
assert trustHostFilter.isNotTrustHost("8.8.8.8");
}
}

9
tests/e2e/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
playwright-report/
test-results/
__pycache__/
fixtures/zip-tmp/
fixtures/sample.docx
fixtures/sample.xlsx
fixtures/sample.pptx

69
tests/e2e/README.md Normal file
View File

@@ -0,0 +1,69 @@
# kkFileView E2E MVP
This folder contains a first MVP of end-to-end automated tests.
## What is covered
- Basic preview smoke checks for common file types (txt/md/json/xml/csv/html/png)
- Office Phase-2 smoke checks (docx/xlsx/pptx)
- Archive smoke check (zip)
- Basic endpoint reachability
- Security regression checks for blocked internal-network hosts (`10.*`) on:
- `/onlinePreview`
- `/getCorsFile`
- Basic performance smoke checks (configurable threshold): txt/docx/xlsx preview response time
- CI combined run command available via `npm run test:ci`
## Local run
1. Build server jar:
```bash
mvn -q -pl server -DskipTests package
```
2. Install deps + browser:
```bash
cd tests/e2e
npm install
npx playwright install --with-deps chromium
pip3 install -r requirements.txt
```
> Prerequisite: ensure `zip` command is available in PATH (used for `sample.zip` fixture generation).
3. Generate fixtures and start fixture server:
```bash
cd /path/to/kkFileView
npm run gen:all
cd tests/e2e/fixtures && python3 -m http.server 18080
```
4. Start kkFileView in another terminal:
```bash
JAR_PATH=$(ls server/target/kkFileView-*.jar | head -n 1)
KK_TRUST_HOST='*' KK_NOT_TRUST_HOST='10.*,172.16.*,192.168.*' java -jar "$JAR_PATH"
```
5. Run tests:
```bash
cd tests/e2e
KK_BASE_URL=http://127.0.0.1:8012 FIXTURE_BASE_URL=http://127.0.0.1:18080 npm test
```
Optional:
```bash
# smoke only (self-contained: will auto-generate fixtures)
npm run test:smoke
# perf smoke (self-contained; default threshold 15000ms)
E2E_MAX_PREVIEW_MS=15000 npm run test:perf
# CI-style combined run (single fixture generation)
E2E_MAX_PREVIEW_MS=20000 npm run test:ci
```

View File

@@ -0,0 +1,3 @@
name,value
kkFileView,1
e2e,1
1 name value
2 kkFileView 1
3 e2e 1

View File

@@ -0,0 +1 @@
<!doctype html><html><body><h1>kkFileView fixture</h1></body></html>

View File

@@ -0,0 +1,4 @@
{
"app": "kkFileView",
"e2e": true
}

View File

@@ -0,0 +1,3 @@
# kkFileView
This is a markdown fixture.

View File

@@ -0,0 +1,19 @@
%PDF-1.1
1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj
2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj
3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>endobj
4 0 obj<< /Length 44 >>stream
BT /F1 12 Tf 72 120 Td (kkFileView e2e pdf) Tj ET
endstream
endobj
xref
0 5
0000000000 65535 f
0000000010 00000 n
0000000060 00000 n
0000000117 00000 n
0000000212 00000 n
trailer<< /Root 1 0 R /Size 5 >>
startxref
306
%%EOF

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@@ -0,0 +1 @@
kkFileView e2e sample text

View File

@@ -0,0 +1 @@
<root><name>kkFileView</name><e2e>true</e2e></root>

Binary file not shown.

78
tests/e2e/package-lock.json generated Normal file
View File

@@ -0,0 +1,78 @@
{
"name": "kkfileview-e2e",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kkfileview-e2e",
"version": "0.1.0",
"devDependencies": {
"@playwright/test": "^1.55.0"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

22
tests/e2e/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "kkfileview-e2e",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"gen:fixtures": "node ./scripts/generate-fixtures.mjs",
"gen:office": "python3 ./scripts/generate-office-fixtures.py",
"gen:all": "npm run gen:fixtures && npm run gen:office",
"pretest": "npm run gen:all",
"test": "playwright test",
"test:headed": "playwright test --headed",
"pretest:smoke": "npm run gen:all",
"test:smoke": "playwright test specs/preview-smoke.spec.ts",
"pretest:perf": "npm run gen:all",
"test:perf": "playwright test specs/perf-smoke.spec.ts",
"test:ci": "npm run gen:all && playwright test specs/preview-smoke.spec.ts specs/perf-smoke.spec.ts"
},
"devDependencies": {
"@playwright/test": "^1.55.0"
}
}

View File

@@ -0,0 +1,13 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './specs',
timeout: 30_000,
expect: { timeout: 10_000 },
retries: process.env.CI ? 1 : 0,
reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
use: {
baseURL: process.env.KK_BASE_URL || 'http://127.0.0.1:8012',
trace: 'on-first-retry',
},
});

View File

@@ -0,0 +1,3 @@
python-docx==1.1.2
openpyxl==3.1.5
python-pptx==1.0.2

View File

@@ -0,0 +1,50 @@
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const fixturesDir = path.resolve(__dirname, '..', 'fixtures');
fs.mkdirSync(fixturesDir, { recursive: true });
const write = (name, content) => fs.writeFileSync(path.join(fixturesDir, name), content);
write('sample.txt', 'kkFileView e2e sample text');
write('sample.md', '# kkFileView\n\nThis is a markdown fixture.');
write('sample.json', JSON.stringify({ app: 'kkFileView', e2e: true }, null, 2));
write('sample.xml', '<root><name>kkFileView</name><e2e>true</e2e></root>');
write('sample.csv', 'name,value\nkkFileView,1\ne2e,1\n');
write('sample.html', '<!doctype html><html><body><h1>kkFileView fixture</h1></body></html>');
// zip (contains txt) - only generate if missing to avoid noisy local diffs
const zipPath = path.join(fixturesDir, 'sample.zip');
if (!fs.existsSync(zipPath)) {
const zipWork = path.join(fixturesDir, 'zip-tmp');
fs.mkdirSync(zipWork, { recursive: true });
fs.writeFileSync(path.join(zipWork, 'inner.txt'), 'kkFileView zip inner file');
try {
execFileSync('zip', ['-X', '-q', '-r', zipPath, 'inner.txt'], { cwd: zipWork });
} catch (err) {
console.error('Failed to create sample.zip fixture. Ensure "zip" is installed and available in PATH.');
throw err instanceof Error ? err : new Error(String(err));
} finally {
fs.rmSync(zipWork, { recursive: true, force: true });
}
}
// 1x1 png
write(
'sample.png',
Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Zx1sAAAAASUVORK5CYII=',
'base64'
)
);
// tiny valid pdf
write(
'sample.pdf',
`%PDF-1.1\n1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj\n2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj\n3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>endobj\n4 0 obj<< /Length 44 >>stream\nBT /F1 12 Tf 72 120 Td (kkFileView e2e pdf) Tj ET\nendstream\nendobj\nxref\n0 5\n0000000000 65535 f \n0000000010 00000 n \n0000000060 00000 n \n0000000117 00000 n \n0000000212 00000 n \ntrailer<< /Root 1 0 R /Size 5 >>\nstartxref\n306\n%%EOF\n`
);
console.log('fixtures generated in', fixturesDir);

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env python3
from pathlib import Path
from docx import Document
from openpyxl import Workbook
from pptx import Presentation
fixtures = Path(__file__).resolve().parent.parent / "fixtures"
fixtures.mkdir(parents=True, exist_ok=True)
# DOCX
_doc = Document()
_doc.add_heading("kkFileView E2E", level=1)
_doc.add_paragraph("This is a DOCX fixture for Phase-2 E2E.")
_doc.save(fixtures / "sample.docx")
# XLSX
_wb = Workbook()
_ws = _wb.active
_ws.title = "Sheet1"
_ws["A1"] = "name"
_ws["B1"] = "value"
_ws["A2"] = "kkFileView"
_ws["B2"] = 2
_wb.save(fixtures / "sample.xlsx")
# PPTX
_prs = Presentation()
slide_layout = _prs.slide_layouts[1]
slide = _prs.slides.add_slide(slide_layout)
slide.shapes.title.text = "kkFileView E2E"
slide.placeholders[1].text = "This is a PPTX fixture for Phase-2 E2E."
_prs.save(fixtures / "sample.pptx")
print("office fixtures generated in", fixtures)

View File

@@ -0,0 +1,49 @@
import { test, expect, request as playwrightRequest } from '@playwright/test';
import type { APIRequestContext } from '@playwright/test';
const fixtureBase = process.env.FIXTURE_BASE_URL || 'http://127.0.0.1:18080';
const DEFAULT_MAX_MS = 15000;
const envMaxMs = Number(process.env.E2E_MAX_PREVIEW_MS);
const maxMs = Number.isFinite(envMaxMs) && envMaxMs >= 1 ? Math.floor(envMaxMs) : DEFAULT_MAX_MS;
function b64(v: string): string {
return Buffer.from(v).toString('base64');
}
async function timedPreview(request: APIRequestContext, fileUrl: string) {
const started = Date.now();
const resp = await request.get(`/onlinePreview?url=${encodeURIComponent(b64(fileUrl))}`);
const elapsed = Date.now() - started;
return { resp, elapsed };
}
test.beforeAll(async () => {
const api = await playwrightRequest.newContext();
const required = ['sample.txt', 'sample.docx', 'sample.xlsx'];
try {
for (const name of required) {
const resp = await api.get(`${fixtureBase}/${name}`);
expect(resp.ok(), `fixture missing or unavailable: ${name}`).toBeTruthy();
}
} finally {
await api.dispose();
}
});
test('perf: txt preview response under threshold', async ({ request }) => {
const { resp, elapsed } = await timedPreview(request, `${fixtureBase}/sample.txt`);
expect(resp.status()).toBe(200);
expect(elapsed).toBeLessThan(maxMs);
});
test('perf: docx preview response under threshold', async ({ request }) => {
const { resp, elapsed } = await timedPreview(request, `${fixtureBase}/sample.docx`);
expect(resp.status()).toBe(200);
expect(elapsed).toBeLessThan(maxMs);
});
test('perf: xlsx preview response under threshold', async ({ request }) => {
const { resp, elapsed } = await timedPreview(request, `${fixtureBase}/sample.xlsx`);
expect(resp.status()).toBe(200);
expect(elapsed).toBeLessThan(maxMs);
});

View File

@@ -0,0 +1,111 @@
import { test, expect, request as playwrightRequest } from '@playwright/test';
const fixtureBase = process.env.FIXTURE_BASE_URL || 'http://127.0.0.1:18080';
function b64(v: string): string {
return Buffer.from(v).toString('base64');
}
async function openPreview(request: any, fileUrl: string) {
const encoded = encodeURIComponent(b64(fileUrl));
return request.get(`/onlinePreview?url=${encoded}`);
}
test.beforeAll(async () => {
const api = await playwrightRequest.newContext();
const required = [
'sample.txt',
'sample.md',
'sample.json',
'sample.xml',
'sample.csv',
'sample.html',
'sample.png',
'sample.docx',
'sample.xlsx',
'sample.pptx',
'sample.zip',
];
try {
for (const name of required) {
const resp = await api.get(`${fixtureBase}/${name}`);
expect(resp.ok(), `fixture missing or unavailable: ${name}`).toBeTruthy();
}
} finally {
await api.dispose();
}
});
test('01 home/index reachable', async ({ request }) => {
const resp = await request.get('/');
expect(resp.status()).toBeLessThan(500);
});
test('02 txt preview', async ({ request }) => {
const resp = await openPreview(request, `${fixtureBase}/sample.txt`);
expect(resp.status()).toBe(200);
});
test('03 markdown preview', async ({ request }) => {
const resp = await openPreview(request, `${fixtureBase}/sample.md`);
expect(resp.status()).toBe(200);
});
test('04 json preview', async ({ request }) => {
const resp = await openPreview(request, `${fixtureBase}/sample.json`);
expect(resp.status()).toBe(200);
});
test('05 xml preview', async ({ request }) => {
const resp = await openPreview(request, `${fixtureBase}/sample.xml`);
expect(resp.status()).toBe(200);
});
test('06 csv preview', async ({ request }) => {
const resp = await openPreview(request, `${fixtureBase}/sample.csv`);
expect(resp.status()).toBe(200);
});
test('07 html preview', async ({ request }) => {
const resp = await openPreview(request, `${fixtureBase}/sample.html`);
expect(resp.status()).toBe(200);
});
test('08 png preview', async ({ request }) => {
const resp = await openPreview(request, `${fixtureBase}/sample.png`);
expect(resp.status()).toBe(200);
});
test('09 docx preview', async ({ request }) => {
const resp = await openPreview(request, `${fixtureBase}/sample.docx`);
expect(resp.status()).toBe(200);
});
test('10 xlsx preview', async ({ request }) => {
const resp = await openPreview(request, `${fixtureBase}/sample.xlsx`);
expect(resp.status()).toBe(200);
});
test('11 pptx preview', async ({ request }) => {
const resp = await openPreview(request, `${fixtureBase}/sample.pptx`);
expect(resp.status()).toBe(200);
});
test('12 zip preview', async ({ request }) => {
const resp = await openPreview(request, `${fixtureBase}/sample.zip`);
expect(resp.status()).toBe(200);
});
test('13 security: block 10.x host in onlinePreview', async ({ request }) => {
const resp = await openPreview(request, `http://10.1.2.3/a.pdf`);
const body = await resp.text();
expect(body).toContain('不受信任');
});
test('14 security: block 10.x host in getCorsFile', async ({ request }) => {
const encoded = b64('http://10.1.2.3/a.pdf');
const resp = await request.get(`/getCorsFile?urlPath=${encoded}`);
const body = await resp.text();
expect(body).toContain('不受信任');
});