Files
immich-folio/lib/__tests__/config-hero-uuid.test.ts
Ralksta d4d8c8b42f 🛡️ Sentinel: [HIGH] Fix timingSafeEqual DoS Vulnerability (#423)
* Fix timingSafeEqual DoS Vulnerability in Webhook

Co-authored-by: ralksta <16941050+ralksta@users.noreply.github.com>

* Fix memory exhaustion DoS in token decoding

Co-authored-by: ralksta <16941050+ralksta@users.noreply.github.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-14 11:10:42 +02:00

115 lines
4.2 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/lib/env', () => ({
env: {
IMMICH_API_URL: 'http://localhost:2283',
IMMICH_API_KEY: 'test-key',
SITE_TITLE: 'Test Gallery',
SITE_SUBTITLE: '',
CACHE_TTL: 300,
IMMICH_TIMEOUT_MS: 15000,
RATE_LIMIT_RPM: 120,
TRUSTED_PROXY_HOPS: 0,
},
}));
vi.mock('@/lib/secret', () => ({
resolveAuthSecret: () => 'test-auth-secret-32-chars-long-min',
}));
// Spy on validateUuid so we can assert *which* values are routed through it.
// vi.hoisted because vi.mock factories are hoisted above const declarations.
// Two-arg signature, matching the real validateUuid(id, context) — the context
// argument is what the second assertion below inspects.
const { validateUuidSpy } = vi.hoisted(() => ({
validateUuidSpy: vi.fn((id: string, _context: string) => id),
}));
vi.mock('@/lib/config/parser', () => ({
loadYaml: vi.fn(),
clearYamlCache: vi.fn(),
validateUuid: validateUuidSpy,
}));
import { getConfig, invalidateConfigCache } from '@/lib/config';
import { loadYaml } from '@/lib/config/parser';
const ALBUM_ID = '11111111-1111-1111-1111-111111111111';
const HERO_ID = '22222222-2222-2222-2222-222222222222';
/**
* Every asset ID in gallery.yaml is routed through validateUuid before it can
* reach encodeAssetId, which encrypts whatever it is handed. A per-album
* heroImage was the one that slipped through: the album ID (the map key) was
* validated, the hero asset ID (the value) was stored raw. A typo there
* produced a perfectly well-formed image URL that 404s forever, with nothing in
* the log naming the cause.
*/
describe('gallery.yaml per-album heroImage', () => {
beforeEach(() => {
validateUuidSpy.mockClear();
invalidateConfigCache();
vi.mocked(loadYaml).mockImplementation((filename: string) => {
if (filename === 'gallery.yaml') {
return { albums: [{ [ALBUM_ID]: { title: 'An Album', heroImage: HERO_ID } }] };
}
if (filename === 'settings.yaml') return {};
return null;
});
});
it('routes the hero asset ID through validateUuid, not just the album ID', () => {
getConfig();
const validated = validateUuidSpy.mock.calls.map((c) => c[0]);
expect(validated).toContain(HERO_ID);
});
it('names the album in the validation context so the warning is actionable', () => {
getConfig();
const heroCall = validateUuidSpy.mock.calls.find((c) => c[0] === HERO_ID);
expect(heroCall?.[1]).toContain(ALBUM_ID);
});
it('stores the validated value, keyed by album', () => {
expect(getConfig().albumHeroImages[ALBUM_ID]).toBe(HERO_ID);
});
// Same class of ID, same requirement: a pinned asset ID from `sort: manual`
// is written by the admin panel and can be hand-edited, so it goes through
// the same gate. A substituted zero UUID simply never matches an asset in the
// album, so the pin is ignored rather than corrupting the order.
it('routes manual assetOrder IDs through validateUuid, naming the album', () => {
vi.mocked(loadYaml).mockImplementation((filename: string) => {
if (filename === 'gallery.yaml') {
return { albums: [{ [ALBUM_ID]: { sort: 'manual', assetOrder: [HERO_ID] } }] };
}
if (filename === 'settings.yaml') return {};
return null;
});
invalidateConfigCache();
const config = getConfig();
expect(config.albumManualOrders[ALBUM_ID]).toEqual([HERO_ID]);
const call = validateUuidSpy.mock.calls.find((c) => c[0] === HERO_ID);
expect(call?.[1]).toContain(ALBUM_ID);
});
// The substitution the wiring above depends on: validateUuid never throws, so
// a bad ID degrades to a 404 image rather than taking the whole page down.
it('real validateUuid warns and substitutes rather than throwing', async () => {
const actual =
await vi.importActual<typeof import('@/lib/config/parser')>('@/lib/config/parser');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = actual.validateUuid('not-a-uuid', 'gallery.yaml heroImage');
expect(result).toBe('00000000-0000-0000-0000-000000000000');
expect(warn).toHaveBeenCalled();
expect(String(warn.mock.calls[0][0])).toContain('gallery.yaml heroImage');
warn.mockRestore();
});
});