diff --git a/.github/scripts/deploy_windows_winrm.py b/.github/scripts/deploy_windows_winrm.py
index 93f794544..c86f20be1 100644
--- a/.github/scripts/deploy_windows_winrm.py
+++ b/.github/scripts/deploy_windows_winrm.py
@@ -40,6 +40,8 @@ def main() -> int:
"KK_DEPLOY_MVN_CMD": optional_env("KK_DEPLOY_MVN_CMD", r"C:\kkFileView-tools\maven\bin\mvn.cmd"),
"KK_DEPLOY_MAVEN_SETTINGS": optional_env("KK_DEPLOY_MAVEN_SETTINGS", ""),
"KK_DEPLOY_DRY_RUN": optional_env("KK_DEPLOY_DRY_RUN", "false").lower(),
+ "KK_DEPLOY_REVISION": optional_env("KK_DEPLOY_REVISION", ""),
+ "KK_DEPLOY_HARDEN_ACTUATOR": optional_env("KK_DEPLOY_HARDEN_ACTUATOR", "false").lower(),
}
script_path = pathlib.Path(__file__).with_name("remote_windows_deploy.ps1")
diff --git a/.github/scripts/remote_windows_deploy.ps1 b/.github/scripts/remote_windows_deploy.ps1
index bc0901ed7..2a6828e14 100644
--- a/.github/scripts/remote_windows_deploy.ps1
+++ b/.github/scripts/remote_windows_deploy.ps1
@@ -41,6 +41,8 @@ $GitExe = Get-OptionalEnv 'KK_DEPLOY_GIT_EXE' 'C:\kkFileView-tools\git\cmd\git.e
$MvnCmd = Get-OptionalEnv 'KK_DEPLOY_MVN_CMD' 'C:\kkFileView-tools\maven\bin\mvn.cmd'
$MavenSettings = Get-OptionalEnv 'KK_DEPLOY_MAVEN_SETTINGS' ''
$DryRun = Get-OptionalEnv 'KK_DEPLOY_DRY_RUN' 'false'
+$ExpectedRevision = Get-OptionalEnv 'KK_DEPLOY_REVISION' ''
+$HardenActuator = (Get-OptionalEnv 'KK_DEPLOY_HARDEN_ACTUATOR' 'false') -eq 'true'
$BinDir = Join-Path $DeployRoot 'bin'
$StartupScript = Join-Path $BinDir 'startup.bat'
@@ -202,6 +204,18 @@ function Build-KkFileView {
}
Sync-Repository
+if (-not [string]::IsNullOrWhiteSpace($ExpectedRevision)) {
+ if ($ExpectedRevision -notmatch '^[0-9a-f]{40}$') {
+ throw 'Deployment revision must be a full commit SHA'
+ }
+ Invoke-External -FilePath $GitExe -Arguments @('fetch', '--depth', '1', 'origin', $ExpectedRevision) -WorkingDirectory $SourceRoot
+ Invoke-External -FilePath $GitExe -Arguments @('checkout', '--detach', $ExpectedRevision) -WorkingDirectory $SourceRoot
+}
+$SourceRevision = (& $GitExe -C $SourceRoot rev-parse HEAD).Trim()
+if ($LASTEXITCODE -ne 0 -or ($ExpectedRevision -and $SourceRevision -ne $ExpectedRevision)) {
+ throw 'Source revision does not match the requested deployment commit'
+}
+Write-Step "Building source revision: $SourceRevision"
Build-KkFileView
$DownloadedJars = Get-ChildItem $BuildOutputDir -Filter 'kkFileView-*.jar' -File
@@ -295,33 +309,118 @@ function Wait-Health {
return $false
}
-Write-Step "Backing up current jar to $BackupJar"
-Copy-Item $JarPath $BackupJar -Force
+function Resolve-ActiveConfigPath {
+ param([string[]]$CommandLines, [string]$WorkingDirectory)
+ $Paths = @()
+ foreach ($CommandLine in $CommandLines) {
+ $MatchesFound = [regex]::Matches($CommandLine, '(?:-D|--)spring\.config\.location=(?:"([^"]+)"|([^\s"<>]+))')
+ foreach ($Match in $MatchesFound) {
+ $Value = $Match.Groups[1].Value
+ if (-not $Value) { $Value = $Match.Groups[2].Value }
+ $Value = $Value -replace '^file:', ''
+ if ($Value -match '[,;*?%$]' -or $Value -notmatch '\.properties$') {
+ throw 'Security config migration requires one explicit .properties file'
+ }
+ if (-not [System.IO.Path]::IsPathRooted($Value)) {
+ $Value = Join-Path $WorkingDirectory $Value
+ }
+ $Paths += [System.IO.Path]::GetFullPath($Value)
+ }
+ }
+ $Paths = @($Paths | Select-Object -Unique)
+ if ($Paths.Count -ne 1 -or -not (Test-Path -LiteralPath $Paths[0] -PathType Leaf)) {
+ throw 'Unable to identify a unique active external configuration file'
+ }
+ return $Paths[0]
+}
-Stop-KkFileView
-if (-not (Wait-KkFileViewStopped)) {
- throw "Timed out waiting for the previous kkFileView process to exit"
+function Set-SecureActuatorDefaults {
+ param([string]$ConfigPath)
+ # Round-trip unrelated bytes unchanged, regardless of the existing encoding.
+ $Encoding = [System.Text.Encoding]::GetEncoding(28591)
+ $Content = $Encoding.GetString([System.IO.File]::ReadAllBytes($ConfigPath))
+ $NewLine = "`n"
+ if ($Content.Contains("`r`n")) { $NewLine = "`r`n" }
+ foreach ($Entry in @(
+ @('management.endpoints.web.exposure.include', 'health'),
+ @('management.endpoint.health.show-details', 'never')
+ )) {
+ $Pattern = '(?m)^[ \t]*' + [regex]::Escape($Entry[0]) + '[ \t]*[=:][^\r\n]*'
+ $Line = $Entry[0] + ' = ' + $Entry[1]
+ if ([regex]::IsMatch($Content, $Pattern)) {
+ $Content = [regex]::Replace($Content, $Pattern, $Line)
+ } else {
+ $Content += $NewLine + $Line + $NewLine
+ }
+ }
+ [System.IO.File]::WriteAllBytes($ConfigPath, $Encoding.GetBytes($Content))
}
-Write-Step "Replacing jar with artifact output"
-Copy-Item $DownloadedJar.FullName $JarPath -Force
+function Assert-SecureActuatorExposure {
+ param([string]$Url)
+ $HealthEndpoint = [System.Uri]::new([System.Uri]$Url, 'actuator/health').AbsoluteUri
+ $MetricsEndpoint = [System.Uri]::new([System.Uri]$Url, 'actuator/metrics').AbsoluteUri
+ $Health = Invoke-RestMethod -Uri $HealthEndpoint -TimeoutSec 10
+ if ($Health.status -ne 'UP' -or @($Health.PSObject.Properties).Count -ne 1) {
+ throw 'Health endpoint still exposes component details or is unhealthy'
+ }
+ try {
+ $null = Invoke-WebRequest -Uri $MetricsEndpoint -UseBasicParsing -TimeoutSec 10
+ throw 'Metrics endpoint remains publicly exposed'
+ } catch {
+ if (-not $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -ne 404) {
+ throw
+ }
+ }
+ Write-Step 'Security verification passed: aggregate health only; metrics returns 404'
+}
-Start-KkFileView
+$ActiveConfig = $null
+$BackupConfig = $null
+if ($HardenActuator) {
+ $CommandLines = @(Get-KkFileViewJavaProcesses | ForEach-Object { $_.CommandLine })
+ if ($CommandLines.Count -eq 0) { $CommandLines = @(Get-Content -LiteralPath $StartupScript -Raw) }
+ $ActiveConfig = Resolve-ActiveConfigPath -CommandLines $CommandLines -WorkingDirectory $BinDir
+ $BackupConfig = Join-Path $ReleaseDir ("{0}.{1}.bak" -f [System.IO.Path]::GetFileName($ActiveConfig), $Timestamp)
+ Write-Step "Active configuration: $ActiveConfig"
+ Write-Step "Backing up active configuration to $BackupConfig"
+ Copy-Item -LiteralPath $ActiveConfig -Destination $BackupConfig
+}
+
+Write-Step "Backing up current jar to $BackupJar"
+Copy-Item $JarPath $BackupJar -Force
-if (-not (Wait-Health -Url $HealthUrl)) {
- Write-Step "Health check failed, rolling back"
+try {
Stop-KkFileView
if (-not (Wait-KkFileViewStopped)) {
- throw "Timed out waiting for the failed kkFileView process to exit during rollback"
+ throw "Timed out waiting for the previous kkFileView process to exit"
}
- Copy-Item $BackupJar $JarPath -Force
+ if ($HardenActuator) { Set-SecureActuatorDefaults -ConfigPath $ActiveConfig }
+ Write-Step "Replacing jar with artifact output: $($DownloadedJar.Name)"
+ Copy-Item $DownloadedJar.FullName $JarPath -Force
Start-KkFileView
-
if (-not (Wait-Health -Url $HealthUrl)) {
- throw "Deployment failed and rollback health check also failed"
+ throw 'Deployment health check failed'
}
-
- throw "Deployment failed, rollback completed successfully"
+ if ($HardenActuator) { Assert-SecureActuatorExposure -Url $HealthUrl }
+} catch {
+ $DeploymentError = $_
+ Write-Step 'Deployment verification failed, restoring jar and configuration'
+ Stop-KkFileView
+ if (-not (Wait-KkFileViewStopped)) { throw 'Unable to stop the failed deployment for rollback' }
+ Copy-Item $BackupJar $JarPath -Force
+ if ($BackupConfig) { Copy-Item -LiteralPath $BackupConfig -Destination $ActiveConfig -Force }
+ Start-KkFileView
+ if (-not (Wait-Health -Url $HealthUrl)) { throw 'Deployment and rollback health checks both failed' }
+ throw "Deployment failed; rollback succeeded: $DeploymentError"
}
+@{
+ revision = $SourceRevision
+ artifact = $DownloadedJar.Name
+ sha256 = (Get-FileHash -LiteralPath $JarPath -Algorithm SHA256).Hash
+ actuatorHardened = $HardenActuator
+ deployedAt = (Get-Date).ToUniversalTime().ToString('o')
+} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $DeployRoot 'deployment.json') -Encoding UTF8
+Write-Step "Verified deployed revision: $SourceRevision"
Write-Step "Deployment completed successfully"
diff --git a/.github/scripts/test_windows_deploy.ps1 b/.github/scripts/test_windows_deploy.ps1
new file mode 100644
index 000000000..486cc50fb
--- /dev/null
+++ b/.github/scripts/test_windows_deploy.ps1
@@ -0,0 +1,42 @@
+$ErrorActionPreference = 'Stop'
+$Tokens = $null
+$ParseErrors = $null
+$ScriptPath = Join-Path $PSScriptRoot 'remote_windows_deploy.ps1'
+$Ast = [System.Management.Automation.Language.Parser]::ParseFile($ScriptPath, [ref]$Tokens, [ref]$ParseErrors)
+if ($ParseErrors.Count -gt 0) { throw ($ParseErrors | Out-String) }
+foreach ($Name in @('Resolve-ActiveConfigPath', 'Set-SecureActuatorDefaults')) {
+ $FunctionAst = $Ast.Find({ param($Node) $Node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $Node.Name -eq $Name }, $true)
+ if (-not $FunctionAst) { throw "Missing function: $Name" }
+ . ([scriptblock]::Create($FunctionAst.Extent.Text))
+}
+
+$TempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('kkfileview-deploy-test-' + [guid]::NewGuid().ToString('N'))
+try {
+ $Bin = New-Item -ItemType Directory -Path (Join-Path $TempRoot 'bin')
+ $Config = New-Item -ItemType Directory -Path (Join-Path $TempRoot 'config')
+ $ActiveFile = Join-Path $Config.FullName 'test.properties'
+ $Utf8 = New-Object System.Text.UTF8Encoding($false)
+ $Original = "# Preserve Unicode: 中文`r`ncustom.value = unchanged`r`nmanagement.endpoints.web.exposure.include = health,info,metrics`r`nmanagement.endpoint.health.show-details = always`r`n"
+ [System.IO.File]::WriteAllText($ActiveFile, $Original, $Utf8)
+ $Resolved = Resolve-ActiveConfigPath -CommandLines @('java -Dspring.config.location=..\config\test.properties -jar kkFileView-5.0.jar') -WorkingDirectory $Bin.FullName
+ if ($Resolved -ne $ActiveFile) { throw 'Did not resolve the runtime configuration override' }
+ Set-SecureActuatorDefaults -ConfigPath $Resolved
+ $Expected = $Original.Replace('health,info,metrics', 'health').Replace('show-details = always', 'show-details = never')
+ $Actual = [System.IO.File]::ReadAllText($Resolved, $Utf8)
+ if ($Actual -cne $Expected) { throw 'Migration altered unrelated configuration or missed a security default' }
+ Set-SecureActuatorDefaults -ConfigPath $Resolved
+ if ([System.IO.File]::ReadAllText($Resolved, $Utf8) -cne $Expected) { throw 'Migration is not idempotent' }
+
+ [System.IO.File]::WriteAllText($ActiveFile, 'custom.value = unchanged', $Utf8)
+ Set-SecureActuatorDefaults -ConfigPath $ActiveFile
+ $Actual = [System.IO.File]::ReadAllText($ActiveFile, $Utf8)
+ if ($Actual -notmatch 'management.endpoints.web.exposure.include = health' -or $Actual -notmatch 'management.endpoint.health.show-details = never') {
+ throw 'Missing properties were not added'
+ }
+ $Rejected = $false
+ try { Resolve-ActiveConfigPath -CommandLines @('java -jar app.jar') -WorkingDirectory $Bin.FullName } catch { $Rejected = $true }
+ if (-not $Rejected) { throw 'Unknown active configuration must fail closed' }
+ Write-Host 'Windows deployment syntax and configuration migration tests passed'
+} finally {
+ if (Test-Path $TempRoot) { Remove-Item -LiteralPath $TempRoot -Recurse -Force }
+}
diff --git a/.github/workflows/master-auto-deploy.yml b/.github/workflows/master-auto-deploy.yml
index cfc0ef726..b1507be93 100644
--- a/.github/workflows/master-auto-deploy.yml
+++ b/.github/workflows/master-auto-deploy.yml
@@ -4,6 +4,11 @@ on:
push:
branches: [ master ]
workflow_dispatch:
+ inputs:
+ harden_actuator:
+ description: Apply v5.0.3 monitoring defaults to the active external config
+ type: boolean
+ default: false
concurrency:
group: master-auto-deploy-production
@@ -29,6 +34,8 @@ jobs:
KK_DEPLOY_GIT_EXE: ${{ vars.KK_DEPLOY_GIT_EXE }}
KK_DEPLOY_MVN_CMD: ${{ vars.KK_DEPLOY_MVN_CMD }}
KK_DEPLOY_MAVEN_SETTINGS: ${{ vars.KK_DEPLOY_MAVEN_SETTINGS }}
+ KK_DEPLOY_REVISION: ${{ github.sha }}
+ KK_DEPLOY_HARDEN_ACTUATOR: ${{ inputs.harden_actuator || 'false' }}
steps:
- name: Checkout repository
diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml
index 1e4c554c5..ecfe28737 100644
--- a/.github/workflows/maven.yml
+++ b/.github/workflows/maven.yml
@@ -35,6 +35,18 @@ jobs:
- name: Build with Maven
run: mvn -B package "-Dmaven.test.skip=true" --file pom.xml
+ - name: Security regression tests
+ if: runner.os == 'Linux'
+ run: >-
+ mvn -B -pl server
+ -Dtest=FtpCommandSecurityTests,UploadPathSecurityTests,PreviewSourceSecurityTests,PreviewParameterSecurityTests,ArchiveDirectorySecurityTests,ActuatorExposureSecurityTests,ActuatorOverrideExposureSecurityTests,ArchiveTraversalSecurityTests,FileControllerPathSecurityTests,FileControllerDeleteSecurityTests
+ test
+
+ - name: Validate Windows deployment
+ if: runner.os == 'Windows'
+ shell: powershell
+ run: .github/scripts/test_windows_deploy.ps1
+
- name: Upload Linux distribution package
if: success() && runner.os == 'Linux'
uses: actions/upload-artifact@v4
diff --git a/Dockerfile b/Dockerfile
index 165f8a022..6d0618081 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
FROM keking/kkfileview-base:5.0.0
ADD server/target/kkFileView-*.tar.gz /opt/
-ENV KKFILEVIEW_BIN_FOLDER=/opt/kkFileView-5.0.2/bin
-ENTRYPOINT ["java","-Dfile.encoding=UTF-8","-Dspring.config.location=/opt/kkFileView-5.0.2/config/application.properties","-jar","/opt/kkFileView-5.0.2/bin/kkFileView-5.0.2.jar"]
+ENV KKFILEVIEW_BIN_FOLDER=/opt/kkFileView-5.0.3/bin
+ENTRYPOINT ["java","-Dfile.encoding=UTF-8","-Dspring.config.location=/opt/kkFileView-5.0.3/config/application.properties","-jar","/opt/kkFileView-5.0.3/bin/kkFileView-5.0.3.jar"]
diff --git a/README.cn.md b/README.cn.md
index d2332a4e4..6a3e8739b 100644
--- a/README.cn.md
+++ b/README.cn.md
@@ -149,6 +149,22 @@ pdf预览模式预览效果如下
### 历史更新记录
+#### > 2026年10月08日,v5.0.3 安全补丁版本发布:
+
+#### 安全修复
+1. FTP 连接前校验命令参数,阻止控制字符注入
+2. 上传和建目录限制在演示文件目录内,并检查符号链接
+3. 校验所有预览源地址,阻止通过不同参数绕过信任策略
+4. 预览页码和代理参数经过类型校验后再渲染模板
+5. 在通过校验的压缩包预览中生成目录树,移除公开的 `/directory` 接口
+6. 默认仅开放汇总健康状态,不再默认暴露健康详情、info 和 metrics
+
+#### 升级说明
+1. 建议所有 v5.0.2 及更早版本用户升级至 v5.0.3,继续要求 JDK 21 及以上
+2. 已有外部配置文件需设置 `management.endpoints.web.exposure.include=health` 和 `management.endpoint.health.show-details=never`,才能应用监控配置加固
+3. `/directory` 接口已移除,内置压缩包目录预览继续可用
+4. 上传仍默认禁用;启用后,上传和建目录仅允许演示目录内的路径
+
#### > 2026年08月14日,v5.0.2 补丁版本发布 :
#### 安全修复
diff --git a/README.md b/README.md
index 1284f36d3..22fce91ef 100644
--- a/README.md
+++ b/README.md
@@ -65,6 +65,22 @@ URL:[https://file.kkview.cn](https://file.kkview.cn)
## Change History
+### Version 5.0.3 (October 8, 2026)
+
+#### Security Fixes
+1. Reject control characters in FTP command arguments before connecting
+2. Confine uploads and folder creation to demo storage, including symbolic-link checks
+3. Validate every preview source URL to prevent parameter-confusion bypasses of trust policies
+4. Validate page and agent parameters before rendering preview templates
+5. Render archive directory trees within the validated preview flow and remove the public `/directory` endpoint
+6. Expose only aggregate health status by default; detailed health, info, and metrics are no longer public defaults
+
+#### Upgrade Notes
+1. All v5.0.2 and earlier users should upgrade to v5.0.3; JDK 21 or higher remains required
+2. Existing external configuration files must set `management.endpoints.web.exposure.include=health` and `management.endpoint.health.show-details=never` to apply the monitoring hardening
+3. The `/directory` endpoint is removed; the built-in archive preview remains supported
+4. Uploads remain disabled by default. Enabled uploads and folder creation accept only paths beneath demo storage
+
### Version 5.0.2 (August 14, 2026)
#### Security Fixes
diff --git a/pom.xml b/pom.xml
index 7c46e332e..ebdcf33d1 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
cn.keking
kkFileView-parent
- 5.0.2
+ 5.0.3
diff --git a/server/pom.xml b/server/pom.xml
index b1679048d..0ab813349 100644
--- a/server/pom.xml
+++ b/server/pom.xml
@@ -6,7 +6,7 @@
kkFileView-parent
cn.keking
- 5.0.2
+ 5.0.3
kkFileView
diff --git a/server/src/main/config/application.properties b/server/src/main/config/application.properties
index 3e3913bcb..9bc1bd9e7 100644
--- a/server/src/main/config/application.properties
+++ b/server/src/main/config/application.properties
@@ -35,8 +35,8 @@ spring.freemarker.request-context-attribute = request
spring.freemarker.suffix = .ftl
# Spring Boot Actuator监控端点配置
-management.endpoints.web.exposure.include = health,info,metrics
-management.endpoint.health.show-details = always
+management.endpoints.web.exposure.include = health
+management.endpoint.health.show-details = never
management.health.defaults.enabled = true
diff --git a/server/src/main/config/test.properties b/server/src/main/config/test.properties
index 650701e05..45bf7bdf2 100644
--- a/server/src/main/config/test.properties
+++ b/server/src/main/config/test.properties
@@ -35,8 +35,8 @@ spring.freemarker.request-context-attribute = request
spring.freemarker.suffix = .ftl
# Spring Boot Actuator监控端点配置
-management.endpoints.web.exposure.include = health,info,metrics
-management.endpoint.health.show-details = always
+management.endpoints.web.exposure.include = health
+management.endpoint.health.show-details = never
management.health.defaults.enabled = true
diff --git a/server/src/main/java/cn/keking/service/CompressFileReader.java b/server/src/main/java/cn/keking/service/CompressFileReader.java
index b3e15f9eb..c98ac72e7 100644
--- a/server/src/main/java/cn/keking/service/CompressFileReader.java
+++ b/server/src/main/java/cn/keking/service/CompressFileReader.java
@@ -35,13 +35,13 @@
@Component
public class CompressFileReader {
private final FileHandlerService fileHandlerService;
- private static final String fileDir = ConfigConstants.getFileDir();
public CompressFileReader(FileHandlerService fileHandlerService) {
this.fileHandlerService = fileHandlerService;
}
public String unRar(String filePath, String filePassword, String fileName, FileAttribute fileAttribute) throws Exception {
+ String fileDir = ConfigConstants.getFileDir();
List imgUrls = new ArrayList<>();
String baseUrl = BaseUrlFilter.getBaseUrl();
String packagePath = "_";
diff --git a/server/src/main/java/cn/keking/service/impl/CompressFilePreviewImpl.java b/server/src/main/java/cn/keking/service/impl/CompressFilePreviewImpl.java
index 13fc0791f..de817baaf 100644
--- a/server/src/main/java/cn/keking/service/impl/CompressFilePreviewImpl.java
+++ b/server/src/main/java/cn/keking/service/impl/CompressFilePreviewImpl.java
@@ -8,6 +8,8 @@
import cn.keking.service.FileHandlerService;
import cn.keking.service.CompressFileReader;
import cn.keking.utils.KkFileUtils;
+import cn.keking.utils.RarUtils;
+import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.poi.EncryptedDocumentException;
import org.slf4j.Logger;
@@ -78,6 +80,12 @@ public String filePreviewHandle(String url, Model model, FileAttribute fileAttri
}
model.addAttribute("fileName", fileName);
model.addAttribute("fileTree", fileTree);
+ try {
+ model.addAttribute("archiveTreeJson", new ObjectMapper().writeValueAsString(RarUtils.getTree(fileTree)));
+ } catch (IOException | SecurityException e) {
+ logger.warn("Unable to read the previewed archive directory", e);
+ return otherFilePreview.notSupportedFile(model, fileAttribute, "该压缩包目录无法读取!");
+ }
return COMPRESS_FILE_PREVIEW_PAGE;
}
}
diff --git a/server/src/main/java/cn/keking/utils/FtpUtils.java b/server/src/main/java/cn/keking/utils/FtpUtils.java
index e3ac7c469..0a1859430 100644
--- a/server/src/main/java/cn/keking/utils/FtpUtils.java
+++ b/server/src/main/java/cn/keking/utils/FtpUtils.java
@@ -154,9 +154,22 @@ private static FtpConnectionInfo parseFtpConnectionInfo(String ftpUrl, String ft
} catch (URISyntaxException e) {
throw new IOException("无效的FTP URL: " + ftpUrl, e);
}
+ // URI#getPath decodes percent escapes. Validate the values actually
+ // sent as FTP command arguments, before opening any connection.
+ validateCommandArgument(info.remoteFilePath);
+ validateCommandArgument(info.username);
+ validateCommandArgument(info.password);
+ validateCommandArgument(new String(info.remoteFilePath.getBytes(info.controlEncoding),
+ StandardCharsets.ISO_8859_1));
return info;
}
+ private static void validateCommandArgument(String value) throws IOException {
+ if (value != null && value.chars().anyMatch(c -> c < 0x20 || c == 0x7f)) {
+ throw new IOException("FTP command arguments must not contain control characters");
+ }
+ }
+
/**
* FTP连接信息对象
*/
diff --git a/server/src/main/java/cn/keking/utils/RarUtils.java b/server/src/main/java/cn/keking/utils/RarUtils.java
index 6a46d392e..f5a140fa5 100644
--- a/server/src/main/java/cn/keking/utils/RarUtils.java
+++ b/server/src/main/java/cn/keking/utils/RarUtils.java
@@ -5,6 +5,12 @@
import org.slf4j.LoggerFactory;
import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.LinkOption;
+import java.nio.file.DirectoryStream;
import java.io.UnsupportedEncodingException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
@@ -19,7 +25,6 @@
**/
public class RarUtils {
private static final Logger logger = LoggerFactory.getLogger(RarUtils.class);
- private static final String fileDir = ConfigConstants.getFileDir();
public static byte[] getUTF8BytesFromGBKString(String gbkStr) {
int n = gbkStr.length();
@@ -108,30 +113,40 @@ public static boolean isMessyCode(String strName) {
/**
* 读取文件目录树
*/
- public static List getTree(String rootPath) {
- List nodes = new ArrayList<>();
- File file = new File(fileDir+rootPath);
- ZtreeNodeVo node = traverse(file);
- nodes.add(node);
- return nodes;
+ public static List getTree(String rootPath) throws IOException {
+ Path fileRoot = Paths.get(ConfigConstants.getFileDir()).toRealPath();
+ Path archiveRoot = fileRoot.resolve(rootPath).normalize();
+ if (archiveRoot.equals(fileRoot) || !archiveRoot.startsWith(fileRoot)) {
+ throw new SecurityException("Only an individual archive directory may be listed");
+ }
+ archiveRoot = archiveRoot.toRealPath();
+ if (archiveRoot.equals(fileRoot) || !archiveRoot.startsWith(fileRoot) || !Files.isDirectory(archiveRoot)) {
+ throw new SecurityException("Archive directory escapes file storage");
+ }
+ return List.of(traverse(archiveRoot, fileRoot, archiveRoot));
}
- private static ZtreeNodeVo traverse(File file) {
- ZtreeNodeVo pathNodeVo = new ZtreeNodeVo();
- pathNodeVo.setId(file.getAbsolutePath().replace(fileDir, "").replace("\\", "/"));
- pathNodeVo.setName(file.getName());
- pathNodeVo.setPid(file.getParent().replace(fileDir, "").replace("\\", "/"));
- if (file.isDirectory()) {
- List subNodeVos = new ArrayList<>();
- File[] subFiles = file.listFiles();
- if (subFiles == null) {
- return pathNodeVo;
- }
- for (File subFile : subFiles) {
- ZtreeNodeVo subNodeVo = traverse(subFile);
- subNodeVos.add(subNodeVo);
+
+ private static ZtreeNodeVo traverse(Path file, Path fileRoot, Path archiveRoot) throws IOException {
+ ZtreeNodeVo node = new ZtreeNodeVo();
+ node.setId(fileRoot.relativize(file).toString().replace('\\', '/'));
+ node.setName(file.getFileName().toString());
+ node.setPid(file.equals(archiveRoot) ? "" : fileRoot.relativize(file.getParent()).toString().replace('\\', '/'));
+ if (Files.isDirectory(file, LinkOption.NOFOLLOW_LINKS)) {
+ List children = new ArrayList<>();
+ try (DirectoryStream entries = Files.newDirectoryStream(file)) {
+ for (Path child : entries) {
+ // Do not follow links into other cached documents or outside storage.
+ if (Files.isSymbolicLink(child)) {
+ continue;
+ }
+ if (Files.isRegularFile(child, LinkOption.NOFOLLOW_LINKS)
+ || Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) {
+ children.add(traverse(child, fileRoot, archiveRoot));
+ }
+ }
}
- pathNodeVo.setChildren(subNodeVos);
+ node.setChildren(children);
}
- return pathNodeVo;
+ return node;
}
}
diff --git a/server/src/main/java/cn/keking/utils/WebUtils.java b/server/src/main/java/cn/keking/utils/WebUtils.java
index beaa9a3e5..8fe20b3c4 100644
--- a/server/src/main/java/cn/keking/utils/WebUtils.java
+++ b/server/src/main/java/cn/keking/utils/WebUtils.java
@@ -241,32 +241,50 @@ public static String encodeUrlFileName(String url) {
* @return url
*/
public static String getSourceUrl(ServletRequest request) {
- String url = request.getParameter("url");
- String urls = request.getParameter("urls");
- String currentUrl = request.getParameter("currentUrl");
- String urlPath = request.getParameter("urlPath");
+ List urls = getSourceUrls(request);
+ return urls.isEmpty() ? null : urls.get(0);
+ }
+
+ /** Validate every candidate consumed by preview endpoints, including all images. */
+ public static List getSourceUrls(ServletRequest request) {
+ List urls = new ArrayList<>();
String encryption = request.getParameter("encryption");
- if (StringUtils.isNotBlank(url)) {
- return decodeUrl(url,encryption);
- }
- if (StringUtils.isNotBlank(currentUrl)) {
- return decodeUrl(currentUrl,encryption);
- }
- if (StringUtils.isNotBlank(urlPath)) {
- return decodeUrl(urlPath,encryption);
- }
- if (StringUtils.isNotBlank(urls)) {
- urls = decodeUrl(urls,encryption);
- String[] images = urls.split("\\|");
- return images[0];
+ try {
+ for (String name : List.of("url", "currentUrl", "urlPath", "urls")) {
+ String[] values = request.getParameterValues(name);
+ if (values == null) {
+ continue;
+ }
+ for (String value : values) {
+ if (StringUtils.isBlank(value)) {
+ continue;
+ }
+ // picturesPreview decodes currentUrl as Base64 independently of encryption.
+ String decoded = "currentUrl".equals(name)
+ ? decodeBase64String(value, StandardCharsets.UTF_8) : decodeUrl(value, encryption);
+ if (decoded == null) {
+ return List.of("");
+ }
+ if ("urls".equals(name)) {
+ urls.addAll(Arrays.asList(decoded.split("\\|", -1)));
+ } else {
+ urls.add(decoded);
+ }
+ }
+ }
+ } catch (RuntimeException e) {
+ return List.of("");
}
- return null;
+ return urls;
}
/**
* 判断地址是否正确
* 高 2022/12/17
*/
public static boolean isValidUrl(String url) {
+ if (url == null) {
+ return false;
+ }
String regStr = "^((https|http|ftp|rtsp|mms|file)://)";//[.?*]表示匹配的就是本身
Pattern pattern = Pattern.compile(regStr);
Matcher matcher = pattern.matcher(url);
@@ -350,10 +368,13 @@ public static String urlSecurity(String url) {
* @return host
*/
public static String getHost(String urlStr) {
+ if (urlStr == null) {
+ return null;
+ }
try {
- URL url = new URL(urlStr);
- return url.getHost().toLowerCase();
- } catch (MalformedURLException ignored) {
+ URL url = normalizedURL(urlStr);
+ return url.getHost().toLowerCase(Locale.ROOT);
+ } catch (GalimatiasParseException | MalformedURLException | IllegalArgumentException ignored) {
}
return null;
}
diff --git a/server/src/main/java/cn/keking/web/controller/FileController.java b/server/src/main/java/cn/keking/web/controller/FileController.java
index 43c2586ba..57e77e06c 100644
--- a/server/src/main/java/cn/keking/web/controller/FileController.java
+++ b/server/src/main/java/cn/keking/web/controller/FileController.java
@@ -6,7 +6,6 @@
import cn.keking.utils.CaptchaUtil;
import cn.keking.utils.DateUtils;
import cn.keking.utils.KkFileUtils;
-import cn.keking.utils.RarUtils;
import cn.keking.utils.WebUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -29,6 +28,9 @@
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.DirectoryStream;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.LinkOption;
+import java.nio.file.StandardOpenOption;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
@@ -154,29 +156,24 @@ public String getStats() {
@PostMapping("/fileUpload")
public ReturnResponse
- 最新版本 v5.0.2
- 发布日期 2026-08-14
+ 最新版本 v5.0.3
+ 发布日期 2026-10-08
JDK 21+ 强制要求
- 安全补丁 / HTML、文件删除、PDF 转图修复
+ 安全补丁 / FTP、文件路径、预览与监控加固
@@ -52,11 +52,39 @@
2026
+
+ v5.0.3
+
+ 2026-10-08
+ 最新稳定版本
+ 建议尽快升级
+
+
+
+
安全修复
+
+ - 校验 FTP 命令参数,限制上传和建目录路径。
+ - 校验所有预览源地址,以及页码和代理参数。
+ - 压缩包目录树改由通过校验的预览生成,移除公开的目录枚举接口。
+ - 监控默认仅开放汇总健康状态。
+
+
+
+
升级重点
+
+ - 建议 v5.0.2 及更早版本用户升级,继续要求 JDK 21 及以上。
+ - 已有外部配置需将
management.endpoints.web.exposure.include 设为 health,将 management.endpoint.health.show-details 设为 never。
+ /directory 接口已移除,内置压缩包预览继续可用。
+
+
+
+
+
v5.0.2
2026-08-14
- 最新稳定版本
+ 上一补丁版本
建议尽快升级
diff --git a/server/src/test/java/cn/keking/config/ActuatorExposureSecurityTests.java b/server/src/test/java/cn/keking/config/ActuatorExposureSecurityTests.java
new file mode 100644
index 000000000..792aa31bb
--- /dev/null
+++ b/server/src/test/java/cn/keking/config/ActuatorExposureSecurityTests.java
@@ -0,0 +1,30 @@
+package cn.keking.config;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpStatus;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@SpringBootTest(classes = ActuatorExposureSecurityTests.ManagementApp.class,
+ webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+class ActuatorExposureSecurityTests {
+ @Configuration(proxyBeanMethods = false)
+ @EnableAutoConfiguration
+ static class ManagementApp { }
+
+ @Autowired TestRestTemplate http;
+
+ @Test void shippedDefaultsExposeOnlyAnAggregateHealthStatus() {
+ var health = http.getForEntity("/actuator/health", String.class);
+ assertEquals(HttpStatus.OK, health.getStatusCode());
+ assertEquals("{\"status\":\"UP\"}", health.getBody());
+ assertEquals(HttpStatus.NOT_FOUND, http.getForEntity("/actuator/info", String.class).getStatusCode());
+ assertEquals(HttpStatus.NOT_FOUND, http.getForEntity("/actuator/metrics", String.class).getStatusCode());
+ assertEquals(HttpStatus.NOT_FOUND, http.getForEntity("/actuator/metrics/jvm.memory.used", String.class).getStatusCode());
+ }
+}
diff --git a/server/src/test/java/cn/keking/config/ActuatorOverrideExposureSecurityTests.java b/server/src/test/java/cn/keking/config/ActuatorOverrideExposureSecurityTests.java
new file mode 100644
index 000000000..75155ab40
--- /dev/null
+++ b/server/src/test/java/cn/keking/config/ActuatorOverrideExposureSecurityTests.java
@@ -0,0 +1,9 @@
+package cn.keking.config;
+
+import org.springframework.boot.test.context.SpringBootTest;
+
+@SpringBootTest(classes = ActuatorExposureSecurityTests.ManagementApp.class,
+ properties = "spring.config.name=test",
+ webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+class ActuatorOverrideExposureSecurityTests extends ActuatorExposureSecurityTests {
+}
diff --git a/server/src/test/java/cn/keking/service/ArchiveTraversalSecurityTests.java b/server/src/test/java/cn/keking/service/ArchiveTraversalSecurityTests.java
new file mode 100644
index 000000000..eab8d72be
--- /dev/null
+++ b/server/src/test/java/cn/keking/service/ArchiveTraversalSecurityTests.java
@@ -0,0 +1,42 @@
+package cn.keking.service;
+
+import cn.keking.config.ConfigConstants;
+import cn.keking.model.FileAttribute;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ArchiveTraversalSecurityTests {
+ @TempDir Path temp;
+
+ @Test void existingExtractorRejectsZipSlipBeforeWritingOutsideArchive() throws Exception {
+ String previous = ConfigConstants.getFileDir();
+ try {
+ CompressFileReader reader = new CompressFileReader(null);
+ for (String directory : List.of("first", "second")) {
+ Path root = Files.createDirectory(temp.resolve(directory));
+ ConfigConstants.setFileDirValue(root.toString());
+ Path archive = root.resolve("malicious.zip");
+ try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(archive))) {
+ zip.putNextEntry(new ZipEntry("../outside-marker.txt"));
+ zip.write("BENIGN-CANARY".getBytes());
+ zip.closeEntry();
+ }
+ Exception error = assertThrows(Exception.class, () -> reader
+ .unRar(archive.toString(), "", "malicious.zip", new FileAttribute()));
+ assertTrue(error.getMessage().contains("Unsafe path detected"), error.toString());
+ assertTrue(Files.isDirectory(root.resolve("malicious.zip_")));
+ assertFalse(Files.exists(root.resolve("outside-marker.txt")));
+ }
+ } finally {
+ ConfigConstants.setFileDirValue(previous);
+ }
+ }
+}
diff --git a/server/src/test/java/cn/keking/utils/FtpCommandSecurityTests.java b/server/src/test/java/cn/keking/utils/FtpCommandSecurityTests.java
new file mode 100644
index 000000000..d5b4235a2
--- /dev/null
+++ b/server/src/test/java/cn/keking/utils/FtpCommandSecurityTests.java
@@ -0,0 +1,46 @@
+package cn.keking.utils;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.io.IOException;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class FtpCommandSecurityTests {
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "file.txt%0D%0ANOOP", "file.txt%0d%0aNOOP/clean.txt",
+ "file.txt%0D%0ANOOP?fullfilename=clean.txt", "file.txt%0aNOOP", "file.txt%00"
+ })
+ void rejectsDecodedControlsBeforeDownloadOrPreviewConnects(String path) {
+ String url = "ftp://127.0.0.1/" + path;
+ IOException download = assertThrows(IOException.class,
+ () -> FtpUtils.download(url, "1", "unused", "user", "pass", "UTF-8"));
+ IOException preview = assertThrows(IOException.class,
+ () -> FtpUtils.preview(url, "1", "unused", "user", "pass", "UTF-8"));
+ assertTrue(download.getMessage().contains("control characters"));
+ assertTrue(preview.getMessage().contains("control characters"));
+ }
+
+ @Test
+ void rejectsControlsInCredentialsBeforeConnecting() {
+ IOException error = assertThrows(IOException.class,
+ () -> FtpUtils.preview("ftp://127.0.0.1/file.txt", "1", "unused",
+ "user\r\nNOOP", "pass", "UTF-8"));
+ assertTrue(error.getMessage().contains("control characters"));
+ error = assertThrows(IOException.class,
+ () -> FtpUtils.download("ftp://127.0.0.1/file.txt", "1", "unused",
+ "user", "pass\nNOOP", "UTF-8"));
+ assertTrue(error.getMessage().contains("control characters"));
+ }
+
+ @Test
+ void preservesOrdinaryEncodedNamesAndDoesNotDoubleDecode() {
+ Object info = ReflectionTestUtils.invokeMethod(FtpUtils.class, "parseFtpConnectionInfo",
+ "ftp://files.example/a%20b/%E4%B8%AD%E6%96%87%250D.txt", "21", "user", "pass", "UTF-8");
+ assertEquals("/a b/中文%0D.txt", ReflectionTestUtils.getField(info, "remoteFilePath"));
+ }
+}
diff --git a/server/src/test/java/cn/keking/web/controller/ArchiveDirectorySecurityTests.java b/server/src/test/java/cn/keking/web/controller/ArchiveDirectorySecurityTests.java
new file mode 100644
index 000000000..a8c32cf8a
--- /dev/null
+++ b/server/src/test/java/cn/keking/web/controller/ArchiveDirectorySecurityTests.java
@@ -0,0 +1,84 @@
+package cn.keking.web.controller;
+
+import cn.keking.config.ConfigConstants;
+import cn.keking.model.FileAttribute;
+import cn.keking.service.FileHandlerService;
+import cn.keking.service.ZtreeNodeVo;
+import cn.keking.service.impl.CompressFilePreviewImpl;
+import cn.keking.utils.RarUtils;
+import freemarker.template.Configuration;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.io.TempDir;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.ui.ExtendedModelMap;
+
+import java.io.StringWriter;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class ArchiveDirectorySecurityTests {
+ @TempDir Path temp;
+ private String previousDir;
+ private Boolean previousCache;
+ @BeforeEach void setup() throws Exception {
+ previousDir = ConfigConstants.getFileDir();
+ previousCache = ConfigConstants.isCacheEnabled();
+ ConfigConstants.setFileDirValue(temp.toString());
+ ConfigConstants.setCacheEnabledValueValue(true);
+ Files.createDirectories(temp.resolve("archive.zip_/nested"));
+ Files.writeString(temp.resolve("archive.zip_/nested/visible.txt"), "visible");
+ Files.writeString(temp.resolve("private-document.pdf"), "secret");
+ }
+ @AfterEach void restore() {
+ ConfigConstants.setFileDirValue(previousDir);
+ ConfigConstants.setCacheEnabledValueValue(previousCache);
+ }
+
+ @Test void publicDirectoryEnumerationRouteIsAbsent() throws Exception {
+ MockMvcBuilders.standaloneSetup(new FileController()).build()
+ .perform(get("/directory").param("urls", "aHR0cDovLy8="))
+ .andExpect(status().isNotFound());
+ }
+ @Test void treeIsConfinedAndDoesNotFollowLinksOrExposeAbsoluteParents() throws Exception {
+ Files.createSymbolicLink(temp.resolve("archive.zip_/secret-link"), temp.resolve("private-document.pdf"));
+ Files.createSymbolicLink(temp.resolve("archive.zip_/loop"), temp.resolve("archive.zip_"));
+ List tree = RarUtils.getTree("archive.zip_");
+ assertEquals("", tree.get(0).pid);
+ assertEquals(1, tree.get(0).children.size());
+ assertEquals("archive.zip_/nested/visible.txt", tree.get(0).children.get(0).children.get(0).id);
+ assertThrows(SecurityException.class, () -> RarUtils.getTree(""));
+ assertThrows(SecurityException.class, () -> RarUtils.getTree("/"));
+ assertThrows(SecurityException.class, () -> RarUtils.getTree("../"));
+ }
+ @Test void cachedArchivePreviewRendersItsTreeWithoutAnEnumerationRequest() throws Exception {
+ Files.writeString(temp.resolve("archive.zip_/quote'\"",
+ "1;alert(1)", "-1", "2147483648", "999999999999999999999"})
+ void rendersOnlySafeScalarValuesThroughControllerAndPdfTemplate(String payload) throws Exception {
+ ExtendedModelMap model = preview(payload, payload);
+ assertEquals("0", model.get("page"));
+ assertEquals("false", model.get("kkagent"));
+ model.put("baseUrl", "https://preview.example/");
+ for (String name : new String[]{"watermarkXSpace", "watermarkYSpace", "watermarkAlpha",
+ "watermarkWidth", "watermarkHeight", "watermarkAngle"}) {
+ model.put(name, "0");
+ }
+ Configuration cfg = new Configuration(Configuration.VERSION_2_3_32);
+ cfg.setClassForTemplateLoading(getClass(), "/web");
+ cfg.setClassicCompatible(true);
+ StringWriter output = new StringWriter();
+ cfg.getTemplate("pdf.ftl").process(model, output);
+ String html = output.toString();
+ assertTrue(html.contains("var kkagent = 'false';"));
+ assertTrue(html.contains("#page=0"));
+ assertFalse(html.contains("alert(1)"));
+ }
+
+ @Test void preservesSupportedValues() {
+ assertEquals("12", preview("0012", "false").get("page"));
+ assertEquals("2147483647", preview("2147483647", "true").get("page"));
+ assertEquals("true", preview("1", "true").get("kkagent"));
+ }
+}
diff --git a/server/src/test/java/cn/keking/web/controller/UploadPathSecurityTests.java b/server/src/test/java/cn/keking/web/controller/UploadPathSecurityTests.java
new file mode 100644
index 000000000..71af129fb
--- /dev/null
+++ b/server/src/test/java/cn/keking/web/controller/UploadPathSecurityTests.java
@@ -0,0 +1,87 @@
+package cn.keking.web.controller;
+
+import cn.keking.config.ConfigConstants;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+class UploadPathSecurityTests {
+ @TempDir Path temp;
+ private String oldDir;
+ private Boolean oldDisabled;
+ private MockMvc mvc;
+
+ @BeforeEach void setup() throws Exception {
+ oldDir = ConfigConstants.getFileDir();
+ oldDisabled = ConfigConstants.getFileUploadDisable();
+ ConfigConstants.setFileDirValue(Files.createDirectory(temp.resolve("files")).toString());
+ ConfigConstants.setFileUploadDisableValue(false);
+ mvc = MockMvcBuilders.standaloneSetup(new FileController()).build();
+ }
+
+ @AfterEach void restore() {
+ ConfigConstants.setFileDirValue(oldDir);
+ ConfigConstants.setFileUploadDisableValue(oldDisabled);
+ }
+
+ private MockMultipartFile file() {
+ return new MockMultipartFile("file", "canary.pdf", "application/pdf", "CANARY".getBytes());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"../../outside", "..\\..\\outside", "/tmp/outside", "C:\\outside",
+ "\\\\server\\share", "a/../outside", ".. /outside", "bad\0path"})
+ void refusesTraversalAtBothHttpEndpoints(String path) throws Exception {
+ mvc.perform(multipart("/fileUpload").file(file()).param("path", path))
+ .andExpect(jsonPath("$.code").value(1));
+ mvc.perform(post("/createFolder").param("path", path).param("folderName", "new"))
+ .andExpect(jsonPath("$.code").value(1));
+ assertFalse(Files.exists(temp.resolve("outside")));
+ }
+
+ @Test void preservesNestedUploadsAndRefusesOverwrite() throws Exception {
+ mvc.perform(multipart("/fileUpload").file(file()).param("path", "nested/child"))
+ .andExpect(jsonPath("$.code").value(0));
+ Path target = temp.resolve("files/demo/nested/child/canary.pdf");
+ assertEquals("CANARY", Files.readString(target));
+ mvc.perform(multipart("/fileUpload").file(file()).param("path", "nested/child"))
+ .andExpect(jsonPath("$.code").value(1));
+ mvc.perform(post("/createFolder").param("path", "nested\\child").param("folderName", "new"))
+ .andExpect(jsonPath("$.code").value(0));
+ assertTrue(Files.isDirectory(target.getParent().resolve("new")));
+ }
+
+ @Test void refusesParentSymlinksAndExistingFileSymlinks() throws Exception {
+ Path root = Files.createDirectory(temp.resolve("files/demo"));
+ Path outside = Files.createDirectory(temp.resolve("outside"));
+ Files.createSymbolicLink(root.resolve("link"), outside);
+ mvc.perform(multipart("/fileUpload").file(file()).param("path", "link/new"))
+ .andExpect(jsonPath("$.code").value(1));
+ mvc.perform(post("/createFolder").param("path", "link/new").param("folderName", "new"))
+ .andExpect(jsonPath("$.code").value(1));
+ assertFalse(Files.exists(outside.resolve("new")));
+ Files.createSymbolicLink(root.resolve("canary.pdf"), outside.resolve("missing.pdf"));
+ mvc.perform(multipart("/fileUpload").file(file()))
+ .andExpect(jsonPath("$.code").value(1));
+ assertFalse(Files.exists(outside.resolve("missing.pdf")));
+ }
+
+ @Test void uploadDisableStillBlocksBothEndpoints() throws Exception {
+ ConfigConstants.setFileUploadDisableValue(true);
+ mvc.perform(multipart("/fileUpload").file(file())).andExpect(jsonPath("$.code").value(1));
+ mvc.perform(post("/createFolder").param("folderName", "new"))
+ .andExpect(jsonPath("$.code").value(1));
+ assertFalse(Files.exists(temp.resolve("files/demo")));
+ }
+}
diff --git a/server/src/test/java/cn/keking/web/filter/PreviewSourceSecurityTests.java b/server/src/test/java/cn/keking/web/filter/PreviewSourceSecurityTests.java
new file mode 100644
index 000000000..1bc694cc5
--- /dev/null
+++ b/server/src/test/java/cn/keking/web/filter/PreviewSourceSecurityTests.java
@@ -0,0 +1,75 @@
+package cn.keking.web.filter;
+
+import cn.keking.config.ConfigConstants;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class PreviewSourceSecurityTests {
+ private String previousLocalDir;
+ @BeforeEach void setup() {
+ previousLocalDir = ConfigConstants.getLocalPreviewDir();
+ ConfigConstants.setLocalPreviewDirValue("/allowed-preview-root");
+ ConfigConstants.setTrustHostValue("files.example");
+ ConfigConstants.setNotTrustHostValue("127.0.0.1");
+ }
+ @AfterEach void restore() {
+ org.springframework.test.util.ReflectionTestUtils.setField(ConfigConstants.class, "localPreviewDir", previousLocalDir);
+ ConfigConstants.setTrustHostValue("default");
+ ConfigConstants.setNotTrustHostValue("default");
+ }
+ private String encoded(String url) {
+ return Base64.getEncoder().encodeToString(url.getBytes(StandardCharsets.UTF_8));
+ }
+ private MockHttpServletResponse filter(MockHttpServletRequest request) throws Exception {
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ AtomicBoolean reached = new AtomicBoolean();
+ new TrustHostFilter().doFilter(request, response, (req, res) -> reached.set(true));
+ assertEquals(response.getStatus() == 200, reached.get());
+ return response;
+ }
+ @ParameterizedTest
+ @ValueSource(strings = {"url", "currentUrl", "urls"})
+ void allowedDecoysCannotHideBlockedFetchTarget(String decoy) throws Exception {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/getCorsFile");
+ request.setParameter(decoy, encoded("https://files.example/allowed.pdf"));
+ request.setParameter("urlPath", encoded("http://127.0.0.1/private.pdf"));
+ assertEquals(403, filter(request).getStatus());
+ }
+ @Test void validatesEveryImageAndEveryRepeatedParameter() throws Exception {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/picturesPreview");
+ request.setParameter("urls", encoded("https://files.example/a.jpg|http://127.0.0.1/b.jpg"));
+ assertEquals(403, filter(request).getStatus());
+ request = new MockHttpServletRequest("GET", "/getCorsFile");
+ request.setParameter("urlPath", encoded("https://files.example/a.pdf"), encoded("http://127.0.0.1/a.pdf"));
+ assertEquals(403, filter(request).getStatus());
+ }
+ @Test void preservesAllowedRequestsAndRejectsMissingOrMalformedSources() throws Exception {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/getCorsFile");
+ assertEquals(403, filter(request).getStatus());
+ request.setParameter("urlPath", "%%%bad");
+ assertEquals(403, filter(request).getStatus());
+ request.setParameter("urlPath", encoded("https://files.example/a.pdf"));
+ assertEquals(200, filter(request).getStatus());
+ request.setParameter("url", encoded("https://files.example/b.pdf"));
+ assertEquals(200, filter(request).getStatus());
+ }
+ @Test void directoryFilterAlsoRejectsHiddenFileTargets() throws Exception {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/getCorsFile");
+ request.setParameter("url", encoded("https://files.example/a.pdf"));
+ request.setParameter("urlPath", encoded("file:///outside-preview-root/private.pdf"));
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ new TrustDirFilter().doFilter(request, response, (req, res) -> fail("Unsafe URL reached controller"));
+ assertEquals(403, response.getStatus());
+ }
+}