diff --git a/apps/status/src/auth.ts b/apps/status/src/auth.ts index 844bb00..3c2d737 100644 --- a/apps/status/src/auth.ts +++ b/apps/status/src/auth.ts @@ -1,6 +1,18 @@ // Password gate for protected status pages. We sign a short-lived cookie with -// the page id + a secret so a successful password unlock survives across page -// loads without us having to hit Postgres on every request. +// the page id + a tag derived from the current password hash + an expiry, so +// a successful password unlock survives across page loads without us having +// to hit Postgres on every request — and so changing the page password +// invalidates every cookie issued under the old password. +// +// Cookie format: ... +// pwTag = HMAC(SECRET, password_hash).slice(0, 16) +// sig = HMAC(SECRET, "..") +// +// Why include pwTag: without it, the signed payload would be tied only to +// the page id, so a stolen / old cookie would remain valid for the full TTL +// even after the operator rotated the password. With pwTag, the verifier +// derives the expected tag from the *current* password_hash; old cookies +// no longer match and are silently rejected. import { createHmac, timingSafeEqual } from "crypto"; @@ -12,15 +24,24 @@ function sign(payload: string): string { return createHmac("sha256", SECRET).update(payload).digest("hex"); } -export function makeAuthCookie(pageId: string): string { +// Deterministic 16-hex-char tag derived from the current password hash. +// Stable for as long as the password_hash row doesn't change. When the +// operator changes the password, password_hash changes, pwTag changes, old +// cookies' embedded pwTag stops matching → verification fails. +function passwordTag(passwordHash: string): string { + return createHmac("sha256", SECRET).update(`pw:${passwordHash}`).digest("hex").slice(0, 16); +} + +export function makeAuthCookie(pageId: string, passwordHash: string): string { const exp = Date.now() + TTL_MS; - const payload = `${pageId}.${exp}`; + const pwTag = passwordTag(passwordHash); + const payload = `${pageId}.${pwTag}.${exp}`; const sig = sign(payload); const value = `${payload}.${sig}`; return `${COOKIE}=${value}; Path=/; Max-Age=${Math.floor(TTL_MS / 1000)}; HttpOnly; SameSite=Lax${process.env.NODE_ENV !== "development" ? "; Secure" : ""}`; } -export function verifyAuthCookie(cookieHeader: string | null | undefined, pageId: string): boolean { +export function verifyAuthCookie(cookieHeader: string | null | undefined, pageId: string, passwordHash: string): boolean { if (!cookieHeader) return false; const match = cookieHeader.split(/;\s*/).find((c) => c.startsWith(`${COOKIE}=`)); if (!match) return false; @@ -29,8 +50,21 @@ export function verifyAuthCookie(cookieHeader: string | null | undefined, pageId if (lastDot < 0) return false; const payload = value.slice(0, lastDot); const sig = value.slice(lastDot + 1); - const [id, expStr] = payload.split("."); + const parts = payload.split("."); + if (parts.length !== 3) return false; + const [id, pwTag, expStr] = parts; if (id !== pageId) return false; + // Compare the cookie's pwTag against the tag derived from the *current* + // password hash. Mismatch = the password was rotated since this cookie was + // issued, so the cookie is dead. Constant-time compare to avoid leaking + // partial-match timing. + const expectedPwTag = passwordTag(passwordHash); + if (pwTag.length !== expectedPwTag.length) return false; + try { + if (!timingSafeEqual(Buffer.from(pwTag), Buffer.from(expectedPwTag))) return false; + } catch { + return false; + } const exp = Number(expStr); if (!Number.isFinite(exp) || Date.now() > exp) return false; const expected = sign(payload); diff --git a/apps/status/src/index.ts b/apps/status/src/index.ts index 7e10cec..ba2821f 100644 --- a/apps/status/src/index.ts +++ b/apps/status/src/index.ts @@ -79,7 +79,10 @@ function rateLimited(): Response { function isAuthorised(page: { id: string; password_hash: string | null }, req: Request): boolean { if (!page.password_hash) return true; - return verifyAuthCookie(req.headers.get("cookie"), page.id); + // Pass the current password_hash so verifyAuthCookie can derive the + // expected pwTag and reject any cookie that was issued under a previous + // password — i.e. rotating the password evicts every existing session. + return verifyAuthCookie(req.headers.get("cookie"), page.id, page.password_hash); } // Memoirist (Elysia's router) treats `:slug.json` as a single parameter named @@ -92,20 +95,31 @@ function splitSlugAndFormat(raw: string): { slug: string; format: "html" | "json } async function renderHtml(slug: string, request: Request): Promise { - const page = await cached(`page:${slug}`, 15, () => loadStatusPage(slug)); + // Page row is fetched fresh on every request — never cached. The page row + // carries the live password_hash + index_search + display config; an + // operator changing any of those in the dashboard must take effect + // immediately, not after a TTL window. The single PK lookup is sub-ms, + // and the heavy work (rollup payload below) is still cached for 15s. + const page = await loadStatusPage(slug); if (!page) return notFound(); if (!isAuthorised(page, request)) { return new Response(eta.render("password", { title: page.title, slug: page.slug, error: null }), { status: 401, - headers: { "content-type": "text/html; charset=utf-8" }, + headers: { "content-type": "text/html; charset=utf-8", ...NO_STORE_HEADERS }, }); } const payload = await cached(`payload:${slug}`, 15, () => loadPagePayload(slug)); if (!payload) return notFound(); const html = eta.render("page", { ...payload, expandJsHash, appCssHash }); + // Password-protected pages MUST be private — never let an edge cache or + // shared proxy hold a copy that some other visitor could pull. Public + // pages keep the 15s shared cache for performance under viral hits. + const cacheControl = page.password_hash + ? "private, no-store, must-revalidate" + : "public, max-age=15, s-maxage=15"; const headers: Record = { "content-type": "text/html; charset=utf-8", - "cache-control": "public, max-age=15, s-maxage=15", + "cache-control": cacheControl, "x-frame-options":"SAMEORIGIN", "x-content-type-options": "nosniff", "referrer-policy":"strict-origin-when-cross-origin", @@ -115,7 +129,10 @@ async function renderHtml(slug: string, request: Request): Promise { } async function renderJson(slug: string, request: Request, win?: Window): Promise { - const page = await cached(`page:${slug}`, 15, () => loadStatusPage(slug)); + // Same as renderHtml: never cache the page row. The auth check has to see + // the live password_hash, otherwise rotating a password leaves a 15s + // window where old cookies still validate against the stale row. + const page = await loadStatusPage(slug); // Both nonexistent and gated-without-cookie collapse to the same 404 so a // scraper iterating slugs against /.json can't use the response code // as an oracle for which slugs are private. Forces any OSINT enumeration to @@ -125,10 +142,14 @@ async function renderJson(slug: string, request: Request, win?: Window): Promise const cacheKey = `payload:${slug}:${win ?? page.default_window}`; const payload = await cached(cacheKey, 15, () => loadPagePayload(slug, win)); if (!payload) return jsonNotFound(); + // Password-protected JSON must be private — same reasoning as renderHtml. + const cacheControl = page.password_hash + ? "private, no-store, must-revalidate" + : "public, max-age=15, s-maxage=15"; return new Response(JSON.stringify(payload), { headers: { "content-type": "application/json", - "cache-control": "public, max-age=15, s-maxage=15", + "cache-control": cacheControl, ...(page.index_search ? {} : { "x-robots-tag": "noindex, nofollow" }), }, }); @@ -144,10 +165,14 @@ async function renderRssResp(slug: string, request: Request): Promise // is gated vs. nonexistent. if (!isAuthorised(page, request)) return notFound(); const xml = await cached(`rss:${slug}`, 300, () => renderRss(page, PUBLIC_BASE)); + // Authenticated visitor on a private page → never edge-cache the response. + const cacheControl = page.password_hash + ? "private, no-store, must-revalidate" + : "public, max-age=300, s-maxage=300"; return new Response(xml, { headers: { "content-type": "application/rss+xml; charset=utf-8", - "cache-control": "public, max-age=300, s-maxage=300", + "cache-control": cacheControl, }, }); } @@ -197,10 +222,14 @@ const app = new Elysia() if (!payload) return notFound(); const { message, color } = badgeFromState(payload.monitors); const svg = renderBadge("status", message, color); + // Same private/no-store rule on authenticated badge fetches for private pages. + const cacheControl = page.password_hash + ? "private, no-store, must-revalidate" + : "public, max-age=15, s-maxage=15"; return new Response(svg, { headers: { "content-type": "image/svg+xml", - "cache-control": "public, max-age=15, s-maxage=15", + "cache-control": cacheControl, }, }); }) @@ -224,10 +253,13 @@ const app = new Elysia() const cacheKey = `monitor:${params.slug}:${monitorId}:${win ?? ''}`; const payload = await cached(cacheKey, 15, () => loadMonitorDetail(params.slug, monitorId, win)); if (!payload) return jsonNotFound(); + const cacheControl = page.password_hash + ? "private, no-store, must-revalidate" + : "public, max-age=15, s-maxage=15"; return new Response(JSON.stringify(payload), { headers: { "content-type": "application/json", - "cache-control": "public, max-age=15, s-maxage=15", + "cache-control": cacheControl, }, }); }) @@ -251,7 +283,9 @@ const app = new Elysia() }), { headers: { "content-type": "application/manifest+json", - "cache-control": "public, max-age=86400, s-maxage=86400", + "cache-control": page.password_hash + ? "private, no-store, must-revalidate" + : "public, max-age=86400, s-maxage=86400", }, }); }) @@ -275,7 +309,7 @@ const app = new Elysia() } return new Response(null, { status: 303, - headers: { "location": `/${page.slug}`, "set-cookie": makeAuthCookie(page.id) }, + headers: { "location": `/${page.slug}`, "set-cookie": makeAuthCookie(page.id, page.password_hash) }, }); });