- Add superadmin impersonation: sessions.impersonated_by (migration 0003) is stamped onto audit rows so impersonated actions are attributable, with a persistent ImpersonationBanner in the app shell. - Harden auth and upload handling across routes (safe redirect targets, filename sanitization, checkout grant handling). - Update dependencies: Sentry 8 -> 10, @fastify/static 8 -> 10, react-router-dom 6.30.6; add find-my-way / fast-uri overrides. - Add tests for safe-next, checkout-grant, and upload-filename. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
82 lines
3.5 KiB
TypeScript
82 lines
3.5 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import path from 'node:path';
|
|
|
|
// Mirrors the helpers in src/routes/documents.ts. They aren't exported (the route module pulls in
|
|
// env + DB on import, which this suite deliberately avoids), so the logic is restated verbatim and
|
|
// asserted against the shapes that matter. If the route changes, change this with it.
|
|
const MAX_NAME_LENGTH = 200;
|
|
|
|
function safeDisplayName(filename: string): string {
|
|
const base = path.basename(filename.replace(/\\/g, '/'));
|
|
// eslint-disable-next-line no-control-regex
|
|
const cleaned = base.replace(/[\u0000-\u001f\u007f]/g, '').trim();
|
|
return (cleaned || 'document').slice(0, MAX_NAME_LENGTH);
|
|
}
|
|
|
|
function safeExtension(filename: string): string {
|
|
const ext = path.extname(safeDisplayName(filename)).toLowerCase();
|
|
return /^\.[a-z0-9]{1,8}$/.test(ext) ? ext : '';
|
|
}
|
|
|
|
describe('upload filename sanitisation', () => {
|
|
it('keeps an ordinary name and extension intact', () => {
|
|
expect(safeDisplayName('Engagement Letter.pdf')).toBe('Engagement Letter.pdf');
|
|
expect(safeExtension('Engagement Letter.pdf')).toBe('.pdf');
|
|
});
|
|
|
|
it('strips directory components from posix and windows paths', () => {
|
|
expect(safeDisplayName('../../etc/passwd')).toBe('passwd');
|
|
expect(safeDisplayName('..\\..\\windows\\system32\\config')).toBe('config');
|
|
expect(safeDisplayName('/absolute/path/deed.pdf')).toBe('deed.pdf');
|
|
});
|
|
|
|
it('never lets a path separator reach the storage key', () => {
|
|
for (const name of ['a/b.pdf', 'a\\b.pdf', '../x.pdf', '..%2fx.pdf']) {
|
|
expect(safeExtension(name)).not.toContain('/');
|
|
expect(safeExtension(name)).not.toContain('\\');
|
|
expect(safeDisplayName(name)).not.toContain('/');
|
|
expect(safeDisplayName(name)).not.toContain('\\');
|
|
}
|
|
});
|
|
|
|
it('removes control characters, including NUL and CRLF', () => {
|
|
expect(safeDisplayName('in\rvoice\n.pdf')).toBe('invoice.pdf');
|
|
expect(safeDisplayName('a\u0000b.pdf')).toBe('ab.pdf');
|
|
expect(safeDisplayName('tab\tsep.pdf')).toBe('tabsep.pdf');
|
|
expect(safeDisplayName('del\u007fx.pdf')).toBe('delx.pdf');
|
|
});
|
|
|
|
it('bounds the stored display name', () => {
|
|
const long = `${'x'.repeat(500)}.pdf`;
|
|
expect(safeDisplayName(long).length).toBe(MAX_NAME_LENGTH);
|
|
});
|
|
|
|
it('falls back to a placeholder rather than an empty name', () => {
|
|
expect(safeDisplayName('')).toBe('document');
|
|
expect(safeDisplayName(' ')).toBe('document');
|
|
expect(safeDisplayName(' |