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;