import { Request, Response, NextFunction } from 'express'; import crypto from 'crypto'; // The header Nginx Proxy Manager injects on the authentik-gated location. The // name is not a secret and does not need to be — the value is. export const ADMIN_GATE_HEADER = 'x-admin-gate'; // Hashed before comparing for two reasons. timingSafeEqual throws on buffers of // unequal length, so comparing raw values would turn a short header into a 500 // instead of a 403; and a length check before comparing would leak the secret's // length. Digests are always 32 bytes, so neither problem arises. function digest(value: string): Buffer { return crypto.createHash('sha256').update(value, 'utf8').digest(); } /** * Defence in depth for the admin API. * * Authorization for `/admin` and `/api/admin` lives entirely in one * `auth_request` regex in an Nginx Proxy Manager config outside this * repository. That control is real and it works, but it is invisible from the * code, untested here, and bypassed completely by anything that reaches the * published container port directly. See #63. * * With `ADMIN_GATE_SECRET` set, the proxy injects the matching header and this * refuses anything that arrives without it. * * Unset — or empty, which cannot mean "enforce" without letting an empty header * through — this is a no-op and the API is proxy-protected exactly as before. * That keeps local development and the existing admin tests working untouched, * and means shipping the image before configuring the proxy cannot take the * admin panel down. `server.ts` warns at boot when it is inactive, so the * inactive state is visible rather than silent. * * Mounted on the admin routers rather than on a path prefix, deliberately. An * admin router added later at a path the proxy regex does not match will * receive no header and refuse loudly on the first request, instead of being * quietly public — which is the failure #63 was most concerned about. */ export function requireAdminGate(req: Request, res: Response, next: NextFunction): void { const secret = process.env.ADMIN_GATE_SECRET; if (!secret) { next(); return; } const provided = req.get(ADMIN_GATE_HEADER); if (typeof provided !== 'string' || !crypto.timingSafeEqual(digest(provided), digest(secret))) { // Logged because a 403 from behind a proxy is otherwise very hard to // diagnose — most often it means the proxy config and the stack's secret // have drifted apart. The value sent is deliberately not echoed. console.warn( `[admin-gate] refused ${req.method} ${req.originalUrl} — ` + `${provided === undefined ? 'no' : 'incorrect'} ${ADMIN_GATE_HEADER} header` ); res.status(403).json({ error: 'forbidden' }); return; } next(); }