🛡️ 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>
This commit is contained in:
Ralksta
2026-08-14 11:10:42 +02:00
committed by GitHub
parent f120f05157
commit d4d8c8b42f
20 changed files with 1574 additions and 221 deletions

View File

@@ -0,0 +1,211 @@
import { describe, it, expect } from 'vitest';
import {
ALBUM_SORT_MODES,
isAlbumSortMode,
sortAlbumAssets,
type AlbumSortMode,
type SortableAsset,
} from '@/lib/albumSort';
function asset(id: string, overrides: Partial<SortableAsset> = {}): SortableAsset {
return {
id,
originalFileName: `${id}.jpg`,
fileCreatedAt: '2024-01-01T00:00:00.000Z',
localDateTime: '2024-01-01T00:00:00.000Z',
...overrides,
};
}
const ids = (assets: SortableAsset[]) => assets.map((a) => a.id);
describe('sortAlbumAssets is pure', () => {
it('returns a new array and leaves the input untouched', () => {
const input = [
asset('b', { localDateTime: '2024-02-01T00:00:00.000Z' }),
asset('a', { localDateTime: '2024-01-01T00:00:00.000Z' }),
];
const before = ids(input);
const out = sortAlbumAssets(input, { mode: 'oldest' });
expect(out).not.toBe(input);
expect(ids(input)).toEqual(before);
});
// The cached album is shared, so any mode that dropped or duplicated an asset
// would corrupt what every later request sees. This is the guard that any
// future rewrite of the manual branch has to survive.
it.each(ALBUM_SORT_MODES)('preserves the exact set of assets under %s', (mode) => {
const input = [asset('a'), asset('b'), asset('c'), asset('d')];
const out = sortAlbumAssets(input, {
mode: mode as AlbumSortMode,
immichOrder: 'asc',
manualOrder: ['c', 'zzz'],
});
expect(out).toHaveLength(input.length);
expect(ids(out).sort()).toEqual(['a', 'b', 'c', 'd']);
});
});
describe('capture-time modes', () => {
const jan = asset('jan', { localDateTime: '2024-01-01T00:00:00.000Z' });
const feb = asset('feb', { localDateTime: '2024-02-01T00:00:00.000Z' });
const mar = asset('mar', { localDateTime: '2024-03-01T00:00:00.000Z' });
it('orders newest first', () => {
expect(ids(sortAlbumAssets([jan, mar, feb], { mode: 'newest' }))).toEqual([
'mar',
'feb',
'jan',
]);
});
it('orders oldest first, exactly reversing newest', () => {
const input = [jan, mar, feb];
expect(ids(sortAlbumAssets(input, { mode: 'oldest' }))).toEqual(
ids(sortAlbumAssets(input, { mode: 'newest' })).reverse(),
);
});
it('falls back to fileCreatedAt when localDateTime ties', () => {
const early = asset('early', {
localDateTime: '2024-01-01T00:00:00.000Z',
fileCreatedAt: '2024-01-01T08:00:00.000Z',
});
const late = asset('late', {
localDateTime: '2024-01-01T00:00:00.000Z',
fileCreatedAt: '2024-01-01T20:00:00.000Z',
});
expect(ids(sortAlbumAssets([late, early], { mode: 'oldest' }))).toEqual(['early', 'late']);
});
// Date.parse() on garbage yields NaN, and a comparator returning NaN makes
// sort() incoherent — the result depends on the engine's algorithm, not the data.
it('treats an unparseable date as epoch rather than producing NaN', () => {
const broken = asset('broken', { localDateTime: 'not-a-date', fileCreatedAt: 'also-not' });
const out = sortAlbumAssets([feb, broken, jan], { mode: 'oldest' });
expect(ids(out)).toEqual(['broken', 'jan', 'feb']);
});
it('immich follows the album direction', () => {
const input = [feb, jan, mar];
expect(ids(sortAlbumAssets(input, { mode: 'immich', immichOrder: 'asc' }))).toEqual([
'jan',
'feb',
'mar',
]);
expect(ids(sortAlbumAssets(input, { mode: 'immich', immichOrder: 'desc' }))).toEqual([
'mar',
'feb',
'jan',
]);
});
});
describe('filename mode', () => {
it('collates numerically, so IMG_2 precedes IMG_10', () => {
const input = [
asset('c', { originalFileName: 'IMG_10.jpg' }),
asset('a', { originalFileName: 'IMG_2.jpg' }),
asset('b', { originalFileName: 'IMG_9.jpg' }),
];
expect(ids(sortAlbumAssets(input, { mode: 'filename' }))).toEqual(['a', 'b', 'c']);
});
it('is case-insensitive', () => {
const input = [
asset('b', { originalFileName: 'beta.jpg' }),
asset('a', { originalFileName: 'Alpha.jpg' }),
];
expect(ids(sortAlbumAssets(input, { mode: 'filename' }))).toEqual(['a', 'b']);
});
// Duplicate filenames across import batches are common; without the id
// tiebreak the order of a tie would follow the input and drift per request.
it('breaks ties on id so the order is stable', () => {
const one = asset('aaa', { originalFileName: 'DSC.jpg' });
const two = asset('bbb', { originalFileName: 'dsc.jpg' });
expect(ids(sortAlbumAssets([two, one], { mode: 'filename' }))).toEqual(['aaa', 'bbb']);
expect(ids(sortAlbumAssets([one, two], { mode: 'filename' }))).toEqual(['aaa', 'bbb']);
});
});
describe('manual mode is a pinned prefix', () => {
const a = asset('a', { localDateTime: '2024-01-01T00:00:00.000Z' });
const b = asset('b', { localDateTime: '2024-02-01T00:00:00.000Z' });
const c = asset('c', { localDateTime: '2024-03-01T00:00:00.000Z' });
it('puts pinned assets first, in the pinned order', () => {
const out = sortAlbumAssets([a, b, c], {
mode: 'manual',
immichOrder: 'asc',
manualOrder: ['c', 'a'],
});
expect(ids(out)).toEqual(['c', 'a', 'b']);
});
it('appends unpinned assets in the album Immich order', () => {
const desc = sortAlbumAssets([a, b, c], {
mode: 'manual',
immichOrder: 'desc',
manualOrder: ['a'],
});
const asc = sortAlbumAssets([a, b, c], {
mode: 'manual',
immichOrder: 'asc',
manualOrder: ['a'],
});
expect(ids(desc)).toEqual(['a', 'c', 'b']);
expect(ids(asc)).toEqual(['a', 'b', 'c']);
});
// The album can change in Immich after the order was saved; a stale id must
// not shift or drop anything.
it('ignores pinned ids that are no longer in the album', () => {
const out = sortAlbumAssets([a, b], {
mode: 'manual',
immichOrder: 'asc',
manualOrder: ['gone', 'b'],
});
expect(ids(out)).toEqual(['b', 'a']);
});
it('pins a duplicated id once, at its first position', () => {
const out = sortAlbumAssets([a, b, c], {
mode: 'manual',
immichOrder: 'asc',
manualOrder: ['c', 'a', 'c'],
});
expect(ids(out)).toEqual(['c', 'a', 'b']);
});
it('falls back to the album order when nothing is pinned', () => {
const input = [c, a, b];
expect(ids(sortAlbumAssets(input, { mode: 'manual', immichOrder: 'asc' }))).toEqual(
ids(sortAlbumAssets(input, { mode: 'immich', immichOrder: 'asc' })),
);
});
});
describe('isAlbumSortMode', () => {
it('accepts every declared mode and nothing else', () => {
for (const mode of ALBUM_SORT_MODES) expect(isAlbumSortMode(mode)).toBe(true);
expect(isAlbumSortMode('bogus')).toBe(false);
expect(isAlbumSortMode(undefined)).toBe(false);
expect(isAlbumSortMode(null)).toBe(false);
});
});

