From dcd5e62d900cd91e6c015e5a0ff5dc07c67451b2 Mon Sep 17 00:00:00 2001 From: lioensky Date: Wed, 22 Jul 2026 06:38:31 +0800 Subject: [PATCH] fix(image-server): avoid repeated-dot path traversal false positives --- Plugin/ImageFileServer/image-file-server.js | 53 ++++++++++++++---- tests/imageFileServerPathSecurity.test.js | 61 +++++++++++++++++++++ 2 files changed, 103 insertions(+), 11 deletions(-) create mode 100644 tests/imageFileServerPathSecurity.test.js diff --git a/Plugin/ImageFileServer/image-file-server.js b/Plugin/ImageFileServer/image-file-server.js index 47ccc6a6d..81e6e5a21 100644 --- a/Plugin/ImageFileServer/image-file-server.js +++ b/Plugin/ImageFileServer/image-file-server.js @@ -218,6 +218,26 @@ class SecurityManager { // 创建安全管理器实例 const securityManager = new SecurityManager(); +/** + * 判断请求路径是否包含父目录段。 + * 只有完整路径段等于 ".." 才属于目录遍历;文件名或目录名中的连续句点是合法内容。 + * 同时检查一次 URL 解码后的路径,以覆盖 %2e.、.%2e 等混合编码形式。 + */ +function containsParentDirectorySegment(requestedPath) { + const hasParentSegment = candidate => /(?:^|\/)\.\.(?:\/|$)/.test(candidate); + + if (hasParentSegment(requestedPath)) { + return true; + } + + try { + return hasParentSegment(decodeURIComponent(requestedPath)); + } catch (error) { + // 非法百分号编码不是有效静态资源路径,按危险路径处理。 + return true; + } +} + /** * 安全路径验证中间件 * 防止路径遍历攻击 @@ -226,23 +246,21 @@ function createPathSecurityMiddleware(serviceType) { return (req, res, next) => { const requestedPath = req.path; - // 检查路径中是否包含危险字符 + // 父目录遍历必须按完整路径段判断,避免误伤标题中的“......”。 + // 其余模式继续阻止分隔符编码、反斜杠、空字节及 Windows 非法字符。 + const hasDangerousPath = containsParentDirectorySegment(requestedPath); const dangerousPatterns = [ - /\.\./, // 父目录遍历 /\/\//, // 双斜杠 /\\/, // 反斜杠 - /%2e%2e/i, // URL编码的.. /%2f/i, // URL编码的/ /%5c/i, // URL编码的\ /\0/, // 空字节 /[<>:"|?*]/ // Windows文件名非法字符 ]; - for (const pattern of dangerousPatterns) { - if (pattern.test(requestedPath)) { - console.warn(`[PathSecurity] 🚨 检测到路径遍历攻击尝试: ${requestedPath} from IP: ${req.ip}`); - return res.status(400).type('text/plain').send('Bad Request: Invalid path format detected.'); - } + if (hasDangerousPath || dangerousPatterns.some(pattern => pattern.test(requestedPath))) { + console.warn(`[PathSecurity] 🚨 检测到路径遍历攻击尝试: ${requestedPath} from IP: ${req.ip}`); + return res.status(400).type('text/plain').send('Bad Request: Invalid path format detected.'); } // 验证文件扩展名 @@ -349,11 +367,16 @@ function createSecureStaticMiddleware(rootDir, serviceType) { const requestedFile = req.path; const fullPath = path.join(rootDir, requestedFile); - // 确保请求的文件在允许的目录内 + // 确保请求的文件在允许的目录内。使用 path.relative 做目录边界判断, + // 避免字符串 startsWith 将 "image-evil" 误认为 "image" 的子目录。 const normalizedRoot = path.resolve(rootDir); const normalizedPath = path.resolve(fullPath); + const relativePath = path.relative(normalizedRoot, normalizedPath); + const isOutsideRoot = relativePath === '..' + || relativePath.startsWith(`..${path.sep}`) + || path.isAbsolute(relativePath); - if (!normalizedPath.startsWith(normalizedRoot)) { + if (isOutsideRoot) { console.warn(`[SecureStatic] 🚨 路径遍历攻击被阻止: ${requestedFile} -> ${normalizedPath} from IP: ${req.ip}`); return res.status(403).type('text/plain').send('Forbidden: Access denied.'); } @@ -385,7 +408,11 @@ function createSecureStaticMiddleware(rootDir, serviceType) { const tryPath = path.join(rootDir, baseName + tryExt); const normalizedTry = path.resolve(tryPath); // 安全检查:确保回退路径仍在允许目录内 - if (!normalizedTry.startsWith(normalizedRoot)) continue; + const relativeTryPath = path.relative(normalizedRoot, normalizedTry); + const isTryOutsideRoot = relativeTryPath === '..' + || relativeTryPath.startsWith(`..${path.sep}`) + || path.isAbsolute(relativeTryPath); + if (isTryOutsideRoot) continue; if (fs.existsSync(tryPath)) { if (pluginDebugMode) { console.log(`[SecureStatic] 🔄 格式回退: ${decodedPath} -> ${baseName + tryExt}`); @@ -555,6 +582,10 @@ process.on('SIGTERM', () => { module.exports = { registerRoutes, + // 导出纯路径判定函数,供安全回归测试复用 + containsParentDirectorySegment, + // 供测试或宿主优雅卸载时停止定时器,避免模块句柄阻止进程退出 + stopSecurityManager: () => securityManager.stopCleanupTimer(), // 导出安全管理器供外部查询(可选) getSecurityStats: () => securityManager.getAccessStats(), // 手动触发锁定(紧急情况下使用) diff --git a/tests/imageFileServerPathSecurity.test.js b/tests/imageFileServerPathSecurity.test.js new file mode 100644 index 000000000..1117463b6 --- /dev/null +++ b/tests/imageFileServerPathSecurity.test.js @@ -0,0 +1,61 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + containsParentDirectorySegment, + stopSecurityManager +} = require('../Plugin/ImageFileServer/image-file-server.js'); + +test.after(() => { + stopSecurityManager(); +}); + +test('allows ordinary repeated dots inside media title path segments', () => { + const reportedPath = '/bilibili/%E5%8F%AF%E6%8E%A7%E8%81%9A%E5%8F%98%E6%98%AF%E7%BB%88%E6%9E%81%E8%83%BD%E6%BA%90......%E5%90%97%EF%BC%9F/hd_snapshot_BV1xNNZ6XEu9_300s.jpg'; + + assert.equal(containsParentDirectorySegment(reportedPath), false); + assert.equal(containsParentDirectorySegment('/bilibili/title...with...dots/image.jpg'), false); + assert.equal(containsParentDirectorySegment('/bilibili/......../image.jpg'), false); +}); + +test('rejects literal and URL-encoded parent directory segments', () => { + const dangerousPaths = [ + '/../secret.jpg', + '/safe/../secret.jpg', + '/safe/..', + '/safe/%2e%2e/secret.jpg', + '/safe/%2E%2E/secret.jpg', + '/safe/%2e./secret.jpg', + '/safe/.%2e/secret.jpg' + ]; + + for (const requestedPath of dangerousPaths) { + assert.equal( + containsParentDirectorySegment(requestedPath), + true, + `expected parent directory segment to be rejected: ${requestedPath}` + ); + } +}); + +test('does not confuse dot-prefixed or dot-containing names with parent traversal', () => { + const safePaths = [ + '/safe/.../image.jpg', + '/safe/..title/image.jpg', + '/safe/title../image.jpg', + '/safe/a..b/image.jpg', + '/safe/.hidden/image.jpg' + ]; + + for (const requestedPath of safePaths) { + assert.equal( + containsParentDirectorySegment(requestedPath), + false, + `expected ordinary path segment to be allowed: ${requestedPath}` + ); + } +}); + +test('treats malformed URL encoding as unsafe', () => { + assert.equal(containsParentDirectorySegment('/safe/%E0%A4%A/image.jpg'), true); +}); \ No newline at end of file