fix(security): 附件 GET 端点防同源存储型 XSS——非白名单图片(svg/html等)强制下载+octet-stream+nosniff+CSP sandbox
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 <noreply@anthropic.com>
This commit is contained in:
+9
-2
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
@@ -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('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>');
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user