View File

@@ -76,6 +76,27 @@ describe('gallery.yaml per-album heroImage', () => {
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 () => {

View File

@@ -104,6 +104,77 @@ describe('deriveGallery accepts valid structures', () => {
expect(result.albumHeroImages[A]).toBe(B);
});
it('collects the per-album sort mode', () => {
const result = deriveGallery({
albums: [{ [A]: { title: 'Series', sort: 'filename' } }, B],
} as unknown as GalleryYaml);
expect(result.albumSortModes[A]).toBe('filename');
// Absent means the default; an explicit entry would make `immich` and
// "never configured" indistinguishable downstream.
expect(result.albumSortModes[B]).toBeUndefined();
});
// A typo would otherwise be invisible: the album would keep rendering in
// Immich order and nothing would say why. Throwing is also what gives the
// admin PUT its 400, via the deriveGallery dry-run.
it('rejects an unknown sort mode, naming the album and the valid values', () => {
expect(() =>
deriveGallery({ albums: [{ [A]: { sort: 'alphabetical' } }] } as unknown as GalleryYaml),
).toThrow(/alphabetical/);
expect(() =>
deriveGallery({ albums: [{ [A]: { sort: 'alphabetical' } }] } as unknown as GalleryYaml),
).toThrow(new RegExp(A));
expect(() =>
deriveGallery({ albums: [{ [A]: { sort: 'alphabetical' } }] } as unknown as GalleryYaml),
).toThrow(/filename/);
});
it('collects the manual asset order, preserving its order', () => {
const result = deriveGallery({
albums: [{ [A]: { sort: 'manual', assetOrder: [B, A] } }],
} as unknown as GalleryYaml);
expect(result.albumManualOrders[A]).toEqual([B, A]);
});
// Kept regardless of the mode, so toggling manual → newest → manual in the
// admin panel does not silently destroy a hand-curated order.
it('keeps the asset order even when the mode is not manual', () => {
const result = deriveGallery({
albums: [{ [A]: { sort: 'newest', assetOrder: [B] } }],
} as unknown as GalleryYaml);
expect(result.albumSortModes[A]).toBe('newest');
expect(result.albumManualOrders[A]).toEqual([B]);
});
it('ignores an empty asset order rather than storing one', () => {
const result = deriveGallery({
albums: [{ [A]: { sort: 'manual', assetOrder: [] } }],
} as unknown as GalleryYaml);
expect(result.albumManualOrders[A]).toBeUndefined();
});
// processAlbumEntry is reached from four places; only the array-subpage path
// was covered before, so a regression in the others would go unnoticed.
it('applies the sort mode to section and map-style subpage albums too', () => {
const sectioned = deriveGallery({
subpages: [
{ name: 'Work', sections: [{ title: 'Recent', albums: [{ [A]: { sort: 'oldest' } }] }] },
],
} as unknown as GalleryYaml);
expect(sectioned.albumSortModes[A]).toBe('oldest');
const mapStyle = deriveGallery({
subpages: { Trips: { albums: [{ [B]: { sort: 'filename' } }] } },
} as unknown as GalleryYaml);
expect(mapStyle.albumSortModes[B]).toBe('filename');
});
it('parses enabled property on subpages correctly', () => {
const result = deriveGallery({
subpages: [

View File

@@ -2,6 +2,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { immich, ImmichUnavailableError } from '../immich';
import * as config from '../config';
import { cache } from '../cache';
import type { AlbumSortMode } from '../albumSort';
// Per-album sort is read on every getAlbum(), so the tests below need to be
// able to change it between calls without rebuilding the whole mock.
const albumSortModes: Record<string, AlbumSortMode> = {};
const albumManualOrders: Record<string, string[]> = {};
// Mock the config by wrapping it in a factory
vi.mock('../config', async () => {
@@ -16,6 +22,8 @@ vi.mock('../config', async () => {
subpages: [],
albumOverrides: { 'album-1': 'Override Name' },
albumDescriptions: {},
albumSortModes,
albumManualOrders,
// Non-zero: a 0 TTL expires an entry in the same millisecond it is
// written, which would make any caching assertion flaky. Isolation comes
// from the cache.clear() in beforeEach, not from an unusable TTL.
@@ -37,6 +45,8 @@ describe('ImmichClient', () => {
// getAlbum() memoises into the module-level LRU; without this, later tests
// read a previous test's album and never reach the mocked fetch.
cache.clear();
for (const key of Object.keys(albumSortModes)) delete albumSortModes[key];
for (const key of Object.keys(albumManualOrders)) delete albumManualOrders[key];
});
describe('request()', () => {
@@ -579,6 +589,69 @@ describe('ImmichClient', () => {
const album = await immich.getAlbum('album-1');
expect(album?.assets.map((a) => a.id)).toEqual(['frame-1', 'frame-2', 'frame-3']);
});
/**
* The configured sort is applied on the way out of getAlbum(), so the LRU
* keeps one canonical Immich-ordered copy per album. Sorting the cached
* array in place would reorder the shared entry, and the next reader with a
* different mode would then be sorting an already-sorted array.
*/
describe('per-album sort', () => {
const items = [
{ ...asset('b', '2024-02-01T00:00:00Z'), originalFileName: 'IMG_2.jpg' },
{ ...asset('c', '2024-03-01T00:00:00Z'), originalFileName: 'IMG_10.jpg' },
{ ...asset('a', '2024-01-01T00:00:00Z'), originalFileName: 'IMG_1.jpg' },
];
it('overrides the Immich direction', async () => {
albumSortModes['album-1'] = 'oldest';
mockAlbumApi({ order: 'desc', pages: [{ items, nextPage: null }] });
const album = await immich.getAlbum('album-1');
expect(album?.assets.map((a) => a.id)).toEqual(['a', 'b', 'c']);
});
it('applies the manual pin order', async () => {
albumSortModes['album-1'] = 'manual';
albumManualOrders['album-1'] = ['c'];
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
const album = await immich.getAlbum('album-1');
expect(album?.assets.map((a) => a.id)).toEqual(['c', 'a', 'b']);
});
it('leaves the cached album in canonical order', async () => {
albumSortModes['album-1'] = 'filename';
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
const first = await immich.getAlbum('album-1');
expect(first?.assets.map((a) => a.id)).toEqual(['a', 'b', 'c']); // IMG_1, IMG_2, IMG_10
// Same cache entry, different mode: if the first call had sorted the
// stored array, the album would still be in filename order here.
albumSortModes['album-1'] = 'newest';
const second = await immich.getAlbum('album-1');
expect(second?.assets.map((a) => a.id)).toEqual(['c', 'b', 'a']);
expect(second?.assets).not.toBe(first?.assets);
});
it('gives concurrent callers independent arrays', async () => {
albumSortModes['album-1'] = 'manual';
albumManualOrders['album-1'] = ['b'];
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
// Coalesced through the same pending promise, but each caller applies
// the sort itself — so neither can be handed the other's array.
const [one, two] = await Promise.all([
immich.getAlbum('album-1'),
immich.getAlbum('album-1'),
]);
expect(one?.assets.map((a) => a.id)).toEqual(['b', 'a', 'c']);
expect(two?.assets.map((a) => a.id)).toEqual(['b', 'a', 'c']);
expect(one?.assets).not.toBe(two?.assets);
});
});
});
});