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 fileUpload(@RequestParam("file") MultipartFile file, @RequestParam(value = "path", defaultValue = "") String path) { - ReturnResponse checkResult = this.fileUploadCheck(file, path); + ReturnResponse checkResult = this.fileUploadCheck(file); if (checkResult.isFailure()) { return checkResult; } - String uploadPath = fileDir + demoPath; - if (!ObjectUtils.isEmpty(path)) { - uploadPath += path + File.separator; - } - - File outFile = new File(uploadPath); - if (!outFile.exists() && !outFile.mkdirs()) { - logger.error("创建文件夹【{}】失败,请检查目录权限!", uploadPath); - return ReturnResponse.failure("创建文件夹失败,请检查目录权限!"); - } - String fileName = checkResult.getContent().toString(); - logger.info("上传文件:{}{}", uploadPath, fileName); - - try (InputStream in = file.getInputStream(); - OutputStream out = Files.newOutputStream(Paths.get(uploadPath + fileName))) { - StreamUtils.copy(in, out); + try { + Path target = createUploadDirectory(path).resolve(fileName); + try (InputStream in = file.getInputStream(); + OutputStream out = Files.newOutputStream(target, StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) { + StreamUtils.copy(in, out); + } return ReturnResponse.success(null); + } catch (FileAlreadyExistsException e) { + return ReturnResponse.failure("存在同名文件,请先删除原有文件再次上传"); + } catch (SecurityException | InvalidPathException e) { + return ReturnResponse.failure("非法目录路径"); } catch (IOException e) { logger.error("文件上传失败", e); return ReturnResponse.failure("文件上传失败"); @@ -195,30 +192,49 @@ public ReturnResponse createFolder(@RequestParam(value = "path", default return ReturnResponse.failure("文件夹名称不能为空"); } - if (KkFileUtils.isIllegalFileName(folderName)) { + if (KkFileUtils.isIllegalFileName(folderName) || folderName.contains("/") + || folderName.contains("\\") || folderName.contains(":") + || folderName.indexOf('\0') >= 0 || folderName.endsWith(".") || folderName.endsWith(" ")) { return ReturnResponse.failure("非法文件夹名称"); } - String basePath = fileDir + demoPath; - if (!ObjectUtils.isEmpty(path)) { - basePath += path + File.separator; - } + Files.createDirectory(createUploadDirectory(path).resolve(folderName)); + return ReturnResponse.success(); + } catch (FileAlreadyExistsException e) { + return ReturnResponse.failure("文件夹已存在"); + } catch (SecurityException | InvalidPathException e) { + return ReturnResponse.failure("非法目录路径"); + } catch (Exception e) { + logger.error("创建文件夹异常", e); + return ReturnResponse.failure("创建文件夹失败"); + } + } - File newFolder = new File(basePath + folderName); - if (newFolder.exists()) { - return ReturnResponse.failure("文件夹已存在"); + /** Create only relative upload directories, without following symbolic links. */ + private Path createUploadDirectory(String requestedPath) throws IOException { + String value = requestedPath == null ? "" : requestedPath.replace('\\', '/'); + if (value.startsWith("/") || value.indexOf(':') >= 0 || value.indexOf('\0') >= 0) { + throw new SecurityException("Absolute paths are not allowed"); + } + Path relative = Paths.get(value); + for (Path segment : relative) { + String name = segment.toString(); + if ("..".equals(name) || (!".".equals(name) && (name.endsWith(".") || name.endsWith(" ")))) { + throw new SecurityException("Parent path segments are not allowed"); } - - if (newFolder.mkdirs()) { - logger.info("创建文件夹:{}", newFolder.getAbsolutePath()); - return ReturnResponse.success(); - } else { - logger.error("创建文件夹失败:{}", newFolder.getAbsolutePath()); - return ReturnResponse.failure("创建文件夹失败,请检查目录权限"); + } + Path root = Files.createDirectories(Paths.get(fileDir)).toRealPath(); + Path current = root; + for (Path segment : Paths.get(demoDir).resolve(relative).normalize()) { + current = current.resolve(segment); + try { + Files.createDirectory(current); + } catch (FileAlreadyExistsException e) { + if (!Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) { + throw new SecurityException("Upload directory must not be a symbolic link or file"); + } } - } catch (Exception e) { - logger.error("创建文件夹异常", e); - return ReturnResponse.failure("创建文件夹失败:" + e.getMessage()); } + return current; } @PostMapping("/deleteFile") @@ -722,7 +738,7 @@ private Map convertFileInfoToMap(FileInfoWrapper info, String pa /** * 上传文件前校验 */ - private ReturnResponse fileUploadCheck(MultipartFile file, String path) { + private ReturnResponse fileUploadCheck(MultipartFile file) { if (ConfigConstants.getFileUploadDisable()) { return ReturnResponse.failure("文件上传接口已禁用"); } @@ -734,7 +750,8 @@ private ReturnResponse fileUploadCheck(MultipartFile file, String path) if (!KkFileUtils.isAllowedUpload(fileName)) { return ReturnResponse.failure("不允许上传的文件类型: " + fileName); } - if (KkFileUtils.isIllegalFileName(fileName)) { + if (KkFileUtils.isIllegalFileName(fileName) || fileName.contains(":") || fileName.indexOf('\0') >= 0 + || fileName.endsWith(".") || fileName.endsWith(" ")) { return ReturnResponse.failure("不允许上传的文件名: " + fileName); } FileType type = FileType.typeFromFileName(fileName); @@ -742,11 +759,6 @@ private ReturnResponse fileUploadCheck(MultipartFile file, String path) return ReturnResponse.failure("该文件格式还不支持预览,请联系管理员,添加该格式: " + fileName); } - // 判断是否存在同名文件 - if (existsFile(fileName, path)) { - return ReturnResponse.failure("存在同名文件,请先删除原有文件再次上传"); - } - return ReturnResponse.success(fileName); } @@ -801,28 +813,4 @@ private ReturnResponse deleteFileCheck(HttpServletRequest request, Strin return ReturnResponse.success(fileName); } - @GetMapping("/directory") - public Object directory(String urls) { - String fileUrl; - try { - fileUrl = WebUtils.decodeUrl(urls,"base64"); - } catch (Exception ex) { - String errorMsg = String.format(BASE64_DECODE_ERROR_MSG, "url"); - return ReturnResponse.failure(errorMsg); - } - fileUrl = fileUrl.replaceAll("http://", ""); - if (KkFileUtils.isIllegalFileName(fileUrl)) { - return ReturnResponse.failure("不允许访问的路径:"); - } - return RarUtils.getTree(fileUrl); - } - - private boolean existsFile(String fileName, String path) { - String fullPath = fileDir + demoPath; - if (!ObjectUtils.isEmpty(path)) { - fullPath += path + File.separator; - } - File file = new File(fullPath + fileName); - return file.exists(); - } } diff --git a/server/src/main/java/cn/keking/web/controller/OnlinePreviewController.java b/server/src/main/java/cn/keking/web/controller/OnlinePreviewController.java index 666e322be..3d5e4d155 100644 --- a/server/src/main/java/cn/keking/web/controller/OnlinePreviewController.java +++ b/server/src/main/java/cn/keking/web/controller/OnlinePreviewController.java @@ -86,8 +86,8 @@ public String onlinePreview(@RequestParam String url, highlightall= KkFileUtils.htmlEscape(highlightall); model.addAttribute("highlightall", highlightall); - model.addAttribute("page", page); - model.addAttribute("kkagent", kkagent); + model.addAttribute("page", normalizePage(page)); + model.addAttribute("kkagent", Boolean.toString(Boolean.parseBoolean(kkagent))); model.addAttribute("file", fileAttribute); FilePreview filePreview = previewFactory.get(fileAttribute); logger.info("预览文件url:{},previewType:{}", fileUrl, fileAttribute.getType()); @@ -98,6 +98,17 @@ public String onlinePreview(@RequestParam String url, return filePreview.filePreviewHandle(fileUrl, model, fileAttribute); //统一在这里处理 url } + private static String normalizePage(String page) { + if (page != null && page.matches("[0-9]{1,10}")) { + try { + return Integer.toString(Integer.parseInt(page)); + } catch (NumberFormatException ignored) { + // Out-of-range values use the same default as missing values. + } + } + return "0"; + } + @GetMapping( "/picturesPreview") public String picturesPreview(@RequestParam String urls, @RequestParam(required = false) String key, @@ -287,4 +298,4 @@ public String addQueueTask(@RequestParam String url, cacheService.addQueueTask(fileUrls); return "success"; } -} \ No newline at end of file +} diff --git a/server/src/main/java/cn/keking/web/filter/TrustDirFilter.java b/server/src/main/java/cn/keking/web/filter/TrustDirFilter.java index f71e9fd31..f4bb5a953 100644 --- a/server/src/main/java/cn/keking/web/filter/TrustDirFilter.java +++ b/server/src/main/java/cn/keking/web/filter/TrustDirFilter.java @@ -13,6 +13,7 @@ import org.springframework.util.StringUtils; import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletResponse; import java.io.File; import java.io.IOException; @@ -20,6 +21,7 @@ import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.util.Locale; +import java.util.List; /** * @author : kl (http://kailing.pub) @@ -45,9 +47,12 @@ public void init(FilterConfig filterConfig) { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { - String url = WebUtils.getSourceUrl(request); - if (!allowPreview(url)) { - response.getWriter().write(this.notTrustDirView); + List urls = WebUtils.getSourceUrls(request); + if (urls.isEmpty() || urls.stream().anyMatch(url -> !allowPreview(url))) { + if (response instanceof HttpServletResponse httpResponse) { + httpResponse.setStatus(HttpServletResponse.SC_FORBIDDEN); + } + response.getWriter().write(this.notTrustDirView == null ? "Forbidden" : this.notTrustDirView); response.getWriter().close(); } else { chain.doFilter(request, response); @@ -61,7 +66,7 @@ public void destroy() { private boolean allowPreview(String urlPath) { // 判断URL是否合法 - if (KkFileUtils.isIllegalFileName(urlPath) || !StringUtils.hasText(urlPath) || !WebUtils.isValidUrl(urlPath)) { + if (!StringUtils.hasText(urlPath) || KkFileUtils.isIllegalFileName(urlPath) || !WebUtils.isValidUrl(urlPath)) { return false; } try { diff --git a/server/src/main/java/cn/keking/web/filter/TrustHostFilter.java b/server/src/main/java/cn/keking/web/filter/TrustHostFilter.java index f7547ad55..88cf17843 100644 --- a/server/src/main/java/cn/keking/web/filter/TrustHostFilter.java +++ b/server/src/main/java/cn/keking/web/filter/TrustHostFilter.java @@ -5,6 +5,7 @@ import java.io.IOException; import java.util.Map; +import java.util.List; import java.util.Locale; import java.util.concurrent.ConcurrentHashMap; import java.nio.charset.StandardCharsets; @@ -48,9 +49,12 @@ public void init(FilterConfig filterConfig) { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { - String url = WebUtils.getSourceUrl(request); - String host = WebUtils.getHost(url); - if (isNotTrustHost(host) || !WebUtils.isValidUrl(url)) { + List urls = WebUtils.getSourceUrls(request); + String rejectedUrl = urls.stream() + .filter(url -> !WebUtils.isValidUrl(url) || isNotTrustHost(WebUtils.getHost(url))) + .findFirst().orElse(null); + if (urls.isEmpty() || rejectedUrl != null) { + String host = rejectedUrl == null ? null : WebUtils.getHost(rejectedUrl); String currentHost = host == null ? "UNKNOWN" : host; if (response instanceof HttpServletResponse httpServletResponse) { httpServletResponse.setStatus(HttpServletResponse.SC_FORBIDDEN); diff --git a/server/src/main/resources/web/compress.ftl b/server/src/main/resources/web/compress.ftl index d40aef7cc..8be245d7a 100644 --- a/server/src/main/resources/web/compress.ftl +++ b/server/src/main/resources/web/compress.ftl @@ -944,22 +944,10 @@ } function loadTree() { - var url = "http://" + '${fileTree}'; setTreeStatus("目录加载中", "is-loading"); treeSummaryEl.textContent = "正在准备目录结构..."; showTreeState("loading"); - $.ajax({ - type: "get", - url: "${baseUrl}directory?urls=" + encodeURIComponent(Base64.encode(url)), - success: function (res) { - initTree(res); - }, - error: function () { - showTreeState("error"); - setTreeStatus("目录加载失败", "is-error"); - treeSummaryEl.textContent = "目录读取失败,请重试。"; - } - }); + initTree(JSON.parse('${archiveTreeJson?js_string}')); } document.getElementById("retryTree").addEventListener("click", function () { diff --git a/server/src/main/resources/web/main/record.ftl b/server/src/main/resources/web/main/record.ftl index 149d7deda..6ade251b3 100644 --- a/server/src/main/resources/web/main/record.ftl +++ b/server/src/main/resources/web/main/record.ftl @@ -41,10 +41,10 @@ 你可以先看最新版本的升级重点,再顺着时间轴继续了解历史版本细节。

- 最新版本 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()); + } +}