Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of [CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md) for the full list. Highlights: client proofing links (#683), custom content pages (#738), asset picker search (#740), concurrent-edit detection (#742), env-locked settings (#739), an accessibility baseline (#741), three security fixes with advisories (#737, #755, #759), and a pre-release quality pass (#780–#797). --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ralf <rohlenbo79@gmail.com> Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com> Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
381 lines
15 KiB
TypeScript
381 lines
15 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
import crypto from 'crypto';
|
|
|
|
// Mock config to provide a predictable API key and subpage config
|
|
vi.mock('@/lib/config', () => ({
|
|
getConfig: () => ({
|
|
immich: { apiKey: 'test-api-key-for-auth-tests' },
|
|
authSecret: 'test-auth-secret-32-chars-long-min',
|
|
standaloneAlbums: ['00000000-0000-0000-0000-000000000004'],
|
|
albumPasswords: { '00000000-0000-0000-0000-000000000005': 'albumpass' },
|
|
subpages: [
|
|
{
|
|
name: 'Private',
|
|
slug: 'private',
|
|
albumIds: [
|
|
'00000000-0000-0000-0000-000000000001',
|
|
'00000000-0000-0000-0000-000000000004',
|
|
'00000000-0000-0000-0000-000000000005',
|
|
],
|
|
password: 'secret123',
|
|
},
|
|
{
|
|
name: 'Public',
|
|
slug: 'public',
|
|
albumIds: ['00000000-0000-0000-0000-000000000002'],
|
|
// no password
|
|
},
|
|
{
|
|
// Taken offline with `enabled: false`, and asks for nothing.
|
|
name: 'Offline',
|
|
slug: 'offline',
|
|
albumIds: ['00000000-0000-0000-0000-000000000006'],
|
|
enabled: false,
|
|
},
|
|
{
|
|
name: 'Hashed',
|
|
slug: 'hashed',
|
|
albumIds: ['00000000-0000-0000-0000-000000000003'],
|
|
// scrypt hash of 'scrypted-pass' with a fixed salt — exercises the
|
|
// verifyScrypt path rather than the deprecated plaintext fallback.
|
|
password:
|
|
'scrypt:0123456789abcdef0123456789abcdef:4850eef18a09612ef11fdcd943bf64ce98d0eca2b0f677f9c0c51d1f648b8a101cf7ea36b6f94cfc05236b2fda6fffabd0929dc7e14c07626522fba68bf26de5',
|
|
},
|
|
{
|
|
// A leftover bcrypt hash: refused outright, whatever the attempt.
|
|
name: 'Legacy',
|
|
slug: 'legacy',
|
|
albumIds: ['00000000-0000-0000-0000-000000000007'],
|
|
password: '$2b$10$abcdefghijklmnopqrstuuPjJ7x1b0c9vX8rZ0Yx0mZ1n2o3p4q5r',
|
|
},
|
|
],
|
|
}),
|
|
}));
|
|
|
|
import {
|
|
isProtected,
|
|
authenticate,
|
|
isAuthenticated,
|
|
isAlbumReachable,
|
|
withoutLockedAlbums,
|
|
findSubpageBySlug,
|
|
isHttpsRequest,
|
|
} from '@/lib/auth';
|
|
|
|
describe('findSubpageBySlug', () => {
|
|
it('returns the subpage config for a known slug', () => {
|
|
const sp = findSubpageBySlug('private');
|
|
expect(sp).toBeDefined();
|
|
expect(sp?.name).toBe('Private');
|
|
});
|
|
|
|
it('returns undefined for an unknown slug', () => {
|
|
expect(findSubpageBySlug('nonexistent')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('isProtected', () => {
|
|
it('returns true for a password-protected subpage', () => {
|
|
expect(isProtected('private')).toBe(true);
|
|
});
|
|
|
|
it('returns false for a non-protected subpage', () => {
|
|
expect(isProtected('public')).toBe(false);
|
|
});
|
|
|
|
it('returns false for an unknown slug', () => {
|
|
expect(isProtected('unknown')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('authenticate', () => {
|
|
it('returns a Set-Cookie string for the correct password', async () => {
|
|
const cookie = await authenticate('private', 'secret123');
|
|
expect(cookie).toBeTruthy();
|
|
expect(cookie).toContain('lb_auth_private=');
|
|
expect(cookie).toContain('HttpOnly');
|
|
expect(cookie).toContain('SameSite=Strict');
|
|
});
|
|
|
|
it('returns null for the wrong password', async () => {
|
|
await expect(authenticate('private', 'wrongpass')).resolves.toBeNull();
|
|
});
|
|
|
|
it('returns null for a non-protected slug', async () => {
|
|
await expect(authenticate('public', 'anything')).resolves.toBeNull();
|
|
});
|
|
|
|
it('accepts a correct scrypt-hashed password', async () => {
|
|
await expect(authenticate('hashed', 'scrypted-pass')).resolves.toContain('lb_auth_hashed=');
|
|
});
|
|
|
|
it('rejects a wrong password against a scrypt hash', async () => {
|
|
await expect(authenticate('hashed', 'wrong')).resolves.toBeNull();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* scryptSync costs ~23ms of blocked main thread per call. The /api/auth limit
|
|
* is keyed on the client IP, and at the default TRUSTED_PROXY_HOPS=0 that IP
|
|
* comes from a spoofable header — so unlimited buckets, each buying 23ms of
|
|
* dead process. The async variant runs on the libuv threadpool instead.
|
|
*/
|
|
describe('password hashing does not stall the process', () => {
|
|
it('never calls the synchronous scrypt', async () => {
|
|
const sync = vi.spyOn(crypto, 'scryptSync');
|
|
await authenticate('hashed', 'scrypted-pass');
|
|
expect(sync).not.toHaveBeenCalled();
|
|
sync.mockRestore();
|
|
});
|
|
|
|
it('leaves timers free to fire while hashing', async () => {
|
|
let ticks = 0;
|
|
const interval = setInterval(() => ticks++, 2);
|
|
|
|
await authenticate('hashed', 'scrypted-pass');
|
|
clearInterval(interval);
|
|
|
|
// The synchronous variant yields to nothing for the whole hash, so a 2ms
|
|
// interval gets at most one tick after it finishes. Async leaves room for
|
|
// many. A slower machine makes the hash longer, i.e. more ticks — so this
|
|
// fails on a blocked loop, not on a busy one.
|
|
expect(ticks).toBeGreaterThan(3);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A key with an scrypt hash took ~50ms to answer, every other key ~2ms — the
|
|
* early return skipped hashing. Identical reply bodies did not help: the clock
|
|
* alone told a prober which pages, albums and journal entries are locked,
|
|
* drafts included. Every attempt now runs exactly one scrypt. Counting the
|
|
* calls keeps the test independent of how fast the machine is.
|
|
*/
|
|
describe('authenticate — one scrypt per attempt, whatever the key', () => {
|
|
const attempts: [string, () => Promise<string | null>][] = [
|
|
['scrypt hash, right password', () => authenticate('hashed', 'scrypted-pass')],
|
|
['scrypt hash, wrong password', () => authenticate('hashed', 'wrong')],
|
|
['plaintext, right password', () => authenticate('private', 'secret123')],
|
|
['plaintext, wrong password', () => authenticate('private', 'wrong')],
|
|
['key without a password', () => authenticate('public', 'anything')],
|
|
['unknown key', () => authenticate('no-such-page', 'anything')],
|
|
['unknown journal entry', () => authenticate('no-such-entry', 'x', 'journal')],
|
|
['unknown album', () => authenticate('00000000-0000-0000-0000-00000000dead', 'x', 'album')],
|
|
['legacy bcrypt hash', () => authenticate('legacy', 'anything')],
|
|
];
|
|
|
|
it.each(attempts)('%s', async (_label, attempt) => {
|
|
const scrypt = vi.spyOn(crypto, 'scrypt');
|
|
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
try {
|
|
await attempt();
|
|
expect(scrypt).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
scrypt.mockRestore();
|
|
error.mockRestore();
|
|
warn.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('isAuthenticated', () => {
|
|
it('returns true when a valid cookie is present', async () => {
|
|
// First authenticate to get the expected token
|
|
const cookie = (await authenticate('private', 'secret123'))!;
|
|
const token = cookie.split('=')[1].split(';')[0];
|
|
|
|
const getCookie = (name: string) => (name === 'lb_auth_private' ? token : undefined);
|
|
expect(isAuthenticated('private', getCookie)).toBe(true);
|
|
});
|
|
|
|
it('returns false when no cookie is present', () => {
|
|
const getCookie = () => undefined;
|
|
expect(isAuthenticated('private', getCookie)).toBe(false);
|
|
});
|
|
|
|
it('returns false for an invalid token', () => {
|
|
const getCookie = (name: string) => (name === 'lb_auth_private' ? 'badtoken' : undefined);
|
|
expect(isAuthenticated('private', getCookie)).toBe(false);
|
|
});
|
|
|
|
it('returns true for non-protected pages (no auth needed)', () => {
|
|
const getCookie = () => undefined;
|
|
expect(isAuthenticated('public', getCookie)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('token expiry', () => {
|
|
async function tokenFor(slug: string, password: string): Promise<string> {
|
|
return (await authenticate(slug, password))!.split('=')[1].split(';')[0];
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('rejects a token once its embedded expiry has passed', async () => {
|
|
const token = await tokenFor('private', 'secret123');
|
|
const getCookie = (name: string) => (name === 'lb_auth_private' ? token : undefined);
|
|
expect(isAuthenticated('private', getCookie)).toBe(true);
|
|
|
|
// Previously the 24h lifetime lived only in the cookie's Max-Age, so a
|
|
// captured token replayed via curl worked forever.
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(Date.now() + 25 * 60 * 60 * 1000);
|
|
expect(isAuthenticated('private', getCookie)).toBe(false);
|
|
});
|
|
|
|
it('still accepts a token just before it expires', async () => {
|
|
const token = await tokenFor('private', 'secret123');
|
|
const getCookie = (name: string) => (name === 'lb_auth_private' ? token : undefined);
|
|
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(Date.now() + 23 * 60 * 60 * 1000);
|
|
expect(isAuthenticated('private', getCookie)).toBe(true);
|
|
});
|
|
|
|
it('rejects a token whose expiry was tampered with', async () => {
|
|
const token = await tokenFor('private', 'secret123');
|
|
const [, sig] = token.split('.');
|
|
const farFuture = Date.now() + 365 * 24 * 60 * 60 * 1000;
|
|
|
|
// The expiry is covered by the HMAC, so extending it invalidates the token.
|
|
const getCookie = (name: string) =>
|
|
name === 'lb_auth_private' ? `${farFuture}.${sig}` : undefined;
|
|
expect(isAuthenticated('private', getCookie)).toBe(false);
|
|
});
|
|
|
|
it('rejects malformed and legacy tokens without throwing', () => {
|
|
for (const value of ['', '.', 'abc', 'abc.def', `${Date.now() + 1000}.`, 'deadbeef']) {
|
|
const getCookie = (name: string) => (name === 'lb_auth_private' ? value : undefined);
|
|
expect(() => isAuthenticated('private', getCookie)).not.toThrow();
|
|
expect(isAuthenticated('private', getCookie)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('sets a cookie Max-Age consistent with the embedded expiry', async () => {
|
|
const cookie = (await authenticate('private', 'secret123'))!;
|
|
const maxAge = Number(cookie.match(/Max-Age=(\d+)/)![1]);
|
|
const exp = Number(cookie.split('=')[1].split(';')[0].split('.')[0]);
|
|
expect(Math.abs(exp - (Date.now() + maxAge * 1000))).toBeLessThan(2000);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A route handler is reached without ever passing through the page that would
|
|
* have asked for the subpage password, so it has to ask the same question
|
|
* itself (#475).
|
|
*/
|
|
describe('isAlbumReachable', () => {
|
|
const IN_PRIVATE = '00000000-0000-0000-0000-000000000001';
|
|
const IN_PUBLIC = '00000000-0000-0000-0000-000000000002';
|
|
const ALSO_STANDALONE = '00000000-0000-0000-0000-000000000004';
|
|
const OWN_PASSWORD = '00000000-0000-0000-0000-000000000005';
|
|
const none = () => undefined;
|
|
|
|
async function cookieFor(key: string, password: string, type: 'subpage' | 'album' = 'subpage') {
|
|
const header = (await authenticate(key, password, type))!;
|
|
const token = header.split('=')[1].split(';')[0];
|
|
const name = header.split('=')[0];
|
|
return (asked: string) => (asked === name ? token : undefined);
|
|
}
|
|
|
|
it('allows an album in a subpage that asks for nothing', () => {
|
|
expect(isAlbumReachable(IN_PUBLIC, none)).toBe(true);
|
|
});
|
|
|
|
it('refuses an album whose only route is a protected subpage', () => {
|
|
expect(isAlbumReachable(IN_PRIVATE, none)).toBe(false);
|
|
});
|
|
|
|
it('allows it once that subpage has been unlocked', async () => {
|
|
const getCookie = await cookieFor('private', 'secret123');
|
|
expect(isAlbumReachable(IN_PRIVATE, getCookie)).toBe(true);
|
|
});
|
|
|
|
// Listing an album on the home page publishes it, whatever else it is in.
|
|
it('allows a standalone album even when a protected subpage also lists it', () => {
|
|
expect(isAlbumReachable(ALSO_STANDALONE, none)).toBe(true);
|
|
});
|
|
|
|
// `enabled: false` takes the subpage offline; its albums have no route left.
|
|
it('refuses an album whose only subpage is offline', () => {
|
|
expect(isAlbumReachable('00000000-0000-0000-0000-000000000006', none)).toBe(false);
|
|
});
|
|
|
|
it('still refuses an album that carries its own password', async () => {
|
|
const subpageOnly = await cookieFor('private', 'secret123');
|
|
expect(isAlbumReachable(OWN_PASSWORD, subpageOnly)).toBe(false);
|
|
});
|
|
|
|
it('allows that one once its own password is given too', async () => {
|
|
const subpage = await cookieFor('private', 'secret123');
|
|
const album = await cookieFor(OWN_PASSWORD, 'albumpass', 'album');
|
|
expect(isAlbumReachable(OWN_PASSWORD, (n) => subpage(n) ?? album(n))).toBe(true);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The essay layout puts the photographs of several albums on one page and
|
|
* never passes through an album's own gate, so it has to leave out the ones
|
|
* that are still locked.
|
|
*/
|
|
describe('withoutLockedAlbums', () => {
|
|
const OPEN = { id: '00000000-0000-0000-0000-000000000002' };
|
|
const LOCKED = { id: '00000000-0000-0000-0000-000000000005' };
|
|
|
|
it('keeps an album that has no password of its own', () => {
|
|
expect(withoutLockedAlbums([OPEN], () => undefined)).toEqual([OPEN]);
|
|
});
|
|
|
|
it('leaves out an album whose password has not been given', () => {
|
|
expect(withoutLockedAlbums([OPEN, LOCKED], () => undefined)).toEqual([OPEN]);
|
|
});
|
|
|
|
it('brings it back once it has been unlocked, in the original order', async () => {
|
|
const header = (await authenticate(LOCKED.id, 'albumpass', 'album'))!;
|
|
const [name, rest] = header.split('=');
|
|
const token = rest.split(';')[0];
|
|
const getCookie = (asked: string) => (asked === name ? token : undefined);
|
|
|
|
expect(withoutLockedAlbums([LOCKED, OPEN], getCookie)).toEqual([LOCKED, OPEN]);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The `Secure` flag follows the request, not NODE_ENV (#664): the Docker image
|
|
* always runs in production, and a browser drops a `Secure` cookie set over
|
|
* plain HTTP — the password was accepted and every next request answered 401.
|
|
*/
|
|
describe('isHttpsRequest', () => {
|
|
const req = (url: string, proto?: string) =>
|
|
new Request(url, { headers: proto ? { 'x-forwarded-proto': proto } : {} });
|
|
|
|
it('reads the scheme of a direct request', () => {
|
|
expect(isHttpsRequest(req('https://folio.example/api/auth'))).toBe(true);
|
|
expect(isHttpsRequest(req('http://192.168.1.10:7211/api/auth'))).toBe(false);
|
|
});
|
|
|
|
it('prefers the scheme a reverse proxy reports', () => {
|
|
expect(isHttpsRequest(req('http://folio:3000/api/auth', 'https'))).toBe(true);
|
|
expect(isHttpsRequest(req('http://folio:3000/api/auth', 'HTTPS'))).toBe(true);
|
|
expect(isHttpsRequest(req('https://folio.example/api/auth', 'http'))).toBe(false);
|
|
});
|
|
|
|
it('takes the first entry of a chained header', () => {
|
|
expect(isHttpsRequest(req('http://folio:3000/api/auth', 'https, http'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('authenticate — Secure flag', () => {
|
|
it('marks the cookie Secure by default', async () => {
|
|
expect(await authenticate('private', 'secret123')).toMatch(/; Secure$/);
|
|
});
|
|
|
|
it('leaves Secure off for a plain-HTTP visitor', async () => {
|
|
const cookie = await authenticate('private', 'secret123', 'subpage', false);
|
|
expect(cookie).toContain('lb_auth_private=');
|
|
expect(cookie).not.toContain('Secure');
|
|
});
|
|
});
|