security: fix
This commit is contained in:
parent
120ed4b94f
commit
d8ecabe2e2
|
|
@ -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: <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";
|
||||
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<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 (!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<string, string> = {
|
||||
"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<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
|
||||
// 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
|
||||
|
|
@ -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<Response>
|
|||
// 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) },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue