mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(api): rate-limit and validate the web-vitals beacon
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.
This commit is contained in:
1 parent
d8229c26db
commit
1920a763be
2 files changed
+247
-37
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in new issue
Block a user