Files
immich-folio/lib/__tests__/admin-auth.test.ts
Ralksta 871d54fafb security: stop leaking the admin password length through timing (#395)
verifyAdminPassword compared the raw strings, so timingSafeEqual could
only run once the lengths already matched. The early return on a length
mismatch is observable: an attacker who can time login attempts learns
how long ADMIN_PASSWORD is, which is exactly the information that makes
a brute-force search tractable. The dummy comparison that guarded the
mismatch path did not help — it ran on Buffer.alloc(attempt.length),
which is attacker-sized and leaves the branch timing intact anyway.

HMAC both sides to a fixed 32 bytes first, then compare. Every attempt
now takes the same path regardless of length. Keyed with AUTH_SECRET
rather than a bare SHA-256 so the intermediate values are useless
without the secret, matching how lib/auth.ts already compares subpage
passwords.

MAX_PASSWORD_LENGTH caps an attempt at 1024 characters, rejected in the
route before the body reaches the hashing path and again in the library
so no caller can bypass it. 1024 is far above any real passphrase — a
cap a user could actually hit would be a lockout, not a fix.

Supersedes #390 and #388, which fixed the same comparison with an
unkeyed digest. The .jules/sentinel.md note the original branch carried
is left out: .jules/ is gitignored since cf2e8e6.

Adds 3 tests: a wrong password of exactly the right length, an
oversized attempt, and that a long configured password still logs in.

Verified: 210 unit tests passing (207 before), vitest exit 0, tsc 0
errors, production build exit 0, prettier clean.

Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 12:13:32 +02:00

162 lines
6.3 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import crypto from 'crypto';
// next/headers is only used by isAdminAuthenticated(), which we do not exercise here.
vi.mock('next/headers', () => ({ cookies: async () => ({ get: () => undefined }) }));
const ADMIN_PASSWORD = 'correct-horse-battery-staple';
const AUTH_SECRET = 'test-auth-secret-32-chars-long-min';
vi.mock('../env', () => ({
env: {
get AUTH_SECRET() {
return process.env.__TEST_AUTH_SECRET;
},
get ADMIN_PASSWORD() {
return process.env.__TEST_ADMIN_PASSWORD;
},
},
}));
/** Import fresh so module-level state cannot leak between cases. */
async function loadAuth() {
vi.resetModules();
return import('../admin/auth');
}
function forgeToken(signingKey: Buffer, payload: object): string {
const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
const sig = crypto.createHmac('sha256', signingKey).update(data).digest('base64url');
return `${data}.${sig}`;
}
beforeEach(() => {
process.env.__TEST_AUTH_SECRET = AUTH_SECRET;
process.env.__TEST_ADMIN_PASSWORD = ADMIN_PASSWORD;
});
afterEach(() => {
delete process.env.__TEST_AUTH_SECRET;
delete process.env.__TEST_ADMIN_PASSWORD;
vi.unstubAllEnvs();
});
describe('admin session tokens', () => {
it('accepts a token it just issued', async () => {
const { createAdminToken, verifyAdminToken } = await loadAuth();
expect(verifyAdminToken(createAdminToken())).toBe(true);
});
it('rejects a token forged with the old hardcoded dev fallback secret', async () => {
const { verifyAdminToken } = await loadAuth();
// The exact key an attacker could derive from the public source before the fix.
const legacyKey = crypto.createHash('sha256').update('admin:dev-fallback-secret').digest();
const forged = forgeToken(legacyKey, {
role: 'admin',
iat: Date.now(),
exp: Date.now() + 60_000,
});
expect(verifyAdminToken(forged)).toBe(false);
});
it('does not fall back to a guessable secret when AUTH_SECRET is unset in dev', async () => {
delete process.env.__TEST_AUTH_SECRET;
const { verifyAdminToken } = await loadAuth();
const legacyKey = crypto.createHash('sha256').update('admin:dev-fallback-secret').digest();
const forged = forgeToken(legacyKey, {
role: 'admin',
iat: Date.now(),
exp: Date.now() + 60_000,
});
expect(verifyAdminToken(forged)).toBe(false);
});
it('throws rather than signing with a guessable secret when AUTH_SECRET is unset in production', async () => {
delete process.env.__TEST_AUTH_SECRET;
vi.stubEnv('NODE_ENV', 'production');
const { createAdminToken } = await loadAuth();
expect(() => createAdminToken()).toThrow(/AUTH_SECRET/);
});
it('returns false (not throw) for a malformed cookie with a short signature', async () => {
const { verifyAdminToken } = await loadAuth();
// Pre-fix this hit crypto.timingSafeEqual outside the try block -> RangeError -> HTTP 500.
expect(() => verifyAdminToken('a.b')).not.toThrow();
expect(verifyAdminToken('a.b')).toBe(false);
expect(verifyAdminToken('not-a-token')).toBe(false);
expect(verifyAdminToken('')).toBe(false);
});
it('rejects an expired token', async () => {
const { verifyAdminToken } = await loadAuth();
const key = crypto
.createHash('sha256')
.update(`admin:${AUTH_SECRET}:${ADMIN_PASSWORD}`)
.digest();
const expired = forgeToken(key, { role: 'admin', iat: 0, exp: Date.now() - 1000 });
expect(verifyAdminToken(expired)).toBe(false);
});
it('rejects a validly signed token whose role is not admin', async () => {
const { verifyAdminToken } = await loadAuth();
const key = crypto
.createHash('sha256')
.update(`admin:${AUTH_SECRET}:${ADMIN_PASSWORD}`)
.digest();
const wrongRole = forgeToken(key, { role: 'viewer', iat: 0, exp: Date.now() + 60_000 });
expect(verifyAdminToken(wrongRole)).toBe(false);
});
it('invalidates existing sessions when ADMIN_PASSWORD is rotated', async () => {
const first = await loadAuth();
const token = first.createAdminToken();
expect(first.verifyAdminToken(token)).toBe(true);
process.env.__TEST_ADMIN_PASSWORD = 'a-brand-new-password';
const second = await loadAuth();
expect(second.verifyAdminToken(token)).toBe(false);
});
});
describe('verifyAdminPassword', () => {
it('accepts the configured password and rejects others', async () => {
const { verifyAdminPassword } = await loadAuth();
expect(verifyAdminPassword(ADMIN_PASSWORD)).toBe(true);
expect(verifyAdminPassword('wrong')).toBe(false);
expect(verifyAdminPassword(`${ADMIN_PASSWORD}x`)).toBe(false);
expect(verifyAdminPassword('')).toBe(false);
});
it('rejects everything when no ADMIN_PASSWORD is configured', async () => {
delete process.env.__TEST_ADMIN_PASSWORD;
const { verifyAdminPassword, isAdminEnabled } = await loadAuth();
expect(isAdminEnabled()).toBe(false);
expect(verifyAdminPassword('')).toBe(false);
expect(verifyAdminPassword('anything')).toBe(false);
});
// The comparison used to run on the raw strings, so a length mismatch
// returned before timingSafeEqual could. That early exit is what leaked the
// length of ADMIN_PASSWORD; a wrong password of the *right* length has to be
// rejected by the same code path as any other.
it('rejects a wrong password of exactly the right length', async () => {
const { verifyAdminPassword } = await loadAuth();
const sameLength = 'x'.repeat(ADMIN_PASSWORD.length);
expect(sameLength).toHaveLength(ADMIN_PASSWORD.length);
expect(sameLength).not.toBe(ADMIN_PASSWORD);
expect(verifyAdminPassword(sameLength)).toBe(false);
});
it('rejects an oversized attempt without hashing it', async () => {
const { verifyAdminPassword, MAX_PASSWORD_LENGTH } = await loadAuth();
expect(verifyAdminPassword('x'.repeat(MAX_PASSWORD_LENGTH + 1))).toBe(false);
});
// A cap that a real passphrase could hit would be a lockout, not a fix.
it('still accepts the configured password when it is long', async () => {
const { verifyAdminPassword, MAX_PASSWORD_LENGTH } = await loadAuth();
expect(ADMIN_PASSWORD.length).toBeLessThanOrEqual(MAX_PASSWORD_LENGTH);
expect(verifyAdminPassword(ADMIN_PASSWORD)).toBe(true);
});
});