From ae1cf73160e4dfe98e6cb241a58813c6d47f48e0 Mon Sep 17 00:00:00 2001 From: lovefyf Date: Thu, 13 Aug 2026 05:30:08 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=B8=8A=E4=BC=A0=E5=8A=A0=E5=9B=BA?= =?UTF-8?q?=E2=80=94=E2=80=94bodyLimit=20=E9=A2=84=E6=A3=80=20413=E3=80=81?= =?UTF-8?q?StoredMeta=20=E9=A2=84=E7=95=99=20ownerSub=E3=80=81=E5=BC=BA?= =?UTF-8?q?=E5=88=B6=20attachment+nosniff=E3=80=81=E7=A9=BF=E8=B6=8A?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E3=80=81RFC5987=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/upload/fs-store.ts | 1 + src/modules/upload/objectstore.ts | 10 +++- src/modules/upload/routes.ts | 46 ++++++++++++--- test/integration/upload.test.ts | 95 ++++++++++++++++++++++++++++++- 4 files changed, 141 insertions(+), 11 deletions(-) diff --git a/src/modules/upload/fs-store.ts b/src/modules/upload/fs-store.ts index 09a6cb4..dbb0d84 100644 --- a/src/modules/upload/fs-store.ts +++ b/src/modules/upload/fs-store.ts @@ -11,6 +11,7 @@ export function createFsObjectStore(dir: string): ObjectStore { const stored: StoredMeta = { contentType: meta.contentType ?? 'application/octet-stream', ...(meta.filename ? { filename: meta.filename } : {}), + ...(meta.ownerSub ? { ownerSub: meta.ownerSub } : {}), size: data.byteLength, }; await writeFile(join(dir, `${ref}.bin`), data); diff --git a/src/modules/upload/objectstore.ts b/src/modules/upload/objectstore.ts index 6c22f7f..b1f9e7c 100644 --- a/src/modules/upload/objectstore.ts +++ b/src/modules/upload/objectstore.ts @@ -1,6 +1,12 @@ -export interface StoredMeta { contentType: string; filename?: string; size: number } +export interface StoredMeta { + contentType: string; + filename?: string; + /** 归档所有权字段,N3 起用:此刻只落盘,读侧不校验(N3 分发通道接手时再加 ACL) */ + ownerSub?: string; + size: number; +} export interface ObjectStore { - put(data: Uint8Array, meta: { contentType?: string; filename?: string }): Promise; + put(data: Uint8Array, meta: { contentType?: string; filename?: string; ownerSub?: string }): Promise; // D4:data 显式标注 Uint8Array——TS 5.9 下 Uint8Array // 不可赋给 Hono 的 Data(要求 ArrayBuffer 支撑),c.body(found.data) 会红。 get(ref: string): Promise<{ data: Uint8Array; meta: StoredMeta } | null>; diff --git a/src/modules/upload/routes.ts b/src/modules/upload/routes.ts index 82181cd..e8979d9 100644 --- a/src/modules/upload/routes.ts +++ b/src/modules/upload/routes.ts @@ -1,32 +1,64 @@ import { Hono } from 'hono'; +import { bodyLimit } from 'hono/body-limit'; +import type { Context } from 'hono'; import { requireAuth, requireRealmRole } from '../../shared/keycloak-auth'; import { createFsObjectStore } from './fs-store'; const MAX_BYTES = 8 * 1024 * 1024; +function storeDir(): string { + return process.env.OBJECT_STORE_DIR ?? './.objectstore'; +} + +/** + * F1:413 必须在收完 body 之前定。bodyLimit 先看 content-length,没有就边读边计数、 + * 一超限立刻断流——1GB 的 POST 不会先整个躺进内存再挨拒。 + */ +function payloadTooLarge(c: Context): Response { + const len = c.req.header('content-length'); + const got = len ? `收到 ${len} 字节` : '请求体超出上限'; + return c.json({ error: 'payload_too_large', message: `上限 8MB,${got}——拆分或压缩后再传` }, 413); +} + +/** + * F5:RFC 5987。`filename="..."` 只认 ASCII,浏览器不会去解 percent-escape—— + * 中文名照 encodeURIComponent 塞进去会原样显示成 %E4%B8%AD。真正生效的是 filename*。 + */ +function contentDisposition(filename?: string): string { + if (!filename) return 'attachment'; + const ascii = filename.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_'); + const encoded = encodeURIComponent(filename).replace( + /['()*]/g, + (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`, + ); + return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`; +} + export function uploadRoutes(): Hono { const r = new Hono(); r.use('*', requireAuth(), requireRealmRole('user', 'admin', 'service')); - r.post('/', async (c) => { - const store = createFsObjectStore(process.env.OBJECT_STORE_DIR ?? './.objectstore'); + r.post('/', bodyLimit({ maxSize: MAX_BYTES, onError: payloadTooLarge }), async (c) => { + const store = createFsObjectStore(storeDir()); const body = new Uint8Array(await c.req.arrayBuffer()); - if (body.byteLength > MAX_BYTES) { - return c.json({ error: 'payload_too_large', message: `上限 8MB,收到 ${body.byteLength} 字节——拆分或压缩后再传` }, 413); - } + const ownerSub = c.get('user')?.id; const ref = await store.put(body, { contentType: c.req.header('content-type') ?? 'application/octet-stream', ...(c.req.header('x-filename') ? { filename: c.req.header('x-filename') } : {}), + ...(ownerSub ? { ownerSub } : {}), // F2:只落盘,读侧 N3 再管 }); return c.json({ ref }, 201); }); r.get('/:ref', async (c) => { - const store = createFsObjectStore(process.env.OBJECT_STORE_DIR ?? './.objectstore'); + const store = createFsObjectStore(storeDir()); const found = await store.get(c.req.param('ref')); if (!found) return c.json({ error: 'not_found', message: '对象不存在——核对 ref' }, 404); c.header('Content-Type', found.meta.contentType); - if (found.meta.filename) c.header('Content-Disposition', `attachment; filename="${encodeURIComponent(found.meta.filename)}"`); + // F3:content-type 是上传方自报的,原样回显+内联渲染=网关源上的存储型 XSS。 + // 一律 attachment(不内联)+ nosniff(不让浏览器猜类型)。 + c.header('Content-Disposition', contentDisposition(found.meta.filename)); + c.header('X-Content-Type-Options', 'nosniff'); return c.body(found.data); }); diff --git a/test/integration/upload.test.ts b/test/integration/upload.test.ts index 525731a..9a06fba 100644 --- a/test/integration/upload.test.ts +++ b/test/integration/upload.test.ts @@ -1,8 +1,10 @@ -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { basename, join } from 'node:path'; import { beforeAll, afterAll, describe, expect, it } from 'vitest'; import { buildApp } from '../../src/app/app'; +import { createFsObjectStore } from '../../src/modules/upload/fs-store'; +import type { StoredMeta } from '../../src/modules/upload/objectstore'; import { makeToken, setupTestJwks } from '../support/token'; const dir = mkdtempSync(join(tmpdir(), 'bpos-store-')); @@ -29,6 +31,8 @@ describe('upload', () => { const back = await app.request(`/api/upload/${ref}`, { headers: { authorization: `Bearer ${token}` } }); expect(back.status).toBe(200); expect(back.headers.get('content-type')).toContain('text/plain'); + expect(back.headers.get('content-disposition')).toContain('attachment'); // F3 + expect(back.headers.get('x-content-type-options')).toBe('nosniff'); // F3 expect(new Uint8Array(await back.arrayBuffer())).toEqual(payload); expect((await app.request('/api/upload', { method: 'POST' })).status).toBe(401); @@ -39,4 +43,91 @@ describe('upload', () => { }); expect(tooBig.status).toBe(413); }); + + it('F1:超限在收完之前就断,不把整个请求体收进内存', async () => { + const TOTAL = 64 * 1024 * 1024; + let pulled = 0; + const body = new ReadableStream({ + pull(controller) { + if (pulled >= TOTAL) return controller.close(); + pulled += 1024 * 1024; + controller.enqueue(new Uint8Array(1024 * 1024)); + }, + }); + const res = await buildApp().request('/api/upload', { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + body, + duplex: 'half', // Node 要求流式 body 显式声明 + } as RequestInit); + expect(res.status).toBe(413); + expect((await res.json()) as unknown).toMatchObject({ error: 'payload_too_large' }); + // 关键:64MB 的流只被拉了 8MB 出头就掐断——413 不是"全收完再拒" + expect(pulled).toBeLessThan(16 * 1024 * 1024); + }); + + it('F2:PUT 时落盘 ownerSub(读侧 N3 才校验)', async () => { + const app = buildApp(); + const up = await app.request('/api/upload', { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/pdf' }, + body: new Uint8Array([1, 2, 3]), + }); + expect(up.status).toBe(201); + const { ref } = (await up.json()) as { ref: string }; + const meta = JSON.parse(readFileSync(join(dir, `${ref}.json`), 'utf8')) as StoredMeta; + expect(meta.ownerSub).toBe('test-sub-1'); // token 的 sub + expect(meta).toMatchObject({ contentType: 'application/pdf', size: 3 }); + // 落了 owner 也不影响取回:此阶段不做归属拦截 + const back = await app.request(`/api/upload/${ref}`, { headers: { authorization: `Bearer ${token}` } }); + expect(back.status).toBe(200); + }); + + it('F3/F5:html 原件也强制附件下载;中文名走 RFC 5987 filename*', async () => { + // 非 ASCII 头值过不了 fetch 的 ByteString 校验,故直接经 store 落一件中文名原件 + const ref = await createFsObjectStore(dir).put(new TextEncoder().encode(''), { + contentType: 'text/html', + filename: '中文 报告"x".txt', + ownerSub: 'test-sub-1', + }); + const res = await buildApp().request(`/api/upload/${ref}`, { + headers: { authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(200); + const cd = res.headers.get('content-disposition') ?? ''; + expect(cd.startsWith('attachment')).toBe(true); // 绝不 inline + expect(cd).toContain(`filename*=UTF-8''${encodeURIComponent('中文')}`); + expect(cd).not.toMatch(/filename="[^"]*[^\x20-\x7e][^"]*"/); // ASCII 兜底不夹生字节 + expect(cd).not.toMatch(/filename="[^"]*"[^;]/); // 引号被转义掉,不撑破头 + expect(res.headers.get('x-content-type-options')).toBe('nosniff'); + }); + + it('F4:穿越 ref 一律拒绝,读不到存储目录以外的文件', async () => { + const outside = mkdtempSync(join(tmpdir(), 'bpos-outside-')); + // 按 store 的落盘形状造一件"仓外原件":拿掉防穿越正则,下面的 rel 就真能读到它—— + // 保证这个用例不是空转(已实测:去掉正则 → 200+泄漏) + writeFileSync(join(outside, 'secret.bin'), 'TOP_SECRET_DO_NOT_LEAK'); + writeFileSync(join(outside, 'secret.json'), JSON.stringify({ contentType: 'text/plain', size: 22 })); + const rel = `../${basename(outside)}/secret`; // store 目录 → 仓外文件(.bin/.json 后缀由实现拼) + try { + const app = buildApp(); + const refs = [ + '../x', // 裸的点点斜杠(fetch 会先归一化 URL) + '%2e%2e%2fx', // 编码过的点点斜杠 + encodeURIComponent(rel), // 指向真实存在的仓外文件 + '..%2F..%2Fetc%2Fpasswd', + '%2Fetc%2Fpasswd', // 绝对路径样 + `${'a'.repeat(36)}%00../x`, // 空字节截断 + ]; + for (const ref of refs) { + const res = await app.request(`/api/upload/${ref}`, { + headers: { authorization: `Bearer ${token}` }, + }); + expect([400, 404]).toContain(res.status); + expect(await res.text()).not.toContain('TOP_SECRET'); + } + } finally { + rmSync(outside, { recursive: true, force: true }); + } + }); });