134 lines
6.1 KiB
TypeScript
134 lines
6.1 KiB
TypeScript
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
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-'));
|
||
|
||
describe('upload', () => {
|
||
let token = '';
|
||
beforeAll(async () => {
|
||
process.env.OBJECT_STORE_DIR = dir;
|
||
await setupTestJwks();
|
||
token = await makeToken({ roles: ['user'], aud: 'api-gateway' });
|
||
});
|
||
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
||
|
||
it('上传→取回一致;越权 401;超 8MB 413', async () => {
|
||
const app = buildApp();
|
||
const payload = new TextEncoder().encode('原件字节');
|
||
const up = await app.request('/api/upload', {
|
||
method: 'POST',
|
||
headers: { authorization: `Bearer ${token}`, 'content-type': 'text/plain', 'x-filename': 'a.txt' },
|
||
body: payload,
|
||
});
|
||
expect(up.status).toBe(201);
|
||
const { ref } = (await up.json()) as { ref: string };
|
||
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);
|
||
|
||
const big = new Uint8Array(8 * 1024 * 1024 + 1);
|
||
const tooBig = await app.request('/api/upload', {
|
||
method: 'POST', headers: { authorization: `Bearer ${token}` }, body: big,
|
||
});
|
||
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 });
|
||
}
|
||
});
|
||
});
|