fix: resolve the public status page's tenant from a trusted X-Forwarded-Host

The SSR fetch set `Host` to the visitor's hostname. `Host` is a forbidden
header name and undici discards it silently, so the Go server saw
`server:8080`, `hostSlug` returned "", `InstanceFromHost` returned false and
every public status page 404'd on every deployment. The feature did not work.

- `web/` now forwards the visitor's host as `X-Forwarded-Host`, and their
  address on `X-Forwarded-For` — without the latter gin sees a request from the
  Next pod with no XFF and every visitor of every page shares one 120/min
  bucket, tripped by exactly the traffic an outage produces.
- `publicStatusInstance` honours `X-Forwarded-Host` only when `c.RemoteIP()` is
  in `TRUSTED_PROXIES`. It is a tenant selector, so an untrusted peer must not
  be able to name one; `RemoteIP()` rather than `ClientIP()` because the latter
  is reconstructed from the very headers being judged. `TrustedProxies()` moves
  from main.go into the api package so the variable keeps one parser.
- A host naming no slug on a non-cloud deployment resolves the sole instance,
  the way bootstrap does. A self-hosted install at vantage.acme.com or an IP
  has no slug and could never serve a status page; more than one instance is a
  404 rather than a guess, and an unknown-but-well-formed slug stays a 404.
- `InstanceFromHost` gains an explicit-host variant rather than a second copy
  of the slug rules, and now caches negative lookups: an unknown host cost a
  Mongo query per anonymous request, which is also a timing oracle separating
  "no such instance" from "instance exists, page does not".
- The handler's `@Router` annotation is dropped. openapi.json declares one
  server of `/api`, so it published `/api/public/status/{pageId}` — a path that
  does not exist. The real address is described in prose instead.
This commit is contained in:
2026-08-25 09:04:47 +00:00
parent 72c9492223
commit da1dc90ac5
6 changed files with 276 additions and 97 deletions
+77 -13
View File
@@ -1,4 +1,5 @@
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import StatusPageView from "./StatusPageView";
import type { StatusSnapshot } from "@/lib/api";
@@ -6,16 +7,69 @@ import type { StatusSnapshot } from "@/lib/api";
// auth redirect. This page is served to the public.
export const dynamic = "force-dynamic";
async function fetchSnapshot(host: string, pageId: string): Promise<StatusSnapshot | null> {
type FetchResult =
| { kind: "ok"; snapshot: StatusSnapshot }
| { kind: "not-found" }
| { kind: "unavailable" };
async function fetchSnapshot(host: string, forwardedFor: string, pageId: string): Promise<FetchResult> {
const base = process.env.API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
// The instance is resolved server-side from the Host header, so it has to
// be forwarded explicitly — the server-to-server fetch does not carry it.
const res = await fetch(`${base}/public/status/${encodeURIComponent(pageId)}`, {
headers: { Host: host },
cache: "no-store",
});
if (!res.ok) return null;
return res.json();
// The instance is resolved server-side from the visitor's host, so it has
// to be forwarded explicitly — this is a server-to-server call and its own
// Host names the Go service.
//
// It goes in X-Forwarded-Host and NOT in Host: `Host` is a forbidden header
// name, and undici (the fetch behind Node) discards it silently. Setting it
// looked like it worked and delivered `host: <api-host>` upstream, so every
// status page resolved no instance and 404'd.
const outbound: Record<string, string> = { "X-Forwarded-Host": host };
// Likewise the visitor's own address. Without it the Go server sees a
// request from this pod with no XFF and rate limits every visitor of every
// page into one 120/min bucket — tripped by exactly the traffic an outage
// produces. Appending rather than replacing keeps the chain in front of us
// intact.
if (forwardedFor) outbound["X-Forwarded-For"] = forwardedFor;
let res: Response;
try {
res = await fetch(`${base}/public/status/${encodeURIComponent(pageId)}`, {
headers: outbound,
cache: "no-store",
});
} catch {
// The control plane is unreachable. That is not "no such page".
return { kind: "unavailable" };
}
if (res.status === 404) return { kind: "not-found" };
// A 429, a 500 or anything else is a page that exists and cannot be read
// right now. Telling a customer mid-outage that their status page does not
// exist is the worst available answer.
if (!res.ok) return { kind: "unavailable" };
try {
return { kind: "ok", snapshot: (await res.json()) as StatusSnapshot };
} catch {
return { kind: "unavailable" };
}
}
// The shell StatusPageView already renders for available:false, reused rather
// than written a second time so there is one unavailable page, not two.
function unavailableSnapshot(): StatusSnapshot {
return {
available: false,
reason: "licence_inactive",
title: "Status",
overall: "no_data",
sections: [],
active_incidents: [],
upcoming_maintenance: [],
history: [],
generated_at: new Date().toISOString(),
};
}
export default async function PublicStatusPage({
@@ -24,14 +78,24 @@ export default async function PublicStatusPage({
params: Promise<{ pageId: string }>;
}) {
const { pageId } = await params;
const { headers } = await import("next/headers");
const h = await headers();
const host = h.get("x-forwarded-host") ?? h.get("host") ?? "";
const snapshot = await fetchSnapshot(host, pageId);
if (!snapshot) notFound();
const inboundFor = h.get("x-forwarded-for");
const peer = h.get("x-real-ip");
const forwardedFor = [inboundFor, inboundFor ? null : peer]
.filter((v): v is string => !!v)
.join(", ");
return <StatusPageView pageId={pageId} initial={snapshot} />;
const result = await fetchSnapshot(host, forwardedFor, pageId);
if (result.kind === "not-found") notFound();
return (
<StatusPageView
pageId={pageId}
initial={result.kind === "ok" ? result.snapshot : unavailableSnapshot()}
/>
);
}
export async function generateMetadata({ params }: { params: Promise<{ pageId: string }> }) {