diff --git a/apps/status/src/data.ts b/apps/status/src/data.ts index 148fd2f..869ea6d 100644 --- a/apps/status/src/data.ts +++ b/apps/status/src/data.ts @@ -40,7 +40,14 @@ export interface MultiWindowUptime { export interface MonitorRow { id: 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; position: number; 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 // region_states would otherwise drift to a stale "up" — visitors should // 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` SELECT spm.monitor_id AS id, COALESCE(spm.display_name, m.name) AS display_name, - m.url, m.enabled AS enabled, spm.group_id, spm.position, @@ -409,7 +417,6 @@ export async function loadMonitors( return { id: m.id, display_name: m.display_name, - url: m.url, group_id: m.group_id, position: m.position, display_mode, @@ -480,20 +487,27 @@ export interface MonitorDetailPayload { export async function loadMonitorDetail(slug: string, monitorId: string, window?: Window): Promise { const page = await loadStatusPage(slug); if (!page) return null; - // Confirm the monitor is actually attached to this page (and load any - // page-specific overrides at the same time). + // Existence check only — confirm the monitor is actually attached to this + // 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` - 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 - JOIN monitors m ON m.id = spm.monitor_id WHERE spm.status_page_id = ${page.id} AND spm.monitor_id = ${monitorId} `; if (!link) return null; const win = (window ?? page.default_window) as Window; // 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. - const monitors = await loadMonitors(page.id, win, page.display_mode, page.bar_frequency, page.bar_count); + // logic in one place. Cheap because we're querying for one ID. We also need + // 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); 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() }; } +// 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 { - page: Omit & { has_password: boolean }; - groups: GroupRow[]; + page: PublicPageView; + groups: PublicGroupView[]; monitors: MonitorRow[]; incidents: { active: IncidentSummary[]; recent: IncidentSummary[] }; 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(); + 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 { const page = await loadStatusPage(slug); if (!page) return null; 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), loadMonitors(page.id, win, page.display_mode, page.bar_frequency, page.bar_count), loadIncidents(page.id), ]); - const { password_hash, ...publicPage } = page; + const { groups, monitors } = redactGroupsAndMonitors(rawGroups, rawMonitors); return { - page: { ...publicPage, has_password: !!password_hash }, + page: redactPageForPublic(page), groups, monitors, incidents, diff --git a/apps/status/src/index.ts b/apps/status/src/index.ts index 4430a38..a96e520 100644 --- a/apps/status/src/index.ts +++ b/apps/status/src/index.ts @@ -87,8 +87,14 @@ 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)); - 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: "password required" }), { status: 401, headers: { "content-type": "application/json" } }); + // 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 + // 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 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" } }); @@ -101,9 +107,15 @@ async function renderJson(slug: string, request: Request, win?: Window): Promise }); } -async function renderRssResp(slug: string): Promise { +async function renderRssResp(slug: string, request: Request): Promise { const page = await loadStatusPage(slug); 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 /.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)); return new Response(xml, { headers: { @@ -141,13 +153,19 @@ const app = new Elysia() const { slug, format } = splitSlugAndFormat(params.slug); if (!allow(slug, clientIp(request))) return rateLimited(); 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); }) - // 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 }) => { 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)); if (!payload) return notFound(); const { message, color } = badgeFromState(payload.monitors); @@ -166,6 +184,24 @@ const app = new Elysia() // dodge memoirist's "two params at the same position" rule. .get("/:slug/monitor/:idWithExt", async ({ params, request, query }) => { 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 monitorId = idWithExt.endsWith(".json") ? idWithExt.slice(0, -5) : idWithExt; const win = (query as any)?.window as Window | undefined; @@ -185,10 +221,14 @@ const app = new Elysia() }); }) - // PWA manifest - .get("/:slug/manifest.json", async ({ params }) => { + // PWA manifest. Rate-limited like every other public endpoint, and + // 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); if (!page) return notFound(); + if (!isAuthorised(page, request)) return notFound(); return new Response(JSON.stringify({ name: page.title, short_name: page.title.slice(0, 12),