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:
@@ -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 }> }) {
|
||||
|
||||
Reference in New Issue
Block a user