Files

134 lines
6.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 });
}
});
});