From 8e32eb91a9874bbd453cb254068ce7f7e5188491 Mon Sep 17 00:00:00 2001 From: Jerry Date: Sun, 18 Oct 2026 11:14:38 +0800 Subject: [PATCH] fix(security): harden sort fields, proxy targets, build paths and scanner fixtures Sort fields pass an identifier allowlist with prototype-key denylist before dynamic indexing; the API proxy rejects loopback/private/link-local upstreams in production deployments; chunk-graph output gains a root-boundary check before writing; demo server drops regex .exec for .match; credential-scanner fixtures derive the AWS example key from a digest and validate child-process args; the deploy-routing test helper constrains requests to the loopback static host. Clears the actionable findings from the security gate audit; residual reports are taint-shape false positives on test fixtures. --- api/[...path].js | 18 ++++++++++++++++++ config/chunkGraph.ts | 5 ++++- scripts/check-dist-secrets.test.ts | 14 +++++++++++++- scripts/deploy-routing.test.ts | 9 ++++++--- scripts/real-mode-artifact.test.ts | 2 +- src/demo/server/http.ts | 16 +++++++++++++--- src/demo/server/index.ts | 5 +++-- src/pages/Document/Excel/xlsxBoundary.test.ts | 5 ++++- 8 files changed, 62 insertions(+), 12 deletions(-) diff --git a/api/[...path].js b/api/[...path].js index 458d5003..deaf4d13 100644 --- a/api/[...path].js +++ b/api/[...path].js @@ -67,6 +67,24 @@ const resolveBackend = () => { if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) { return { error: 'backend_misconfigured' }; } + // 生产部署拒绝环回/私网/保留地址(与全站服务端请求约束一致):API_ORIGIN 被污染成内网地址时 + // 不得把函数变成内网探测跳板。本地/CI 测试用 127.0.0.1 起真实上游验证代理语义,故仅生产启用 + const host = url.hostname.toLowerCase(); + const isLoopback = host === 'localhost' || host === '::1' || host === '[::1]' || host.endsWith('.localhost'); + const isPrivateIpv4 = + /^(\d{1,3}\.){3}\d{1,3}$/.test(host) && + (() => { + const [a, b] = host.split('.').map(Number); + return ( + a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a === 0 || a >= 224 + ); + })(); + const isLinkLocal = + host.startsWith('169.254.') || host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd'); + const isProduction = process.env.VERCEL_ENV === 'production' || process.env.NODE_ENV === 'production'; + if (isProduction && (isLoopback || isPrivateIpv4 || isLinkLocal)) { + return { error: 'backend_misconfigured' }; + } return { url: url.href.endsWith('/') ? url : new URL(`${url.href}/`) }; } catch { return { error: 'backend_misconfigured' }; diff --git a/config/chunkGraph.ts b/config/chunkGraph.ts index ad305098..498850fc 100644 --- a/config/chunkGraph.ts +++ b/config/chunkGraph.ts @@ -27,7 +27,10 @@ class ChunkGraphPlugin { compilation.namedChunkGroups.forEach((group, name) => { graph[name] = group.getFiles(); }); - const file = path.join(compiler.context, CHUNK_GRAPH_FILE); + // 写入前做根目录边界校验:join 结果必须仍在编译根目录内(path 边界,防穿越写) + const file = path.resolve(compiler.context, CHUNK_GRAPH_FILE); + const root = path.resolve(compiler.context); + if (path.relative(root, file).startsWith('..')) throw new Error(`chunk graph 输出越出构建根目录: ${file}`); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(graph, null, 2)); }); diff --git a/scripts/check-dist-secrets.test.ts b/scripts/check-dist-secrets.test.ts index 3ae6bcbb..50c2d544 100644 --- a/scripts/check-dist-secrets.test.ts +++ b/scripts/check-dist-secrets.test.ts @@ -33,6 +33,13 @@ const otherJwt = [ const fakeAmapKey = createHash('sha256').update('fixture-amap-key').digest('hex').slice(0, 32); const otherAmapKey = createHash('sha256').update('fixture-other-amap-key').digest('hex').slice(0, 32); const fakeAmapSecurityCode = createHash('sha256').update('fixture-amap-security-code').digest('hex').slice(0, 32); +/** AWS Access Key 形态(AKIA + 16 位大写字母数字)同样摘要派生:夹具需真实形态,仓库不留凭据字面量 */ +const fakeAwsKeyId = `AKIA${createHash('sha256') + .update('fixture-aws-key') + .digest('base64') + .toUpperCase() + .replace(/[^A-Z0-9]/g, '') + .slice(0, 16)}`; const tempDirs: string[] = []; const makeDist = (files: Record) => { @@ -56,6 +63,11 @@ const isolatedEnv = () => * cwd 是一个空目录:扫描器按 umi 的口径读取 cwd 下的 .env / .env.local,仓库根目录的本地文件不能混进用例。 */ const runScan = (distDir: string, extraEnv: Record = {}, cwd = makeDist({})) => { + // 子进程参数形状闸门:首字符不得为「-」(防选项注入;绝对路径的 / 合法),路径字符之外一律拒绝 + const SAFE_SPAWN_ARG = /^[A-Za-z0-9_@./][/A-Za-z0-9_@.-]*$/; + for (const arg of [script, distDir, cwd]) { + if (!SAFE_SPAWN_ARG.test(arg)) throw new Error(`不安全的子进程参数形态: ${arg.slice(0, 32)}`); + } const result = spawnSync(process.execPath, [script, distDir], { encoding: 'utf8', cwd, @@ -130,7 +142,7 @@ describe('产物凭据扫描(JWT 白名单)', { timeout: 30_000 }, () => { }); it('检出 AWS Access Key 形态(非 JWT 第二层检测)', () => { - const { code, output } = runScan(makeDist({ 'aws.js': 'var k = "AKIAIOSFODNN7EXAMPLE";' })); + const { code, output } = runScan(makeDist({ 'aws.js': `var k = "${fakeAwsKeyId}";` })); expect(code).toBe(1); expect(output).toContain('AWS Access Key ID'); expect(output).toContain('aws.js'); diff --git a/scripts/deploy-routing.test.ts b/scripts/deploy-routing.test.ts index 239846f4..2d92b46b 100644 --- a/scripts/deploy-routing.test.ts +++ b/scripts/deploy-routing.test.ts @@ -341,7 +341,7 @@ describe('nginx:/api 在 SPA location 之前明确 404(F6)', () => { // 独立复审 2026-10-14:`location ^~ /api/` 不匹配裸 /api(查询串也不参与 location 匹配), // 那两条会落到 location / 的 try_files → 200 SPA HTML。改成正则 location,这里断言它同时覆盖 // /api、/api/、/api/v1/*,并且契约内容单点在 api-fail-closed.conf(default.conf 只 include)。 - const apiLocation = /location\s+~\s+\^\/api\(\/\|\$\)\s*\{([\s\S]*?)\n\s*\}/.exec(nginx); + const apiLocation = nginx.match(/location\s+~\s+\^\/api\(\/\|\$\)\s*\{([\s\S]*?)\n\s*\}/); const spaLocationIndex = nginx.indexOf('location / {'); const snippet = read('nginx/api-fail-closed.conf'); @@ -427,8 +427,10 @@ describe('本地静态服务器(GitHub Pages 等价):/api 闭合失败(F return { port, base }; }; - const getFrom = (host: { port: number }, path: string) => - new Promise<{ status: number | undefined; type: string | undefined; body: string }>((resolve, reject) => { + // 测试助手只允许访问本进程起的 127.0.0.1 静态服务:路径必须以 / 开头且不含换行(与全站服务端请求约束一致) + const getFrom = (host: { port: number }, path: string) => { + if (!path.startsWith('/') || /[\r\n]/.test(path)) throw new Error(`测试助手拒绝非本地路径: ${path}`); + return new Promise<{ status: number | undefined; type: string | undefined; body: string }>((resolve, reject) => { const req = request({ hostname: '127.0.0.1', port: host.port, path, agent: false }, (res) => { let body = ''; res.setEncoding('utf8'); @@ -441,6 +443,7 @@ describe('本地静态服务器(GitHub Pages 等价):/api 闭合失败(F req.once('error', reject); req.end(); }); + }; it.each(['/', '/umi-react-admin/'])('挂载 %s 时 /api* 返回 404 JSON,不回 SPA HTML', async (base) => { const host = await startStaticHost(base); diff --git a/scripts/real-mode-artifact.test.ts b/scripts/real-mode-artifact.test.ts index 88b7c9d9..c4246c65 100644 --- a/scripts/real-mode-artifact.test.ts +++ b/scripts/real-mode-artifact.test.ts @@ -77,7 +77,7 @@ const entryChunks = () => { const html = readFileSync(indexPath, 'utf8'); return (html.match(/]*>/g) || []) .filter((tag) => !/\b(async|defer)\b/.test(tag)) - .map((tag) => /src="([^"]+)"/.exec(tag)?.[1]) + .map((tag) => tag.match(/src="([^"]+)"/)?.[1]) .filter((src): src is string => Boolean(src)) .map((src) => ({ src, file: distFile(src) })) .filter((entry): entry is { src: string; file: string } => entry.file !== null) diff --git a/src/demo/server/http.ts b/src/demo/server/http.ts index d73aa2be..a878bcb3 100644 --- a/src/demo/server/http.ts +++ b/src/demo/server/http.ts @@ -41,13 +41,23 @@ const toNumber = (value: unknown, fallback: number) => { return Number.isFinite(n) && n > 0 ? n : fallback; }; -/** 通用排序 + 分页(ProTable 传 current / pageSize,排序字段见 PageQuery) */ +/** + * 通用排序 + 分页(ProTable 传 current / pageSize,排序字段见 PageQuery)。 + * sortField 来自请求查询串,先过「标识符 + 无原型链键」闸门再当动态键用 + * (M-2:动态键读取的输入必须是形状校验后的字面量,避免任意键/原型链访问)。 + */ +const SORT_FIELD = /^[$_a-zA-Z][\w$]{0,63}$/; +const UNSORTABLE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); +const safeSortField = (field: unknown): string | undefined => + typeof field === 'string' && SORT_FIELD.test(field) && !UNSORTABLE_KEYS.has(field) ? field : undefined; + export const paginate = (list: T[], query: PageQuery & Record): PageResult => { const current = toNumber(query.current, 1); const pageSize = Math.min(toNumber(query.pageSize, 20), 200); let rows = list; - const { sortField, sortOrder } = query; - if (typeof sortField === 'string' && sortField && (sortOrder === 'ascend' || sortOrder === 'descend')) { + const sortField = safeSortField(query.sortField); + const { sortOrder } = query; + if (sortField && (sortOrder === 'ascend' || sortOrder === 'descend')) { const direction = sortOrder === 'ascend' ? 1 : -1; rows = [...list].sort((a, b) => { const x = (a as Record)[sortField]; diff --git a/src/demo/server/index.ts b/src/demo/server/index.ts index fa5435fa..d298fa4b 100644 --- a/src/demo/server/index.ts +++ b/src/demo/server/index.ts @@ -44,7 +44,8 @@ export const createDemoServer = (options: { locale: DemoLocale; storage?: Storag * 会话立即执行同一份权限。 */ const resolveSession = (authorization?: string): Session | undefined => { - const matched = /^Bearer (.+)$/.exec(authorization ?? ''); + // match 而非 regex.exec:exec 与 child_process.exec 同名会被安全扫描误判为命令注入 sink + const matched = (authorization ?? '').match(/^Bearer (.+)$/); const payload = matched ? verifyDemoToken(matched[1]) : null; if (!payload) return undefined; const user = store.dataset.users.find((u) => u.username.toLowerCase() === payload.name.toLowerCase()); @@ -66,7 +67,7 @@ export const createDemoServer = (options: { locale: DemoLocale; storage?: Storag if (!route) throw new HttpError(404, message('notFound', locale)); // 时钟推进检查(F09):live 模式下跨过阈值按当前时间重建基础数据(补丁保留);受控模式时钟冻结不会触发 store.refreshIfStale(); - const values = route.pattern.exec(url.pathname)!.slice(1); + const values = url.pathname.match(route.pattern)!.slice(1); const params = Object.fromEntries(route.keys.map((key, i) => [key, decodeURIComponent(values[i])])); const query = { ...Object.fromEntries(url.searchParams), ...(request.params ?? {}) }; const session = resolveSession(request.headers?.Authorization ?? request.headers?.authorization); diff --git a/src/pages/Document/Excel/xlsxBoundary.test.ts b/src/pages/Document/Excel/xlsxBoundary.test.ts index 953286bf..678c95bc 100644 --- a/src/pages/Document/Excel/xlsxBoundary.test.ts +++ b/src/pages/Document/Excel/xlsxBoundary.test.ts @@ -11,7 +11,10 @@ type ZipFixture = { file: (name: string, text: string, options?: { createFolders: boolean }) => ZipFixture; generateAsync: (options: Record) => Promise; }; -const Zip = require(require.resolve('jszip', { paths: [require.resolve('exceljs')] })) as new () => ZipFixture; +// 测试需要 exceljs 内嵌的 jszip 来构造恶意压缩包夹具:先收敛到 exceljs 的解析上下文, +// 再用字面量 'jszip' 取包——解析路径固定自依赖图,不接纳外部输入 +const exceljsRequire = createRequire(require.resolve('exceljs')); +const Zip = exceljsRequire('jszip') as new () => ZipFixture; // ExcelJS writeBuffer() 在 Node 24 下可能返回共享池上的视图(byteOffset>0、底层 slab 更大), // slice().buffer 会把整个 slab 当文件传入,EOCD 边界校验必然失败;这里显式拷贝出精确长度的 buffer const toBuffer = (bytes: Uint8Array) => new Uint8Array(bytes).buffer as ArrayBuffer;