From 49a47cbe6fe11cba31ba51b57d512bd76f007ec0 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:29:45 +0200 Subject: [PATCH] feat(api): answer GET /api/health without a key (#10771) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged via merge-train (release/v3.8.50, batch1 2026-08-20) — static gates (typecheck/file-size/complexity/cognitive/changelog) green on the combined tree; test:unit reds observed in the boarded run were verified pre-existing on the pure release tip (unrelated flake), not caused by this PR. Thanks for the contribution! --- .../features/10771-health-root-endpoint.md | 1 + src/app/api/health/route.ts | 29 ++++++++++++ src/shared/constants/publicApiRoutes.ts | 21 ++++++++- .../unit/health-root-public-liveness.test.ts | 44 +++++++++++++++++++ 4 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 changelog.d/features/10771-health-root-endpoint.md create mode 100644 src/app/api/health/route.ts create mode 100644 tests/unit/health-root-public-liveness.test.ts diff --git a/changelog.d/features/10771-health-root-endpoint.md b/changelog.d/features/10771-health-root-endpoint.md new file mode 100644 index 0000000000..a367bbce78 --- /dev/null +++ b/changelog.d/features/10771-health-root-endpoint.md @@ -0,0 +1 @@ +- **feat(api):** `GET /api/health` now answers `{ status, timestamp }` without a key. Until now the path had no route, so the management-auth boundary answered first with a 401 — indistinguishable from a wrong key or an unknown route, which left Docker HEALTHCHECKs and Kubernetes probes unable to tell "down" from "misconfigured". Kept deliberately minimal: version, uptime and memory stay behind the authenticated `/api/monitoring/health` ([#PRNUM](https://github.com/diegosouzapw/OmniRoute/pull/10771)). diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000000..2ead593b78 --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; + +/** + * GET /api/health — canonical liveness probe, no auth required. + * + * Without this route, `/api/health` fell through to the `/api/*` catch-all, and the + * management-auth boundary answered before routing: an unauthenticated caller got a 401, + * which is exactly what a wrong or missing key returns. An orchestrator (Docker HEALTHCHECK, + * a Kubernetes probe, a monitoring curl) cannot tell "service down" from "bad credentials" + * from "no such route" — the ambiguity the #6424 catch-all was written to remove for + * authenticated callers, still intact for the one caller that never authenticates. + * + * Deliberately minimal: `{ status, timestamp }` and nothing else. Whatever this returns is + * public on an exposed instance, so version, uptime and memory stay behind the authenticated + * `/api/monitoring/health`. For a probe that also confirms the database answers, use + * `/api/health/ping`. + */ + +export const dynamic = "force-dynamic"; + +export async function GET() { + return NextResponse.json( + { status: "ok", timestamp: new Date().toISOString() }, + { + status: 200, + headers: { "Cache-Control": "no-store, no-cache, must-revalidate" }, + } + ); +} diff --git a/src/shared/constants/publicApiRoutes.ts b/src/shared/constants/publicApiRoutes.ts index 3de4c9a5be..7044089a59 100644 --- a/src/shared/constants/publicApiRoutes.ts +++ b/src/shared/constants/publicApiRoutes.ts @@ -44,6 +44,14 @@ const PUBLIC_READONLY_API_ROUTE_PREFIXES = [ "/api/settings/require-login", ]; +// Read-only routes public by EXACT path, never by prefix. +// +// `/api/health` has to be reachable without a key — a probe has none, and a 401 there is +// indistinguishable from a wrong key or a missing route. It cannot go in the prefix list +// above: `startsWith("/api/health")` would also expose `/api/health/degradation`, which is +// authenticated today. +const PUBLIC_READONLY_API_ROUTES_EXACT = new Set(["/api/health"]); + const PUBLIC_READONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); const PUBLIC_CLOUD_API_ROUTES = [ @@ -76,7 +84,18 @@ export function isPublicApiRoute(pathname: string, method = "GET"): boolean { return false; } + for (const route of PUBLIC_READONLY_API_ROUTES_EXACT) { + if (pathMatchesExactRoute(pathname, route)) { + return true; + } + } + return PUBLIC_READONLY_API_ROUTE_PREFIXES.some((route) => pathname.startsWith(route)); } -export { PUBLIC_API_ROUTE_PREFIXES, PUBLIC_READONLY_API_ROUTE_PREFIXES, PUBLIC_READONLY_METHODS }; +export { + PUBLIC_API_ROUTE_PREFIXES, + PUBLIC_READONLY_API_ROUTE_PREFIXES, + PUBLIC_READONLY_API_ROUTES_EXACT, + PUBLIC_READONLY_METHODS, +}; diff --git a/tests/unit/health-root-public-liveness.test.ts b/tests/unit/health-root-public-liveness.test.ts new file mode 100644 index 0000000000..c8373d9180 --- /dev/null +++ b/tests/unit/health-root-public-liveness.test.ts @@ -0,0 +1,44 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { isPublicApiRoute } from "../../src/shared/constants/publicApiRoutes.ts"; +import { GET } from "../../src/app/api/health/route.ts"; + +// Without a root /api/health route, the path fell through to the /api/* catch-all and the +// management-auth boundary answered first: an unauthenticated probe got a 401, the same answer +// a wrong key returns. These assertions fail against the base — the route does not exist and +// isPublicApiRoute("/api/health") is false. +describe("GET /api/health is a public liveness probe", () => { + it("is reachable without a key, for read methods only", () => { + assert.equal(isPublicApiRoute("/api/health", "GET"), true); + assert.equal(isPublicApiRoute("/api/health", "HEAD"), true); + assert.equal(isPublicApiRoute("/api/health", "POST"), false); + }); + + it("does not open the rest of the health subtree", () => { + // A prefix entry would have exposed this one, which is authenticated today. + assert.equal(isPublicApiRoute("/api/health/degradation", "GET"), false); + assert.equal(isPublicApiRoute("/api/healthzzz", "GET"), false); + }); + + it("is reachable with a trailing slash, like the other exact-match public routes", () => { + // getRequestPathname() (src/shared/utils/apiAuth.ts) does not strip a trailing slash, + // unlike classify.ts's normalizePathname() — a raw Set.has() lookup would miss "/api/health/" + // even though it is the same probe. Same trailing-slash tolerance as PUBLIC_CLOUD_API_ROUTES. + assert.equal(isPublicApiRoute("/api/health/", "GET"), true); + }); + + it("answers 200 with the minimum an orchestrator needs", async () => { + const response = await GET(); + const body = (await response.json()) as Record; + + assert.equal(response.status, 200); + assert.equal(body.status, "ok"); + assert.equal(typeof body.timestamp, "string"); + + // Nothing that should not be public on an exposed instance. + for (const leak of ["version", "uptime", "memoryUsage", "system", "nodeVersion"]) { + assert.equal(leak in body, false, `${leak} must not be exposed without a key`); + } + }); +});