security: api hardening
This commit is contained in:
parent
85df039f51
commit
34960c1868
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue