security: fix

This commit is contained in:
nate 2026-04-09 07:35:27 +04:00
parent 120ed4b94f
commit d8ecabe2e2
2 changed files with 85 additions and 17 deletions

View File

@ -1,6 +1,18 @@
// Password gate for protected status pages. We sign a short-lived cookie with // 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 // the page id + a tag derived from the current password hash + an expiry, so
// loads without us having to hit Postgres on every request. // 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: <pageId>.<pwTag>.<exp>.<sig>
// pwTag = HMAC(SECRET, password_hash).slice(0, 16)
// sig = HMAC(SECRET, "<pageId>.<pwTag>.<exp>")
//
// 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"; import { createHmac, timingSafeEqual } from "crypto";
@ -12,15 +24,24 @@ function sign(payload: string): string {
return createHmac("sha256", SECRET).update(payload).digest("hex"); 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 exp = Date.now() + TTL_MS;
const payload = `${pageId}.${exp}`; const pwTag = passwordTag(passwordHash);
const payload = `${pageId}.${pwTag}.${exp}`;
const sig = sign(payload); const sig = sign(payload);
const value = `${payload}.${sig}`; const value = `${payload}.${sig}`;
return `${COOKIE}=${value}; Path=/; Max-Age=${Math.floor(TTL_MS / 1000)}; HttpOnly; SameSite=Lax${process.env.NODE_ENV !== "development" ? "; Secure" : ""}`; 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; if (!cookieHeader) return false;
const match = cookieHeader.split(/;\s*/).find((c) => c.startsWith(`${COOKIE}=`)); const match = cookieHeader.split(/;\s*/).find((c) => c.startsWith(`${COOKIE}=`));
if (!match) return false; if (!match) return false;
@ -29,8 +50,21 @@ export function verifyAuthCookie(cookieHeader: string | null | undefined, pageId
if (lastDot < 0) return false; if (lastDot < 0) return false;
const payload = value.slice(0, lastDot); const payload = value.slice(0, lastDot);
const sig = value.slice(lastDot + 1); 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; 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); const exp = Number(expStr);
if (!Number.isFinite(exp) || Date.now() > exp) return false; if (!Number.isFinite(exp) || Date.now() > exp) return false;
const expected = sign(payload); const expected = sign(payload);

View File

@ -79,7 +79,10 @@ function rateLimited(): Response {
function isAuthorised(page: { id: string; password_hash: string | null }, req: Request): boolean { function isAuthorised(page: { id: string; password_hash: string | null }, req: Request): boolean {
if (!page.password_hash) return true; 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 // 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<Response> { async function renderHtml(slug: string, request: Request): Promise<Response> {
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 (!page) return notFound();
if (!isAuthorised(page, request)) { if (!isAuthorised(page, request)) {
return new Response(eta.render("password", { title: page.title, slug: page.slug, error: null }), { return new Response(eta.render("password", { title: page.title, slug: page.slug, error: null }), {
status: 401, 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)); const payload = await cached(`payload:${slug}`, 15, () => loadPagePayload(slug));
if (!payload) return notFound(); if (!payload) return notFound();
const html = eta.render("page", { ...payload, expandJsHash, appCssHash }); 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<string, string> = { const headers: Record<string, string> = {
"content-type": "text/html; charset=utf-8", "content-type": "text/html; charset=utf-8",
"cache-control": "public, max-age=15, s-maxage=15", "cache-control": cacheControl,
"x-frame-options":"SAMEORIGIN", "x-frame-options":"SAMEORIGIN",
"x-content-type-options": "nosniff", "x-content-type-options": "nosniff",
"referrer-policy":"strict-origin-when-cross-origin", "referrer-policy":"strict-origin-when-cross-origin",
@ -115,7 +129,10 @@ async function renderHtml(slug: string, request: Request): Promise<Response> {
} }
async function renderJson(slug: string, request: Request, win?: Window): Promise<Response> { async function renderJson(slug: string, request: Request, win?: Window): Promise<Response> {
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 // Both nonexistent and gated-without-cookie collapse to the same 404 so a
// scraper iterating slugs against /<slug>.json can't use the response code // scraper iterating slugs against /<slug>.json can't use the response code
// as an oracle for which slugs are private. Forces any OSINT enumeration to // 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 cacheKey = `payload:${slug}:${win ?? page.default_window}`;
const payload = await cached(cacheKey, 15, () => loadPagePayload(slug, win)); const payload = await cached(cacheKey, 15, () => loadPagePayload(slug, win));
if (!payload) return jsonNotFound(); 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), { return new Response(JSON.stringify(payload), {
headers: { headers: {
"content-type": "application/json", "content-type": "application/json",
"cache-control": "public, max-age=15, s-maxage=15", "cache-control": cacheControl,
...(page.index_search ? {} : { "x-robots-tag": "noindex, nofollow" }), ...(page.index_search ? {} : { "x-robots-tag": "noindex, nofollow" }),
}, },
}); });
@ -144,10 +165,14 @@ async function renderRssResp(slug: string, request: Request): Promise<Response>
// is gated vs. nonexistent. // is gated vs. nonexistent.
if (!isAuthorised(page, request)) return notFound(); if (!isAuthorised(page, request)) return notFound();
const xml = await cached(`rss:${slug}`, 300, () => renderRss(page, PUBLIC_BASE)); 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, { return new Response(xml, {
headers: { headers: {
"content-type": "application/rss+xml; charset=utf-8", "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(); if (!payload) return notFound();
const { message, color } = badgeFromState(payload.monitors); const { message, color } = badgeFromState(payload.monitors);
const svg = renderBadge("status", message, color); 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, { return new Response(svg, {
headers: { headers: {
"content-type": "image/svg+xml", "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 cacheKey = `monitor:${params.slug}:${monitorId}:${win ?? ''}`;
const payload = await cached(cacheKey, 15, () => loadMonitorDetail(params.slug, monitorId, win)); const payload = await cached(cacheKey, 15, () => loadMonitorDetail(params.slug, monitorId, win));
if (!payload) return jsonNotFound(); 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), { return new Response(JSON.stringify(payload), {
headers: { headers: {
"content-type": "application/json", "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: { headers: {
"content-type": "application/manifest+json", "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, { return new Response(null, {
status: 303, status: 303,
headers: { "location": `/${page.slug}`, "set-cookie": makeAuthCookie(page.id) }, headers: { "location": `/${page.slug}`, "set-cookie": makeAuthCookie(page.id, page.password_hash) },
}); });
}); });