security: api hardening

This commit is contained in:
nate 2026-04-09 07:18:27 +04:00
parent 85df039f51
commit 34960c1868
2 changed files with 158 additions and 21 deletions

View File

@ -40,7 +40,14 @@ export interface MultiWindowUptime {
export interface MonitorRow { export interface MonitorRow {
id: string; id: string;
display_name: string; display_name: string;
url: string; // Note: the underlying monitor.url is intentionally NOT exposed on the
// public payload. Status pages display `display_name`; the literal target
// URL (which can contain auth tokens, internal hostnames, staging paths,
// etc.) must never leak to anonymous visitors via the JSON endpoint.
// Group correlator. Emitted as the matching group's `position` index
// (0-based string), NOT the underlying UUID, so the JSON doesn't leak
// internal IDs. The HTML render works the same either way — it just
// looks up groups by this token.
group_id: string | null; group_id: string | null;
position: number; position: number;
display_mode: "compact" | "expanded"; // resolved (per-monitor override → page default → 'expanded') display_mode: "compact" | "expanded"; // resolved (per-monitor override → page default → 'expanded')
@ -111,11 +118,12 @@ export async function loadMonitors(
// public page (the runner stops checking them when disabled, so their // public page (the runner stops checking them when disabled, so their
// region_states would otherwise drift to a stale "up" — visitors should // region_states would otherwise drift to a stale "up" — visitors should
// see this as planned downtime, not phantom uptime). // see this as planned downtime, not phantom uptime).
// Deliberately do NOT select m.url — see the MonitorRow comment for why the
// raw target URL must never reach the public payload.
const monitorRows = await sql<any[]>` const monitorRows = await sql<any[]>`
SELECT SELECT
spm.monitor_id AS id, spm.monitor_id AS id,
COALESCE(spm.display_name, m.name) AS display_name, COALESCE(spm.display_name, m.name) AS display_name,
m.url,
m.enabled AS enabled, m.enabled AS enabled,
spm.group_id, spm.group_id,
spm.position, spm.position,
@ -409,7 +417,6 @@ export async function loadMonitors(
return { return {
id: m.id, id: m.id,
display_name: m.display_name, display_name: m.display_name,
url: m.url,
group_id: m.group_id, group_id: m.group_id,
position: m.position, position: m.position,
display_mode, display_mode,
@ -480,20 +487,27 @@ export interface MonitorDetailPayload {
export async function loadMonitorDetail(slug: string, monitorId: string, window?: Window): Promise<MonitorDetailPayload | null> { export async function loadMonitorDetail(slug: string, monitorId: string, window?: Window): Promise<MonitorDetailPayload | null> {
const page = await loadStatusPage(slug); const page = await loadStatusPage(slug);
if (!page) return null; if (!page) return null;
// Confirm the monitor is actually attached to this page (and load any // Existence check only — confirm the monitor is actually attached to this
// page-specific overrides at the same time). // page. The bulk loader below produces the full payload; this query exists
// purely so we can return null on a wrong slug/monitor combo without firing
// the bigger query at all.
const [link] = await sql<any[]>` const [link] = await sql<any[]>`
SELECT spm.monitor_id, COALESCE(spm.display_name, m.name) AS display_name, m.url, spm.group_id, spm.position SELECT 1
FROM status_page_monitors spm FROM status_page_monitors spm
JOIN monitors m ON m.id = spm.monitor_id
WHERE spm.status_page_id = ${page.id} AND spm.monitor_id = ${monitorId} WHERE spm.status_page_id = ${page.id} AND spm.monitor_id = ${monitorId}
`; `;
if (!link) return null; if (!link) return null;
const win = (window ?? page.default_window) as Window; const win = (window ?? page.default_window) as Window;
// Reuse the bulk loader with a single-monitor list — keeps the bucket/state // Reuse the bulk loader with a single-monitor list — keeps the bucket/state
// logic in one place. Cheap because we're querying for one ID. // logic in one place. Cheap because we're querying for one ID. We also need
const monitors = await loadMonitors(page.id, win, page.display_mode, page.bar_frequency, page.bar_count); // the page's groups so we can redact the monitor's group_id (UUID → public
// position-as-string token), matching what /:slug.json emits.
const [allGroups, allMonitors] = await Promise.all([
loadGroups(page.id),
loadMonitors(page.id, win, page.display_mode, page.bar_frequency, page.bar_count),
]);
const { monitors } = redactGroupsAndMonitors(allGroups, allMonitors);
const m = monitors.find((x) => x.id === monitorId); const m = monitors.find((x) => x.id === monitorId);
if (!m) return null; if (!m) return null;
@ -540,26 +554,109 @@ export async function loadMonitorDetail(slug: string, monitorId: string, window?
return { monitor: m, incidents, generated_at: new Date().toISOString() }; return { monitor: m, incidents, generated_at: new Date().toISOString() };
} }
// The shape we actually expose to anonymous visitors. Computed by stripping
// internal IDs and any field a public consumer doesn't need from the row
// types — see redactPageForPublic / redactGroupsAndMonitors below.
//
// custom_css and analytics_html are kept here even though they're noisy to
// JSON consumers, because the HTML render reads from this same object — and
// they're already publicly visible in the rendered HTML, so dropping them
// from JSON wouldn't actually add any privacy.
export interface PublicPageView {
slug: string;
title: string;
description: string | null;
theme: "auto" | "light" | "dark";
index_search: boolean;
show_powered_by: boolean;
show_response_time: boolean;
show_cert_expiry: boolean;
default_window: Window;
display_mode: "compact" | "expanded";
bar_frequency: BucketType;
bar_count: number;
custom_css: string | null;
footer_text: string | null;
og_image_url: string | null;
analytics_html: string | null;
auto_refresh_s: number;
has_password: boolean;
}
export interface PublicGroupView {
id: string; // re-keyed to position-as-string, NOT the underlying UUID
name: string;
position: number;
}
export interface PagePayload { export interface PagePayload {
page: Omit<StatusPageRow, "password_hash"> & { has_password: boolean }; page: PublicPageView;
groups: GroupRow[]; groups: PublicGroupView[];
monitors: MonitorRow[]; monitors: MonitorRow[];
incidents: { active: IncidentSummary[]; recent: IncidentSummary[] }; incidents: { active: IncidentSummary[]; recent: IncidentSummary[] };
generated_at: string; generated_at: string;
} }
// Strip everything that doesn't belong on an unauthenticated payload:
// - account_id leaks the customer identifier across the platform
// - id internal status_page UUID, no consumer needs it
// - password_hash obvious
function redactPageForPublic(p: StatusPageRow): PublicPageView {
return {
slug: p.slug,
title: p.title,
description: p.description,
theme: p.theme,
index_search: p.index_search,
show_powered_by: p.show_powered_by,
show_response_time: p.show_response_time,
show_cert_expiry: p.show_cert_expiry,
default_window: p.default_window,
display_mode: p.display_mode,
bar_frequency: p.bar_frequency,
bar_count: p.bar_count,
custom_css: p.custom_css,
footer_text: p.footer_text,
og_image_url: p.og_image_url,
analytics_html: p.analytics_html,
auto_refresh_s: p.auto_refresh_s,
has_password: !!p.password_hash,
};
}
// Replace each group's UUID with its position-as-string. Monitors carry the
// same token in their group_id field, so the consumer can still join them
// — they just see opaque "0", "1", "2" tokens instead of internal UUIDs.
function redactGroupsAndMonitors(
groups: GroupRow[],
monitors: MonitorRow[],
): { groups: PublicGroupView[]; monitors: MonitorRow[] } {
const idMap = new Map<string, string>();
groups.forEach((g, i) => idMap.set(g.id, String(i)));
const publicGroups: PublicGroupView[] = groups.map((g, i) => ({
id: String(i),
name: g.name,
position: g.position,
}));
const publicMonitors = monitors.map((m) => ({
...m,
group_id: m.group_id ? (idMap.get(m.group_id) ?? null) : null,
}));
return { groups: publicGroups, monitors: publicMonitors };
}
export async function loadPagePayload(slug: string, window?: Window): Promise<PagePayload | null> { export async function loadPagePayload(slug: string, window?: Window): Promise<PagePayload | null> {
const page = await loadStatusPage(slug); const page = await loadStatusPage(slug);
if (!page) return null; if (!page) return null;
const win = (window ?? page.default_window) as Window; const win = (window ?? page.default_window) as Window;
const [groups, monitors, incidents] = await Promise.all([ const [rawGroups, rawMonitors, incidents] = await Promise.all([
loadGroups(page.id), loadGroups(page.id),
loadMonitors(page.id, win, page.display_mode, page.bar_frequency, page.bar_count), loadMonitors(page.id, win, page.display_mode, page.bar_frequency, page.bar_count),
loadIncidents(page.id), loadIncidents(page.id),
]); ]);
const { password_hash, ...publicPage } = page; const { groups, monitors } = redactGroupsAndMonitors(rawGroups, rawMonitors);
return { return {
page: { ...publicPage, has_password: !!password_hash }, page: redactPageForPublic(page),
groups, groups,
monitors, monitors,
incidents, incidents,

View File

@ -87,8 +87,14 @@ 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)); const page = await cached(`page:${slug}`, 15, () => loadStatusPage(slug));
if (!page) return new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "content-type": "application/json" } }); // Both nonexistent and gated-without-cookie collapse to the same 404 so a
if (!isAuthorised(page, request)) return new Response(JSON.stringify({ error: "password required" }), { status: 401, headers: { "content-type": "application/json" } }); // 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
// fall back to HTML scraping (which gets the password form, also a 401-ish
// signal but more expensive to parse and less stable).
if (!page || !isAuthorised(page, request)) {
return new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "content-type": "application/json" } });
}
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 new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "content-type": "application/json" } }); if (!payload) return new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "content-type": "application/json" } });
@ -101,9 +107,15 @@ async function renderJson(slug: string, request: Request, win?: Window): Promise
}); });
} }
async function renderRssResp(slug: string): Promise<Response> { async function renderRssResp(slug: string, request: Request): Promise<Response> {
const page = await loadStatusPage(slug); const page = await loadStatusPage(slug);
if (!page) return notFound(); if (!page) return notFound();
// Password-protected pages must NOT leak incident text via RSS. Without
// this check anyone with the slug could pull every incident update on a
// private page just by hitting /<slug>.rss. Match the page-existence 404
// exactly so an unauthenticated scraper can't even tell whether a slug
// is gated vs. nonexistent.
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));
return new Response(xml, { return new Response(xml, {
headers: { headers: {
@ -141,13 +153,19 @@ const app = new Elysia()
const { slug, format } = splitSlugAndFormat(params.slug); const { slug, format } = splitSlugAndFormat(params.slug);
if (!allow(slug, clientIp(request))) return rateLimited(); if (!allow(slug, clientIp(request))) return rateLimited();
if (format === "json") return renderJson(slug, request, (query as any)?.window as Window | undefined); if (format === "json") return renderJson(slug, request, (query as any)?.window as Window | undefined);
if (format === "rss") return renderRssResp(slug); if (format === "rss") return renderRssResp(slug, request);
return renderHtml(slug, request); return renderHtml(slug, request);
}) })
// Public SVG badge // Public SVG badge. Password-protected pages 404 here so an unauthenticated
// shields-style embed can't reveal a private page's current state — and
// crucially can't even confirm whether a private slug exists, since the
// 404 is identical to a totally bogus slug.
.get("/:slug/badge.svg", async ({ params, request }) => { .get("/:slug/badge.svg", async ({ params, request }) => {
if (!allow(params.slug, clientIp(request))) return rateLimited(); if (!allow(params.slug, clientIp(request))) return rateLimited();
const page = await loadStatusPage(params.slug);
if (!page) return notFound();
if (!isAuthorised(page, request)) return notFound();
const payload = await cached(`payload:${params.slug}`, 15, () => loadPagePayload(params.slug)); const payload = await cached(`payload:${params.slug}`, 15, () => loadPagePayload(params.slug));
if (!payload) return notFound(); if (!payload) return notFound();
const { message, color } = badgeFromState(payload.monitors); const { message, color } = badgeFromState(payload.monitors);
@ -166,6 +184,24 @@ const app = new Elysia()
// dodge memoirist's "two params at the same position" rule. // dodge memoirist's "two params at the same position" rule.
.get("/:slug/monitor/:idWithExt", async ({ params, request, query }) => { .get("/:slug/monitor/:idWithExt", async ({ params, request, query }) => {
if (!allow(params.slug, clientIp(request))) return rateLimited(); if (!allow(params.slug, clientIp(request))) return rateLimited();
// Same password gate as the page-level JSON. Without this, the click-to-
// expand endpoint would happily serve uptime / region state / incidents
// for monitors on a password-protected page to anyone who guesses the
// monitor id (8 hex bytes). Match the page existence 404 so unauthenticated
// requests can't even tell whether a slug is gated.
const page = await loadStatusPage(params.slug);
if (!page) {
return new Response(JSON.stringify({ error: "not found" }), {
status: 404,
headers: { "content-type": "application/json" },
});
}
if (!isAuthorised(page, request)) {
return new Response(JSON.stringify({ error: "not found" }), {
status: 404,
headers: { "content-type": "application/json" },
});
}
const idWithExt = params.idWithExt; const idWithExt = params.idWithExt;
const monitorId = idWithExt.endsWith(".json") ? idWithExt.slice(0, -5) : idWithExt; const monitorId = idWithExt.endsWith(".json") ? idWithExt.slice(0, -5) : idWithExt;
const win = (query as any)?.window as Window | undefined; const win = (query as any)?.window as Window | undefined;
@ -185,10 +221,14 @@ const app = new Elysia()
}); });
}) })
// PWA manifest // PWA manifest. Rate-limited like every other public endpoint, and
.get("/:slug/manifest.json", async ({ params }) => { // password-gated 404s match the page-existence 404 so a scraper can't use
// this route as an oracle for which slugs exist behind a password gate.
.get("/:slug/manifest.json", async ({ params, request }) => {
if (!allow(params.slug, clientIp(request))) return rateLimited();
const page = await loadStatusPage(params.slug); const page = await loadStatusPage(params.slug);
if (!page) return notFound(); if (!page) return notFound();
if (!isAuthorised(page, request)) return notFound();
return new Response(JSON.stringify({ return new Response(JSON.stringify({
name: page.title, name: page.title,
short_name: page.title.slice(0, 12), short_name: page.title.slice(0, 12),