From d9d3de26b825912431aacb907af2980e41b25f75 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Tue, 30 Jun 2026 01:59:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(security):=20=E9=99=84=E4=BB=B6=20GET=20?= =?UTF-8?q?=E7=AB=AF=E7=82=B9=E9=98=B2=E5=90=8C=E6=BA=90=E5=AD=98=E5=82=A8?= =?UTF-8?q?=E5=9E=8B=20XSS=E2=80=94=E2=80=94=E9=9D=9E=E7=99=BD=E5=90=8D?= =?UTF-8?q?=E5=8D=95=E5=9B=BE=E7=89=87(svg/html=E7=AD=89)=E5=BC=BA?= =?UTF-8?q?=E5=88=B6=E4=B8=8B=E8=BD=BD+octet-stream+nosniff+CSP=20sandbox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit maestro 双复审漏掉的真实 HIGH:上传文件与 API 同源,inline 渲染会在 console 源执行其脚本。只对 png/jpeg/gif/webp 允许 inline 预览,其余强制 attachment 下载并改 content-type 为 octet-stream,加 X-Content-Type-Options:nosniff + CSP sandbox 双重兜底。带 SVG 安全单测。 Co-Authored-By: Claude Opus 4.8 --- src/api/server.ts | 11 +++++++++-- test/attachments.test.ts | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/api/server.ts b/src/api/server.ts index 73e8c2a..d92eb13 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -375,12 +375,19 @@ export function buildServer(opts: ApiOptions): FastifyInstance { const dl = (req.query as { download?: string }).download; const fallbackName = att.name || name; + // 安全:上传文件与本 API 同源,inline 渲染会在 console 源里执行其脚本 → 存储型 XSS。 + // 故只对【硬白名单图片类型】允许 inline 预览;其余(尤其 image/svg+xml、text/html 等可携带脚本的类型) + // 一律强制 attachment 下载 + content-type 改 octet-stream,并加 nosniff + CSP sandbox 双重兜底。 + const SAFE_INLINE = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); + const inline = !dl && SAFE_INLINE.has(att.type ?? ''); reply .header('cache-control', 'private, max-age=300') .header('content-length', String(st.size)) + .header('x-content-type-options', 'nosniff') + .header('content-security-policy', "sandbox; default-src 'none'") .header('content-disposition', - `${dl ? 'attachment' : 'inline'}; filename*=UTF-8''${encodeURIComponent(fallbackName)}`) - .type(att.type || 'application/octet-stream'); + `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(fallbackName)}`) + .type(inline ? (att.type as string) : 'application/octet-stream'); return reply.send(createReadStream(abs)); }); diff --git a/test/attachments.test.ts b/test/attachments.test.ts index 4a898bd..3704510 100644 --- a/test/attachments.test.ts +++ b/test/attachments.test.ts @@ -89,6 +89,25 @@ test('GET 附件:默认 inline,content-type/length 正确', async () => { store.close(); }); +test('GET 附件【安全】:SVG/HTML 等可执行类型强制下载(attachment + octet-stream + nosniff + CSP sandbox),杜绝同源存储型 XSS', async () => { + const dir = tmpData(); + process.env.MAESTRO_DATA_DIR = dir; + const { store, taskId } = freshTask(); + const svg = Buffer.from(''); + store.addAttachments(taskId, [placeFile(dir, taskId, 'evil.svg', 'image/svg+xml', svg)]); + + const app = buildServer({ store }); + const res = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}/attachments/evil.svg` }); + assert.equal(res.statusCode, 200); + // 非白名单图片类型(含 svg/html):绝不 inline、绝不以原 MIME 回 → 强制下载 + octet-stream + 双重头兜底 + assert.match(res.headers['content-disposition'] as string, /^attachment;/, 'SVG 必须强制 attachment 下载'); + assert.match(res.headers['content-type'] as string, /application\/octet-stream/, 'content-type 必须改为 octet-stream(不能回 image/svg+xml)'); + assert.equal(res.headers['x-content-type-options'], 'nosniff'); + assert.match(res.headers['content-security-policy'] as string, /sandbox/); + await app.close(); + store.close(); +}); + test('GET 附件:?download=1 → attachment + RFC5987 文件名(中文不乱码)', async () => { const dir = tmpData(); process.env.MAESTRO_DATA_DIR = dir;