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 = {
|
||||
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);
|
||||
|
||||
@@ -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<string>;
|
||||
put(data: Uint8Array, meta: { contentType?: string; filename?: string; ownerSub?: string }): Promise<string>;
|
||||
// D4:data 显式标注 Uint8Array<ArrayBuffer>——TS 5.9 下 Uint8Array<ArrayBufferLike>
|
||||
// 不可赋给 Hono 的 Data(要求 ArrayBuffer 支撑),c.body(found.data) 会红。
|
||||
get(ref: string): Promise<{ data: Uint8Array<ArrayBuffer>; meta: StoredMeta } | null>;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user