Files
picpeak/backend/__tests__/utils/filenameSanitizer.test.js
T
Paul Nothaft defeae9634 fix(uploads): prevent cross-photo contamination from filename collisions and non-atomic writes (#931) (#933)
* 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>
2026-08-01 12:29:12 +02:00

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('abc.jpg')).toBe('abc.jpg');
});
});
describe('uniquifyZipNames', () => {
it('returns unchanged names when there are no collisions', () => {
expect(uniquifyZipNames(['a.jpg', 'b.jpg', 'c.jpg'])).toEqual([
'a.jpg',
'b.jpg',
'c.jpg',
]);
});
it('appends _N before the extension on collision', () => {
expect(uniquifyZipNames(['DSC_1.jpg', 'DSC_1.jpg', 'DSC_1.jpg'])).toEqual([
'DSC_1.jpg',
'DSC_1_1.jpg',
'DSC_1_2.jpg',
]);
});
it('handles dotless names by appending at the end', () => {
expect(uniquifyZipNames(['raw', 'raw'])).toEqual(['raw', 'raw_1']);
});
it('is deterministic for the same input order', () => {
const input = ['A.jpg', 'A.jpg', 'B.jpg', 'A.jpg', 'B.jpg'];
expect(uniquifyZipNames(input)).toEqual([
'A.jpg',
'A_1.jpg',
'B.jpg',
'A_2.jpg',
'B_1.jpg',
]);
});
it('avoids reusing a synthesized name that already exists explicitly', () => {
// The natural `_1` is taken — the next collision must skip to `_2`.
expect(uniquifyZipNames(['DSC.jpg', 'DSC_1.jpg', 'DSC.jpg'])).toEqual([
'DSC.jpg',
'DSC_1.jpg',
'DSC_2.jpg',
]);
});
});
describe('generatePhotoFilename', () => {
it('keeps the event/category/counter prefix and extension', () => {
const name = generatePhotoFilename('Summer Wedding', 'individual', 42, '.jpg');
expect(name).toMatch(/^Summer_Wedding_individual_0042_[0-9a-f]{12}\.jpg$/);
});
// #931 — the counter base is count(*)+1 computed per upload request, so
// concurrent bulk uploads assign the same counter to different photos.
// The random suffix is what keeps the final storage path unique; without
// it, the second writer overwrote the first photo's bytes at its
// already-recorded path (cross-photo contamination).
//
// A suffix-less/constant implementation makes ANY two identical-input
// calls collide, so a small sample catches the regression every time —
// while keeping the birthday-collision odds of a false failure
// negligible (C(5,2)/2^48 ≈ 4e-14).
it('produces distinct names for identical inputs', () => {
const names = new Set(
Array.from({ length: 5 }, () =>
generatePhotoFilename('Event', 'individual', 7, '.jpg')
)
);
expect(names.size).toBe(5);
});
});