Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions api/[...path].js
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand Down
5 changes: 4 additions & 1 deletion config/chunkGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
Expand Down
14 changes: 13 additions & 1 deletion scripts/check-dist-secrets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>) => {
Expand All @@ -56,6 +63,11 @@ const isolatedEnv = () =>
* cwd 是一个空目录:扫描器按 umi 的口径读取 cwd 下的 .env / .env.local,仓库根目录的本地文件不能混进用例。
*/
const runScan = (distDir: string, extraEnv: Record<string, string> = {}, 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,
Expand Down Expand Up @@ -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');
Expand Down
9 changes: 6 additions & 3 deletions scripts/deploy-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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');
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion scripts/real-mode-artifact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ const entryChunks = () => {
const html = readFileSync(indexPath, 'utf8');
return (html.match(/<script\b[^>]*>/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)
Expand Down
16 changes: 13 additions & 3 deletions src/demo/server/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T>(list: T[], query: PageQuery & Record<string, unknown>): PageResult<T> => {
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<string, unknown>)[sortField];
Expand Down
5 changes: 3 additions & 2 deletions src/demo/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion src/pages/Document/Excel/xlsxBoundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ type ZipFixture = {
file: (name: string, text: string, options?: { createFolders: boolean }) => ZipFixture;
generateAsync: (options: Record<string, string | boolean>) => Promise<Uint8Array>;
};
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;
Expand Down
Loading