Files
immich-folio/app/api/admin/auth/route.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

87 lines
2.5 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import {
verifyAdminPassword,
createAdminToken,
isAdminAuthenticated,
isAdminEnabled,
COOKIE_NAME,
SESSION_DURATION_MS,
MAX_PASSWORD_LENGTH,
} from '@/lib/admin/auth';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
/** Admin login attempts per minute per IP — the highest-value credential in the app. */
const ADMIN_AUTH_RPM = 5;
/** POST: Login with admin password. */
export async function POST(request: NextRequest) {
const ip = getClientIp(request);
const rl = checkRateLimit(`admin-auth:${ip}`, ADMIN_AUTH_RPM);
if (!rl.success) {
return NextResponse.json(
{ error: 'Too many requests' },
{
status: 429,
headers: { 'Retry-After': String(retryAfterSeconds(rl.resetAt)) },
},
);
}
if (!isAdminEnabled()) {
return NextResponse.json(
{ error: 'Admin panel is not enabled. Set ADMIN_PASSWORD in your environment.' },
{ status: 403 },
);
}
const body = await request.json().catch(() => null);
if (!body?.password || typeof body.password !== 'string') {
return NextResponse.json({ error: 'Password is required' }, { status: 400 });
}
// Reject before hashing rather than after: nothing downstream should have to
// carry an unbounded request body.
if (body.password.length > MAX_PASSWORD_LENGTH) {
return NextResponse.json({ error: 'Password is too long' }, { status: 400 });
}
if (!verifyAdminPassword(body.password)) {
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
}
const token = createAdminToken();
const response = NextResponse.json({ success: true });
response.cookies.set(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
maxAge: SESSION_DURATION_MS / 1000,
});
return response;
}
/** GET: Check session status. */
export async function GET() {
if (!isAdminEnabled()) {
return NextResponse.json({ authenticated: false, enabled: false });
}
const authenticated = await isAdminAuthenticated();
return NextResponse.json({ authenticated, enabled: true });
}
/** DELETE: Logout. */
export async function DELETE() {
const response = NextResponse.json({ success: true });
response.cookies.set(COOKIE_NAME, '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
maxAge: 0,
});
return response;
}