defeae9634
* fix(uploads): prevent cross-photo contamination from filename collisions and non-atomic writes (#931) * test: pin the suffixed photo filename format in the NFD pipeline suite (#931) * test: make the suffix-uniqueness check deterministic-in-practice (#931) * fix(uploads): widen the anti-collision suffix to 48 bits (#931) * fix(uploads): hide staging files from list() + share one watermark limiter process-wide (#931) * fix(uploads): reclaim orphaned staging files + revalidate watermark settings in queued jobs (#931) --------- Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
156 lines
5.4 KiB
JavaScript
156 lines
5.4 KiB
JavaScript
/**
|
|
* Tests for the download-filename helpers added in #493.
|
|
* Covers header-injection defence, RFC 5987 encoding, zip path-traversal
|
|
* sanitization, and deterministic collision suffixing.
|
|
*/
|
|
|
|
const {
|
|
sanitizeForContentDisposition,
|
|
buildContentDisposition,
|
|
sanitizeForZipEntry,
|
|
uniquifyZipNames,
|
|
generatePhotoFilename,
|
|
} = require('../../src/utils/filenameSanitizer');
|
|
|
|
describe('sanitizeForContentDisposition', () => {
|
|
it('keeps ASCII camera filenames intact', () => {
|
|
expect(sanitizeForContentDisposition('DSC_1234.jpg')).toBe('DSC_1234.jpg');
|
|
});
|
|
|
|
it('strips CR/LF/NUL to defeat header injection', () => {
|
|
const out = sanitizeForContentDisposition('file\r\nX-Evil: yes\nname.jpg');
|
|
expect(out).not.toMatch(/[\r\n\0]/);
|
|
expect(out).toContain('X-Evil');
|
|
expect(out).toContain('name.jpg');
|
|
});
|
|
|
|
it('replaces path separators and quotes with underscore', () => {
|
|
// Leading dots get stripped to defeat `..` showing up as a download.
|
|
expect(sanitizeForContentDisposition('../etc/passwd')).toBe('etc_passwd');
|
|
expect(sanitizeForContentDisposition('a"b.jpg')).toBe('a_b.jpg');
|
|
});
|
|
|
|
it('collapses non-ASCII bytes (filename* carries them instead)', () => {
|
|
const out = sanitizeForContentDisposition('über_照片.jpg');
|
|
expect(out).toMatch(/^[\x20-\x7E]+$/);
|
|
expect(out).toContain('.jpg');
|
|
});
|
|
|
|
it('returns a safe fallback for empty input', () => {
|
|
expect(sanitizeForContentDisposition('')).toBe('download');
|
|
expect(sanitizeForContentDisposition(null)).toBe('download');
|
|
});
|
|
});
|
|
|
|
describe('buildContentDisposition', () => {
|
|
it('emits both filename and filename* parameters', () => {
|
|
const header = buildContentDisposition('DSC_1234.jpg');
|
|
expect(header).toBe('attachment; filename="DSC_1234.jpg"; filename*=UTF-8\'\'DSC_1234.jpg');
|
|
});
|
|
|
|
it('percent-encodes unicode in filename*', () => {
|
|
const header = buildContentDisposition('über.jpg');
|
|
expect(header).toContain('filename="ber.jpg"');
|
|
// %C3%BC is UTF-8 for ü.
|
|
expect(header).toContain("filename*=UTF-8''%C3%BCber.jpg");
|
|
});
|
|
|
|
it('escapes the parens/apostrophe that break RFC 5987 attr-char', () => {
|
|
const header = buildContentDisposition("a'b(c).jpg");
|
|
expect(header).toMatch(/filename\*=UTF-8''/);
|
|
expect(header).not.toMatch(/filename\*=UTF-8''.*\(/);
|
|
});
|
|
});
|
|
|
|
describe('sanitizeForZipEntry', () => {
|
|
it('preserves spaces and parentheses (real camera names use them)', () => {
|
|
expect(sanitizeForZipEntry('IMG 1234 (2).jpg')).toBe('IMG 1234 (2).jpg');
|
|
});
|
|
|
|
it('neutralises path traversal', () => {
|
|
// The exact result keeps leading underscores (so `_DSC1234.NEF`-style
|
|
// Nikon raws survive) — the safety property we care about is that no
|
|
// path separator escapes into the zip entry.
|
|
const out = sanitizeForZipEntry('../../etc/passwd');
|
|
expect(out).not.toMatch(/[\/\\]/);
|
|
expect(out).toContain('etc_passwd');
|
|
expect(sanitizeForZipEntry('a/b\\c.jpg')).toBe('a_b_c.jpg');
|
|
});
|
|
|
|
it('preserves leading underscore for Nikon-style raw names', () => {
|
|
expect(sanitizeForZipEntry('_DSC1234.NEF')).toBe('_DSC1234.NEF');
|
|
});
|
|
|
|
it('strips control characters', () => {
|
|
expect(sanitizeForZipEntry('a |