fix: 上传加固——bodyLimit 预检 413、StoredMeta 预留 ownerSub、强制 attachment+nosniff、穿越测试、RFC5987 文件名
This commit is contained in:
@@ -11,6 +11,7 @@ export function createFsObjectStore(dir: string): ObjectStore {
|
|||||||
const stored: StoredMeta = {
|
const stored: StoredMeta = {
|
||||||
contentType: meta.contentType ?? 'application/octet-stream',
|
contentType: meta.contentType ?? 'application/octet-stream',
|
||||||
...(meta.filename ? { filename: meta.filename } : {}),
|
...(meta.filename ? { filename: meta.filename } : {}),
|
||||||
|
...(meta.ownerSub ? { ownerSub: meta.ownerSub } : {}),
|
||||||
size: data.byteLength,
|
size: data.byteLength,
|
||||||
};
|
};
|
||||||
await writeFile(join(dir, `${ref}.bin`), data);
|
await writeFile(join(dir, `${ref}.bin`), data);
|
||||||
|
|||||||
@@ -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 {
|
export interface ObjectStore {
|
||||||
put(data: Uint8Array, meta: { contentType?: string; filename?: string }): Promise<string>;
|
put(data: Uint8Array, meta: { contentType?: string; filename?: string; ownerSub?: string }): Promise<string>;
|
||||||
// D4:data 显式标注 Uint8Array<ArrayBuffer>——TS 5.9 下 Uint8Array<ArrayBufferLike>
|
// D4:data 显式标注 Uint8Array<ArrayBuffer>——TS 5.9 下 Uint8Array<ArrayBufferLike>
|
||||||
// 不可赋给 Hono 的 Data(要求 ArrayBuffer 支撑),c.body(found.data) 会红。
|
// 不可赋给 Hono 的 Data(要求 ArrayBuffer 支撑),c.body(found.data) 会红。
|
||||||
get(ref: string): Promise<{ data: Uint8Array<ArrayBuffer>; meta: StoredMeta } | null>;
|
get(ref: string): Promise<{ data: Uint8Array<ArrayBuffer>; meta: StoredMeta } | null>;
|
||||||
|
|||||||
@@ -1,32 +1,64 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
import { bodyLimit } from 'hono/body-limit';
|
||||||
|
import type { Context } from 'hono';
|
||||||
import { requireAuth, requireRealmRole } from '../../shared/keycloak-auth';
|
import { requireAuth, requireRealmRole } from '../../shared/keycloak-auth';
|
||||||
import { createFsObjectStore } from './fs-store';
|
import { createFsObjectStore } from './fs-store';
|
||||||
|
|
||||||
const MAX_BYTES = 8 * 1024 * 1024;
|
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 {
|
export function uploadRoutes(): Hono {
|
||||||
const r = new Hono();
|
const r = new Hono();
|
||||||
r.use('*', requireAuth(), requireRealmRole('user', 'admin', 'service'));
|
r.use('*', requireAuth(), requireRealmRole('user', 'admin', 'service'));
|
||||||
|
|
||||||
r.post('/', async (c) => {
|
r.post('/', bodyLimit({ maxSize: MAX_BYTES, onError: payloadTooLarge }), async (c) => {
|
||||||
const store = createFsObjectStore(process.env.OBJECT_STORE_DIR ?? './.objectstore');
|
const store = createFsObjectStore(storeDir());
|
||||||
const body = new Uint8Array(await c.req.arrayBuffer());
|
const body = new Uint8Array(await c.req.arrayBuffer());
|
||||||
if (body.byteLength > MAX_BYTES) {
|
const ownerSub = c.get('user')?.id;
|
||||||
return c.json({ error: 'payload_too_large', message: `上限 8MB,收到 ${body.byteLength} 字节——拆分或压缩后再传` }, 413);
|
|
||||||
}
|
|
||||||
const ref = await store.put(body, {
|
const ref = await store.put(body, {
|
||||||
contentType: c.req.header('content-type') ?? 'application/octet-stream',
|
contentType: c.req.header('content-type') ?? 'application/octet-stream',
|
||||||
...(c.req.header('x-filename') ? { filename: c.req.header('x-filename') } : {}),
|
...(c.req.header('x-filename') ? { filename: c.req.header('x-filename') } : {}),
|
||||||
|
...(ownerSub ? { ownerSub } : {}), // F2:只落盘,读侧 N3 再管
|
||||||
});
|
});
|
||||||
return c.json({ ref }, 201);
|
return c.json({ ref }, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
r.get('/:ref', async (c) => {
|
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'));
|
const found = await store.get(c.req.param('ref'));
|
||||||
if (!found) return c.json({ error: 'not_found', message: '对象不存在——核对 ref' }, 404);
|
if (!found) return c.json({ error: 'not_found', message: '对象不存在——核对 ref' }, 404);
|
||||||
c.header('Content-Type', found.meta.contentType);
|
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);
|
return c.body(found.data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { mkdtempSync, rmSync } from 'node:fs';
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
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 { beforeAll, afterAll, describe, expect, it } from 'vitest';
|
||||||
import { buildApp } from '../../src/app/app';
|
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';
|
import { makeToken, setupTestJwks } from '../support/token';
|
||||||
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), 'bpos-store-'));
|
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}` } });
|
const back = await app.request(`/api/upload/${ref}`, { headers: { authorization: `Bearer ${token}` } });
|
||||||
expect(back.status).toBe(200);
|
expect(back.status).toBe(200);
|
||||||
expect(back.headers.get('content-type')).toContain('text/plain');
|
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(new Uint8Array(await back.arrayBuffer())).toEqual(payload);
|
||||||
|
|
||||||
expect((await app.request('/api/upload', { method: 'POST' })).status).toBe(401);
|
expect((await app.request('/api/upload', { method: 'POST' })).status).toBe(401);
|
||||||
@@ -39,4 +43,91 @@ describe('upload', () => {
|
|||||||
});
|
});
|
||||||
expect(tooBig.status).toBe(413);
|
expect(tooBig.status).toBe(413);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('F1:超限在收完之前就断,不把整个请求体收进内存', async () => {
|
||||||
|
const TOTAL = 64 * 1024 * 1024;
|
||||||
|
let pulled = 0;
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
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('<script>alert(1)</script>'), {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user