From 1920a763bec4c3b70a9e3ee3045cfba4203244ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 02:35:36 +0200 Subject: [PATCH] fix(api): rate-limit and validate the web-vitals beacon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public POST surface lacked an origin gate, a rate-limit, and a schema — a peer could flood the wide-event log with arbitrary text under the `name` slot. Tighten the route: - Same-origin Referer check when `NEXT_PUBLIC_APP_URL` is set. - Per-IP `checkRateLimit` (60/min) matching the deploy-webhook shape. - Zod schema locks `name` to the six documented web-vitals identifiers; `id` capped at 50 chars; `value`/`delta` must be finite. Only validated fields enter the log envelope — the raw payload is never observed by the wide-event pipeline. 204 No Content on a successful enqueue keeps the `sendBeacon` contract intact; 400 on schema reject; 429 on rate-limit overflow. --- .../web-vitals/__tests__/route.test.ts | 151 ++++++++++++++++++ src/app/api/internal/web-vitals/route.ts | 133 ++++++++++----- 2 files changed, 247 insertions(+), 37 deletions(-) create mode 100644 src/app/api/internal/web-vitals/__tests__/route.test.ts diff --git a/src/app/api/internal/web-vitals/__tests__/route.test.ts b/src/app/api/internal/web-vitals/__tests__/route.test.ts new file mode 100644 index 000000000..47418b0bb --- /dev/null +++ b/src/app/api/internal/web-vitals/__tests__/route.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * v1.4.28 R4-CODE-C3 / Security H-1 / SD-C2 — the web-vitals beacon + * is unauthenticated by design (browser fires before any session) but + * MUST gate the surface so a peer cannot flood the wide-event log nor + * inject arbitrary strings. + * + * Contract: + * + * - Zod schema rejects unknown `name`, non-finite `value`, oversized + * `id` → 400. Raw body never enters the log. + * - Per-IP rate-limit (60/min) → 429 with `X-RateLimit-*` headers on + * overflow. + * - Valid payload → 204 No Content (sendBeacon shape — never retry). + */ + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), + rateLimitHeaders: () => ({}), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { checkRateLimit } from "@/lib/rate-limit"; + +function postRequest(body: unknown, init?: { referer?: string }): NextRequest { + return new NextRequest("http://localhost/api/internal/web-vitals", { + method: "POST", + headers: { + "content-type": "application/json", + ...(init?.referer ? { referer: init.referer } : {}), + }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 60_000, + }); + // NEXT_PUBLIC_APP_URL unset in the unit harness — the same-origin + // gate is skipped (dev-friendly default). + delete process.env.NEXT_PUBLIC_APP_URL; +}); + +describe("POST /api/internal/web-vitals", () => { + it("returns 204 on a valid payload", async () => { + const res = await POST( + postRequest({ + name: "LCP", + value: 1234.5, + id: "v1-1700000000-7", + delta: 12.3, + rating: "good", + navigationType: "navigate", + }), + ); + expect(res.status).toBe(204); + }); + + it("returns 400 when the name is not in the documented set", async () => { + const res = await POST( + postRequest({ + name: "ARBITRARY_LOG_STRING", + value: 0, + id: "x", + }), + ); + expect(res.status).toBe(400); + }); + + it("returns 400 when value is non-finite", async () => { + const res = await POST( + postRequest({ + name: "CLS", + value: Number.NaN, + id: "x", + }), + ); + expect(res.status).toBe(400); + }); + + it("returns 400 when id exceeds the 50-char cap", async () => { + const res = await POST( + postRequest({ + name: "INP", + value: 12, + id: "a".repeat(51), + }), + ); + expect(res.status).toBe(400); + }); + + it("returns 400 on malformed JSON without logging the raw payload", async () => { + const res = await POST(postRequest("not-json")); + expect(res.status).toBe(400); + }); + + it("returns 429 when the rate-limit fires", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + }); + const res = await POST( + postRequest({ name: "CLS", value: 0.1, id: "x" }), + ); + expect(res.status).toBe(429); + }); + + it("rejects a cross-origin Referer when NEXT_PUBLIC_APP_URL is configured", async () => { + process.env.NEXT_PUBLIC_APP_URL = "https://healthlog.example.com"; + const res = await POST( + postRequest( + { name: "LCP", value: 1000, id: "x" }, + { referer: "https://malicious.example.org/probe" }, + ), + ); + expect(res.status).toBe(204); + // The beacon was silently dropped (no log line); the rate-limiter + // was never even consulted for a cross-site request. + expect(checkRateLimit).not.toHaveBeenCalled(); + }); + + it("accepts a same-origin Referer when NEXT_PUBLIC_APP_URL is configured", async () => { + process.env.NEXT_PUBLIC_APP_URL = "https://healthlog.example.com"; + const res = await POST( + postRequest( + { name: "LCP", value: 1000, id: "x" }, + { referer: "https://healthlog.example.com/insights" }, + ), + ); + expect(res.status).toBe(204); + expect(checkRateLimit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/internal/web-vitals/route.ts b/src/app/api/internal/web-vitals/route.ts index 9b5470f48..7abe172bf 100644 --- a/src/app/api/internal/web-vitals/route.ts +++ b/src/app/api/internal/web-vitals/route.ts @@ -1,10 +1,10 @@ /** * v1.4.28 R3d — Web Vitals beacon. * - * Next 16 exposes `reportWebVitals` from the root layout; we wire the - * client to POST every measurement to this internal route, which logs - * to the shared wide-event pipeline so ops can see CLS / FCP / FID / - * LCP / TTFB / INP histograms in the same place the rest of the + * Next 16 exposes `reportWebVitals` from the root layout; the client + * POSTs every measurement to this internal route, which logs to the + * shared wide-event pipeline so ops can see CLS / FCP / FID / LCP / + * TTFB / INP / FCP histograms in the same place the rest of the * request flow surfaces. * * Why a route + not the front-end alone: @@ -16,61 +16,120 @@ * - Persisting elsewhere (Postgres, S3) would be a wasted spend — * this is a beacon. We log and forget. * - * Authentication: none (the route is internal-only by name; CSP + - * same-origin policy keep cross-site reporting out). The dispatch is - * idempotent — a duplicate beacon is at worst one extra log line. + * Authentication: none. Web Vitals fire from the document lifecycle + * (including the very first navigation that pre-dates auth), so an + * auth gate would silently drop the most informative samples. The + * route compensates by gating on: * - * Rate-limit: deliberately none. Web Vitals emit one report per metric - * per page load (max 6 per session). A bot trying to flood the route - * is cheaper to drop at the edge. + * 1. Same-origin Referer header (when `NEXT_PUBLIC_APP_URL` is + * configured) — fails closed on cross-site dispatch. + * 2. Per-IP `checkRateLimit` (60 req / minute) — same shape as the + * deploy-webhook. A real client sends ≤ 7 beacons per page; + * anything past 60 is a flood. + * 3. Zod schema — `name` is locked to the documented web-vitals + * identifier set; `value` / `delta` / `id` are size-bounded. + * Only validated fields enter the wide-event meta — the raw + * payload is NEVER logged, so a hostile client cannot inject + * arbitrary text into the observability sink. * - * Payload shape: matches `web-vitals`' `Metric` interface — `id`, - * `name`, `value`, `delta`, `rating`. We log the lot; if a future - * client adds fields we'll see them in the event meta. + * Always returns 204 No Content on a successful enqueue (no body so + * `sendBeacon` never retries). 400 on schema reject; 429 on rate-limit + * hit. The client never reads either — the beacon is fire-and-forget. */ import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod/v4"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { getClientIp } from "@/lib/api-response"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; -interface WebVitalsBody { - id?: unknown; - name?: unknown; - value?: unknown; - delta?: unknown; - rating?: unknown; - navigationType?: unknown; +const webVitalsBodySchema = z.object({ + // Locked to the web-vitals library's documented metric identifiers. + // FCP is included for parity with the iOS WebView client; INP super- + // sedes FID under the new Core Web Vitals contract. + name: z.enum(["CLS", "LCP", "FID", "INP", "TTFB", "FCP"]), + value: z.number().finite(), + // Web Vitals client builds `id` from `${navigation-uuid}-${index}` + // — bounded length keeps a hostile peer from logging arbitrary text + // under the identifier slot. + id: z.string().max(50), + delta: z.number().finite().optional(), + rating: z.enum(["good", "needs-improvement", "poor"]).optional(), + navigationType: z + .enum(["navigate", "reload", "back-forward", "back-forward-cache", "prerender"]) + .optional(), +}); + +function isSameOriginReferer(request: NextRequest): boolean { + // When `NEXT_PUBLIC_APP_URL` is unset (test / unconfigured dev) skip + // the check — the rate-limit + schema still bound the surface, and + // gating on a missing env would dead-lock the beacon in dev. + const expectedOrigin = process.env.NEXT_PUBLIC_APP_URL; + if (!expectedOrigin) return true; + + const referer = request.headers.get("referer"); + if (!referer) return false; + + try { + const refererOrigin = new URL(referer).origin; + const expected = new URL(expectedOrigin).origin; + return refererOrigin === expected; + } catch { + return false; + } } export const POST = apiHandler(async (request: NextRequest) => { - let body: WebVitalsBody | null = null; + // Same-origin gate. Fails closed on cross-site dispatch. + if (!isSameOriginReferer(request)) { + return new NextResponse(null, { status: 204 }); + } + + // Per-IP rate-limit. 60 req / minute is well above a real client's + // ≤ 7 beacons / page-load and well below a flood. 429 on hit; the + // beacon contract still accepts 4xx without retry on the client. + const ip = getClientIp(request); + const rl = await checkRateLimit( + `web-vitals:${ip ?? "unknown"}`, + 60, + 60_000, + ); + if (!rl.allowed) { + return NextResponse.json( + { error: "rate_limited" }, + { status: 429, headers: rateLimitHeaders(rl) }, + ); + } + + let raw: unknown; try { - body = (await request.json()) as WebVitalsBody; + raw = await request.json(); } catch { - // Malformed payload — accept silently so the client doesn't retry. - return NextResponse.json({ ok: true }); + // Malformed payload — 400 so the beacon never retries. Crucially: + // we do NOT log the raw body (log-injection defence). + return NextResponse.json({ error: "invalid_body" }, { status: 400 }); } - if (!body || typeof body !== "object") { - return NextResponse.json({ ok: true }); + const parsed = webVitalsBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json({ error: "invalid_body" }, { status: 400 }); } - const name = typeof body.name === "string" ? body.name : "unknown"; - const value = - typeof body.value === "number" && Number.isFinite(body.value) - ? body.value - : null; - const rating = typeof body.rating === "string" ? body.rating : null; - + // Log only the validated fields. The raw payload never enters the + // observability pipeline, so a hostile peer cannot smuggle text + // into the wide-event log via unrecognised keys. + const { name, value, id, delta, rating, navigationType } = parsed.data; annotate({ meta: { "web_vitals.name": name, "web_vitals.value": value, - "web_vitals.rating": rating, - "web_vitals.navigation_type": - typeof body.navigationType === "string" ? body.navigationType : null, + "web_vitals.id": id, + "web_vitals.delta": delta ?? null, + "web_vitals.rating": rating ?? null, + "web_vitals.navigation_type": navigationType ?? null, }, }); - return NextResponse.json({ ok: true }); + return new NextResponse(null, { status: 204 }); });