mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge fix/module-gate-and-webhook
This commit is contained in:
commit
a675daf0c1
11 files changed
+787
-85
No files matched your search
@@ -28,6 +28,38 @@
|
||||
and the second from 6.9 ms to 1.6 ms, and both stop growing with the size of
|
||||
the archive. Nothing about the tiles changes: same numbers, same order, same
|
||||
metrics.
|
||||
- **The check that watches for ungated module routes now watches every
|
||||
module.** It walked a hand-written list of route trees, and the list had
|
||||
stopped keeping up: the Coach tree, the environmental-context tree, the
|
||||
mental-health screeners, the inbound-document vault and the MCP credential
|
||||
surface all shipped an API tree the check never looked at. Nothing was
|
||||
leaking — every route in those trees already refuses when the module is off,
|
||||
and the doctor-report export was gated too — but a green run was saying
|
||||
something narrower than it appeared to say, which is how the FHIR routes once
|
||||
sat in a green bucket while serving the whole record. The trees are declared
|
||||
per module now, so a new module cannot be added without saying where its
|
||||
routes live, and a directory named after a module is found on disk rather
|
||||
than remembered. For a self-hoster this changes no behaviour today; it is the
|
||||
difference between a module switch that is enforced and one that is only
|
||||
believed to be.
|
||||
|
||||
### Security
|
||||
|
||||
- **Every webhook verb that checks the shared secret is rate-limited now, not
|
||||
just the one that carries data.** The Withings entrypoints, the Telegram bot
|
||||
webhook and the Coolify deploy hook all limited their POST and left the
|
||||
reachability verbs — the GET and HEAD a webhook UI sends to confirm the URL
|
||||
before saving it — comparing the same secret with nothing in front of them.
|
||||
Those verbs answer 200 for the right secret and 401 for a wrong one, so
|
||||
unlimited they were a free guessing machine against the callback token of an
|
||||
instance whose URL is known, and a free load channel besides. The comparison
|
||||
was already constant-time, which hides how long the check took but not what
|
||||
it answered. Each verb now runs the per-source limit its POST sibling used,
|
||||
on the same bucket, before it looks at the secret. Nothing changes for a
|
||||
legitimate subscription: Withings, Telegram and Coolify send one or two
|
||||
probes, far below the limit. A new guard walks every webhook route and fails
|
||||
the build if a verb reaches a secret comparison without the limiter in front
|
||||
of it, so the next verb added to one of these files cannot repeat this.
|
||||
|
||||
## [1.38.4] — 2026-09-02
|
||||
|
||||
|
||||
@@ -74,80 +74,125 @@ import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
|
||||
* `BEHAVIOURAL_GATE_TESTS`. This test asserts those files still exist, so
|
||||
* deleting the behavioural coverage fails here with a pointer to what was
|
||||
* lost rather than silently leaving only a text-match behind.
|
||||
*
|
||||
* WHICH TREES GET WALKED, AND WHY THAT IS NOT A HAND-WRITTEN LIST ANY MORE
|
||||
* -----------------------------------------------------------------------
|
||||
* A discovery test is only worth what its discovery covers, and this one used
|
||||
* to enumerate its trees in a flat array that grew when somebody remembered to
|
||||
* grow it. It had stopped: `coach`, `environment`, `mentalHealth`,
|
||||
* `inboundDocuments` and `mcp` each shipped an API tree that this file never
|
||||
* looked at. No leak came of it — every route in the first four gates — but
|
||||
* that was the authors' care and not this test's, and a green run was saying
|
||||
* something narrower than it appeared to say.
|
||||
*
|
||||
* The trees are now declared per module key as an exhaustive
|
||||
* `Record<ModuleKey, …>`, so the REGISTRY drives coverage: a new key does not
|
||||
* compile without an entry, and an API directory named after a key is
|
||||
* discovered from the filesystem and must be declared for it. A tree at a
|
||||
* non-obvious address (`achievements` → `/api/gamification`) still has to be
|
||||
* named by hand — that is the residual gap, and it is the reason the
|
||||
* declaration carries a comment per key rather than a bare path.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The per-domain route trees that serve a toggleable module. Each entry
|
||||
* is a directory under `src/app/api`; every `route.ts` beneath it is
|
||||
* enumerated and classified. A domain that is owned entirely by one
|
||||
* builder (doctor-report / FHIR) or one delegated gate (cycle) is still
|
||||
* walked so a NEW route in the tree must justify itself.
|
||||
* The per-domain API route trees each toggleable module owns, keyed by the
|
||||
* registry key.
|
||||
*
|
||||
* Declared as an exhaustive `Record<ModuleKey, …>` so the REGISTRY drives
|
||||
* coverage rather than anybody's memory: adding a key to `MODULE_KEYS` without
|
||||
* deciding which API tree it owns does not compile, and the two tests below
|
||||
* catch the same omission at runtime — one asserting every registry key is
|
||||
* declared, one asserting that an API directory NAMED after a module key is
|
||||
* declared for that key.
|
||||
*
|
||||
* The hand-written array this replaced could only grow when a contributor
|
||||
* remembered to grow it, and it had stopped growing: `coach`, `environment`,
|
||||
* `mentalHealth`, `inboundDocuments` and `mcp` all shipped an API tree that
|
||||
* this inventory never walked. Every route in the first four turned out to be
|
||||
* gated, so nothing leaked — but that was the authors' care, not this file's
|
||||
* doing, and the next tree would have been just as invisible.
|
||||
*
|
||||
* An empty array is a claim, not a gap: the module owns no API tree of its
|
||||
* own, and the entry says where its data actually lives.
|
||||
*
|
||||
* A domain owned entirely by one builder (doctor-report / FHIR) or one
|
||||
* delegated gate (cycle) is still walked, so a NEW route in the tree must
|
||||
* justify itself.
|
||||
*/
|
||||
const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
|
||||
"src/app/api/mood",
|
||||
"src/app/api/mood-entries",
|
||||
"src/app/api/sleep",
|
||||
"src/app/api/workouts",
|
||||
"src/app/api/labs",
|
||||
const MODULE_ROUTE_TREES_BY_KEY: Readonly<
|
||||
Record<ModuleKey, readonly string[]>
|
||||
> = {
|
||||
cycle: ["src/app/api/cycle"],
|
||||
mood: ["src/app/api/mood", "src/app/api/mood-entries"],
|
||||
sleep: ["src/app/api/sleep"],
|
||||
// Glucose has no tree of its own: readings are Measurement rows on the core
|
||||
// measurement engine, and the glucose panel is an insights surface. The
|
||||
// module gates the surfaces, which live under trees already walked here.
|
||||
glucose: [],
|
||||
workouts: ["src/app/api/workouts"],
|
||||
// Recovery, like glucose, rides the measurement engine — no dedicated tree.
|
||||
recovery: [],
|
||||
// v1.18.1 — the user-scoped Biomarker catalog backs the Labs feature.
|
||||
"src/app/api/biomarkers",
|
||||
"src/app/api/cycle",
|
||||
labs: ["src/app/api/labs", "src/app/api/biomarkers"],
|
||||
// v1.18.1 (W-B) — the illness/condition journal. Every `/api/illness/*`
|
||||
// route gates on the `illness` module via the thin
|
||||
// `requireIllnessEnabled(...)` wrapper (which re-stamps the
|
||||
// illness-specific errorCode over `requireModuleEnabled("illness")`),
|
||||
// recognised below as a delegated gate. Walking the tree means a NEW
|
||||
// ungated illness route fails this test BY NAME rather than leaking the
|
||||
// surface over a Bearer token when the account turned the module off.
|
||||
"src/app/api/illness",
|
||||
"src/app/api/gamification",
|
||||
// recognised below as a delegated gate.
|
||||
illness: ["src/app/api/illness"],
|
||||
achievements: ["src/app/api/gamification"],
|
||||
// The Coach tree. Every route calls `requireModuleEnabled(userId, "coach")`
|
||||
// directly, which resolves the delegated two-layer state (assistant master
|
||||
// flag AND the per-user opt-out) through the registry — so the routes are
|
||||
// counted as directly gated here, and the sibling
|
||||
// `coach-route-gate-inventory.test.ts` covers the surface in depth.
|
||||
coach: ["src/app/api/coach"],
|
||||
// v1.18.0 (B2) — the AI-narrative insights tree, plus v1.28's unified
|
||||
// daily-digest read (`GET /api/daily/digest`), which is the AI-narrative
|
||||
// daily layer and gates on `insights` directly.
|
||||
insights: ["src/app/api/insights", "src/app/api/daily"],
|
||||
// v1.18.1 (D3) — medications graduated from CORE to a toggleable module.
|
||||
// It is SURFACE-gated (nav entry, dashboard widget, the dedicated
|
||||
// Medikamente settings entry), not data-layer-gated: every `/api/medications/*`
|
||||
// route is raw medication CRUD / intake / inventory / compliance over the
|
||||
// user's own rows, so they are EXEMPT below under the same data-layer
|
||||
// reasoning as mood/labs — disabling the module hides the surface, it does
|
||||
// not wedge an importer / sync / the user's ability to clean up, and
|
||||
// re-enabling finds the schedule + intake history intact. Walking the tree
|
||||
// means a NEW medication route must justify itself (gate or document-exempt)
|
||||
// rather than silently appearing.
|
||||
"src/app/api/medications",
|
||||
// v1.18.0 B3 — the legacy `/api/doctor-report` tree (JSON + server-PDF +
|
||||
// availability probe) was orphaned dead code (no production caller) and
|
||||
// removed. The doctor-report surface is `/api/export/health-record` plus
|
||||
// the FHIR REST face below; both gate on `doctorReport` directly. The FHIR
|
||||
// routes previously relied on the removed builder-mention bucket and were
|
||||
// in practice ungated — see the header note.
|
||||
"src/app/api/fhir",
|
||||
// v1.18.0 (B2) — the AI-narrative insights tree. Every status / cards /
|
||||
// correlations / derived / narrative / pregenerate / rhythm-events route
|
||||
// gates on the `insights` module; the Coach sub-tree delegates to the
|
||||
// coach assistant surface; the AI-settings / tile-layout / therapy-timeline
|
||||
// infra-and-config routes are EXEMPT below. Walking the tree means a NEW
|
||||
// ungated insights AI route fails this test BY NAME rather than leaking the
|
||||
// surface over a Bearer token when the account turned insights off.
|
||||
"src/app/api/insights",
|
||||
// v1.28 — the unified daily-digest read (`GET /api/daily/digest`). The digest
|
||||
// is the AI-narrative daily layer, so it gates on the `insights` module via
|
||||
// `requireModuleEnabled(user.id, "insights")` directly. Walking the tree means
|
||||
// a NEW ungated daily route fails BY NAME rather than leaking over a Bearer
|
||||
// token when the account turned insights off.
|
||||
"src/app/api/daily",
|
||||
// v1.28 — the nutrient-intake sync (opt-in `nutrients` module). Unlike the
|
||||
// SURFACE-gated (nav entry, dashboard widget, the dedicated Medikamente
|
||||
// settings entry), not data-layer-gated: every `/api/medications/*` route is
|
||||
// raw CRUD / intake / inventory / compliance over the user's own rows, so
|
||||
// they are EXEMPT below under the same data-layer reasoning as mood/labs.
|
||||
medications: ["src/app/api/medications"],
|
||||
// v1.18.0 B3 — the legacy `/api/doctor-report` tree was orphaned dead code
|
||||
// and removed. The doctor-report surface is `/api/export/health-record` plus
|
||||
// the FHIR REST face; both gate on `doctorReport` directly, and both are
|
||||
// key-pinned below so a future route cannot satisfy the inventory by gating
|
||||
// on some other, more permissive module.
|
||||
doctorReport: ["src/app/api/fhir", "src/app/api/export/health-record"],
|
||||
// v1.25.0 (W-ENV) — the environmental-context tree. Every route calls
|
||||
// `requireModuleEnabled(user.id, "environment")`, including the geocode
|
||||
// lookup and the backfill, because each one either reads the module's data
|
||||
// or spends the outbound weather/geocode budget on the user's behalf.
|
||||
environment: ["src/app/api/environment"],
|
||||
// v1.22.0 — the remote MCP endpoint is `src/app/mcp/route.ts`, OUTSIDE the
|
||||
// `/api` tree, and it gates on the module itself. What lives under
|
||||
// `/api/mcp` is the credential-management surface behind it, exempt below.
|
||||
mcp: ["src/app/api/mcp"],
|
||||
// v1.25.0 (W-DOCS-IN) — the inbound clinical-document vault. Every route
|
||||
// gates on `inboundDocuments`; the AI READ of a document is a separate
|
||||
// per-user opt-in on top.
|
||||
inboundDocuments: ["src/app/api/documents/inbound"],
|
||||
// v1.25.0 — the mental-health screeners. Both routes gate on `mentalHealth`.
|
||||
mentalHealth: ["src/app/api/mental-health"],
|
||||
// v1.28 — the nutrient-intake sync (opt-in module). Unlike the
|
||||
// data-layer-exempt siblings this domain is REFUSE-INGEST-WHEN-OFF (the
|
||||
// mental-health posture): both the batch ingest and the window-summary
|
||||
// read call `requireModuleEnabled(user.id, "nutrients")` directly, so a
|
||||
// phone whose user never opted in cannot land rows server-side. Walking
|
||||
// the tree means a NEW ungated nutrients route fails BY NAME.
|
||||
"src/app/api/nutrients",
|
||||
// v1.38.0 — the immunization log (`vaccinations` module). SURFACE-gated
|
||||
// like medications, not data-layer-gated: every `/api/vaccinations/*` route
|
||||
// is raw CRUD over the person's own doses, so all four are EXEMPT below
|
||||
// under the same reasoning. Walking the tree means a NEW vaccination route
|
||||
// must justify itself — gate or documented exemption — rather than
|
||||
// silently appearing.
|
||||
"src/app/api/vaccinations",
|
||||
];
|
||||
// mental-health posture): both the batch ingest and the window-summary read
|
||||
// gate directly, so a phone whose user never opted in cannot land rows.
|
||||
nutrients: ["src/app/api/nutrients"],
|
||||
// v1.38.0 — the immunization log. SURFACE-gated like medications, not
|
||||
// data-layer-gated: every `/api/vaccinations/*` route is raw CRUD over the
|
||||
// person's own doses, so all six are EXEMPT below under the same reasoning.
|
||||
vaccinations: ["src/app/api/vaccinations"],
|
||||
};
|
||||
|
||||
/** Every declared tree, de-duplicated — the set this inventory walks. */
|
||||
const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
|
||||
...new Set(Object.values(MODULE_ROUTE_TREES_BY_KEY).flat()),
|
||||
].sort();
|
||||
|
||||
/**
|
||||
* EXEMPT — routes that serve a toggleable domain but are deliberately
|
||||
@@ -309,6 +354,33 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
// while the nav entry, the picker and the report leaf hide.
|
||||
"src/app/api/vaccinations/[id]/booster/route.ts",
|
||||
"src/app/api/vaccinations/suggest/route.ts",
|
||||
// ── INFRA / CREDENTIALS (mcp) ─────────────────────────────────────
|
||||
// The remote MCP endpoint itself is `src/app/mcp/route.ts`, outside `/api`,
|
||||
// and it carries the gate: an account with the module off gets a 404 there
|
||||
// regardless of the credential presented (proven by the behavioural test
|
||||
// listed below). What lives under `/api/mcp` is the credential surface
|
||||
// BEHIND that endpoint, and it is deliberately not gated:
|
||||
//
|
||||
// - the token list / mint / revoke and the connection rows are the
|
||||
// cookie-session settings card. Revoking must keep working while the
|
||||
// module is off — that is precisely when somebody wants to revoke — and
|
||||
// minting happens in the same breath as switching the module on.
|
||||
// - the OAuth bridge mints and exchanges the same `health:read`
|
||||
// credential for a cloud connector. It runs on `withBackgroundEvent`
|
||||
// with its protections applied by hand (rate limit, body cap, kill
|
||||
// switch, mandatory PKCE-S256, redirect-URI allowlist) per the
|
||||
// documented exception, and the credential it issues is inert while the
|
||||
// module is off because `/mcp` refuses it.
|
||||
//
|
||||
// Gating these would not close a data path; it would only wedge the surface
|
||||
// that turns the module on and off.
|
||||
"src/app/api/mcp/tokens/route.ts",
|
||||
"src/app/api/mcp/tokens/[id]/route.ts",
|
||||
"src/app/api/mcp/connections/route.ts",
|
||||
"src/app/api/mcp/connections/[id]/route.ts",
|
||||
"src/app/api/mcp/oauth/authorize/route.ts",
|
||||
"src/app/api/mcp/oauth/token/route.ts",
|
||||
"src/app/api/mcp/oauth/register/route.ts",
|
||||
];
|
||||
|
||||
const MODULE_GATE_NEEDLE = "requireModuleEnabled(";
|
||||
@@ -333,6 +405,7 @@ const ILLNESS_GATE_NEEDLE = "requireIllnessEnabled(";
|
||||
*/
|
||||
const REQUIRED_TREE_MODULE: Readonly<Record<string, ModuleKey>> = {
|
||||
"src/app/api/fhir": "doctorReport",
|
||||
"src/app/api/export/health-record": "doctorReport",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -368,6 +441,14 @@ const BEHAVIOURAL_GATE_TESTS: ReadonlyArray<{ path: string; proves: string }> =
|
||||
proves:
|
||||
"the dashboard summary drops disabled-module metric cards while core vitals still ship",
|
||||
},
|
||||
{
|
||||
// The `mcp` module's enforcement point is outside `/api` entirely, so
|
||||
// nothing in the tree walk above can see it. Listing the proof here is
|
||||
// what keeps it from disappearing unnoticed.
|
||||
path: "src/app/mcp/__tests__/route.test.ts",
|
||||
proves:
|
||||
"the remote MCP endpoint 404s when the mcp module is off, which is what makes the ungated /api/mcp credential surface safe",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -456,6 +537,12 @@ describe("module API route gate inventory", () => {
|
||||
"doctorReport",
|
||||
"insights",
|
||||
"medications",
|
||||
"environment",
|
||||
"mentalHealth",
|
||||
"inboundDocuments",
|
||||
"nutrients",
|
||||
"mcp",
|
||||
"vaccinations",
|
||||
] as const) {
|
||||
expect(known.has(key), `unknown module key in inventory: ${key}`).toBe(
|
||||
true,
|
||||
@@ -463,6 +550,75 @@ describe("module API route gate inventory", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("every module key declares which API trees it owns", () => {
|
||||
// The type already makes an omission a compile error. This is the runtime
|
||||
// half: it fails by NAME, so the message says which key was added without
|
||||
// anybody deciding where its routes live.
|
||||
const undeclared = MODULE_KEYS.filter(
|
||||
(key) => !(key in MODULE_ROUTE_TREES_BY_KEY),
|
||||
);
|
||||
|
||||
expect(
|
||||
undeclared,
|
||||
[
|
||||
"Module keys with no entry in MODULE_ROUTE_TREES_BY_KEY:",
|
||||
...undeclared.map((k) => ` - ${k}`),
|
||||
"Name the module's API tree(s), or declare [] and say in the comment",
|
||||
"where its data actually lives.",
|
||||
].join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("every declared route tree exists on disk", () => {
|
||||
const missing = Object.entries(MODULE_ROUTE_TREES_BY_KEY).flatMap(
|
||||
([key, trees]) =>
|
||||
trees
|
||||
.filter((tree) => !existsSync(resolve(repoRoot, tree)))
|
||||
.map((tree) => ` - ${key}: ${tree}`),
|
||||
);
|
||||
|
||||
expect(
|
||||
missing,
|
||||
[
|
||||
"MODULE_ROUTE_TREES_BY_KEY names directories that do not exist —",
|
||||
"a renamed or deleted tree silently stops being walked:",
|
||||
...missing,
|
||||
].join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("an API directory named after a module key is declared for that key", () => {
|
||||
// This is the check the hand-written array could not perform. A module
|
||||
// whose routes live at the obvious address (`/api/environment` for
|
||||
// `environment`, `/api/mental-health` for `mentalHealth`) is discovered
|
||||
// from the filesystem, so a tree cannot sit outside the inventory just
|
||||
// because nobody added a line here. Trees at a NON-obvious address
|
||||
// (`achievements` → `/api/gamification`, `inboundDocuments` →
|
||||
// `/api/documents/inbound`) still have to be declared by hand; the
|
||||
// declaration is what this test compares against.
|
||||
const apiRoot = resolve(repoRoot, "src/app/api");
|
||||
const undeclared: string[] = [];
|
||||
|
||||
for (const key of MODULE_KEYS) {
|
||||
const dirName = key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
||||
const tree = `src/app/api/${dirName}`;
|
||||
if (!existsSync(join(apiRoot, dirName))) continue;
|
||||
if (MODULE_ROUTE_TREES_BY_KEY[key].includes(tree)) continue;
|
||||
undeclared.push(` - ${key}: ${tree} exists but is not declared`);
|
||||
}
|
||||
|
||||
expect(
|
||||
undeclared,
|
||||
[
|
||||
"API trees named after a module key that this inventory does not walk:",
|
||||
...undeclared,
|
||||
"Declare the tree under its key in MODULE_ROUTE_TREES_BY_KEY. Every",
|
||||
"route beneath it then has to be gated, delegated, or exempt with a",
|
||||
"documented reason.",
|
||||
].join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("every toggleable-module route is gated, delegated, or explicitly exempt", () => {
|
||||
const routes = findAllModuleRouteFiles();
|
||||
expect(routes.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Webhook verb rate-limit guard.
|
||||
*
|
||||
* The project rule for an inbound webhook is: per-source rate limit BEFORE
|
||||
* secret verification. The reason is not throughput, it is the oracle. A
|
||||
* secret comparison that answers 200 against 401 tells the caller whether it
|
||||
* guessed right; constant-time comparison hides the timing channel but not the
|
||||
* status code, so the only thing standing between a wrong guess and the next
|
||||
* one is the limiter. An unlimited verb is a free guessing machine and a free
|
||||
* load channel.
|
||||
*
|
||||
* The rule was applied per HANDLER rather than per FILE, which is how it drifted:
|
||||
* both Withings entrypoints, the Telegram bot webhook and the Coolify deploy
|
||||
* hook limited POST and left the verification verbs (GET / HEAD, the URL
|
||||
* reachability probes every webhook UI sends) comparing the same secret with
|
||||
* nothing in front of them. Reviewing the POST proved nothing about the sibling
|
||||
* export three lines below it.
|
||||
*
|
||||
* So this guard reasons about EVERY exported verb, not about files:
|
||||
*
|
||||
* 1. Discover the webhook routes from the filesystem — every `route.ts`
|
||||
* under `src/app/api` whose path names a webhook, plus the explicitly
|
||||
* listed unauthenticated inbound endpoints below.
|
||||
* 2. Resolve which local helpers reach a constant-time secret comparison and
|
||||
* which reach the rate limiter, transitively, so a verb that delegates
|
||||
* (`verifyTokenSegment`, `hasValidSecret`, `checkAndWarn`) is judged on
|
||||
* what the helper does rather than on the call's spelling.
|
||||
* 3. For every exported verb that reaches a comparison, require that it also
|
||||
* reaches the limiter and reaches it FIRST.
|
||||
*
|
||||
* A verb that compares no secret is not the subject here and is skipped — the
|
||||
* CSP report endpoint is the example: nothing to guess, and it rate-limits
|
||||
* anyway. A verb that never gets exported cannot be reached, which is why
|
||||
* WHOOP passes trivially: it exports POST only.
|
||||
*
|
||||
* LIMIT, stated so nobody reads more into a green run than it earns: this is a
|
||||
* source-text guard. It proves the limiter call precedes the comparison call in
|
||||
* the handler body; it does not prove the limiter and the POST share a bucket,
|
||||
* and a comparison moved into an imported module (rather than a local helper)
|
||||
* would fall outside the transitive resolution below. The per-route tests are
|
||||
* what prove the refusal.
|
||||
*/
|
||||
|
||||
const repoRoot = resolve(__dirname, "..", "..", "..", "..");
|
||||
const API_ROOT = "src/app/api";
|
||||
|
||||
/**
|
||||
* Inbound endpoints that are webhooks by behaviour but not by path. Listed
|
||||
* explicitly so the discovery below is not silently narrower than the class it
|
||||
* claims to cover.
|
||||
*/
|
||||
const EXTRA_WEBHOOK_ROUTES: ReadonlyArray<string> = [
|
||||
// Browser-posted CSP violation reports. Unauthenticated by design (the
|
||||
// browser cannot carry a secret), rate-limited, no comparison to shield.
|
||||
"src/app/api/monitoring/csp-report/route.ts",
|
||||
];
|
||||
|
||||
/** The HTTP verbs Next.js recognises as route handlers. */
|
||||
const HTTP_VERBS = [
|
||||
"GET",
|
||||
"HEAD",
|
||||
"POST",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"DELETE",
|
||||
"OPTIONS",
|
||||
] as const;
|
||||
|
||||
/** A constant-time secret comparison, however it is spelled. */
|
||||
const COMPARISON_NEEDLES = [
|
||||
"timingSafeEqual(",
|
||||
"timingSafeStringEqual(",
|
||||
] as const;
|
||||
|
||||
/** The per-source rate limiter, direct or through the shared webhook helper. */
|
||||
const LIMITER_NEEDLES = [
|
||||
"checkRateLimit(",
|
||||
"checkAuthSurfaceRateLimit(",
|
||||
"applyWebhookRateLimit(",
|
||||
] as const;
|
||||
|
||||
/** Drop whole-line comments so a docstring mentioning a helper is not a call. */
|
||||
function stripCommentLines(text: string): string {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (
|
||||
trimmed.startsWith("//") ||
|
||||
trimmed.startsWith("*") ||
|
||||
trimmed.startsWith("/*")
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
return line;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** Index of the earliest needle in `text`, or -1 when none appears. */
|
||||
function firstIndexOfAny(text: string, needles: ReadonlyArray<string>): number {
|
||||
let best = -1;
|
||||
for (const needle of needles) {
|
||||
const at = text.indexOf(needle);
|
||||
if (at === -1) continue;
|
||||
if (best === -1 || at < best) best = at;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Every `route.ts` under `src/app/api`, as repo-relative POSIX paths. */
|
||||
function findRouteFiles(): string[] {
|
||||
const hits: string[] = [];
|
||||
|
||||
function walk(dir: string): void {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
if (entry === "__tests__" || entry === "node_modules") continue;
|
||||
walk(full);
|
||||
continue;
|
||||
}
|
||||
if (entry !== "route.ts") continue;
|
||||
hits.push(relative(repoRoot, full).split(/[\\/]/).join("/"));
|
||||
}
|
||||
}
|
||||
|
||||
walk(resolve(repoRoot, API_ROOT));
|
||||
return hits.sort();
|
||||
}
|
||||
|
||||
function findWebhookRoutes(): string[] {
|
||||
const byPath = findRouteFiles().filter((p) =>
|
||||
p.toLowerCase().includes("webhook"),
|
||||
);
|
||||
return [...new Set([...byPath, ...EXTRA_WEBHOOK_ROUTES])].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a module into its top-level declarations. Crude on purpose: a
|
||||
* declaration runs from its own `const`/`function`/`export` keyword at column
|
||||
* zero to the next one. Good enough to attribute a call to the handler or the
|
||||
* helper that contains it.
|
||||
*/
|
||||
function topLevelBlocks(text: string): Array<{ name: string; body: string }> {
|
||||
const starts: Array<{ name: string; at: number }> = [];
|
||||
const re =
|
||||
/^(?:export\s+)?(?:async\s+)?(?:function|const|let|class)\s+([A-Za-z0-9_$]+)/gm;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
starts.push({ name: m[1], at: m.index });
|
||||
}
|
||||
return starts.map((s, i) => ({
|
||||
name: s.name,
|
||||
body: text.slice(
|
||||
s.at,
|
||||
i + 1 < starts.length ? starts[i + 1].at : undefined,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The local helper names that transitively reach one of `needles`. Two passes
|
||||
* over the declaration list resolve one level of delegation
|
||||
* (`checkAndWarn` → `hasValidWebhookSecret` → `timingSafeStringEqual`), which
|
||||
* is the depth the webhook routes actually use; a third pass changes nothing
|
||||
* today and would be the place to grow if a route nests deeper.
|
||||
*/
|
||||
function helpersReaching(
|
||||
blocks: ReadonlyArray<{ name: string; body: string }>,
|
||||
needles: ReadonlyArray<string>,
|
||||
): Set<string> {
|
||||
const reaching = new Set<string>();
|
||||
for (let pass = 0; pass < 4; pass += 1) {
|
||||
let grew = false;
|
||||
for (const block of blocks) {
|
||||
if (reaching.has(block.name)) continue;
|
||||
const direct = firstIndexOfAny(block.body, needles) !== -1;
|
||||
const viaHelper = [...reaching].some((h) => block.body.includes(`${h}(`));
|
||||
if (direct || viaHelper) {
|
||||
reaching.add(block.name);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
if (!grew) break;
|
||||
}
|
||||
return reaching;
|
||||
}
|
||||
|
||||
interface VerbVerdict {
|
||||
path: string;
|
||||
verb: string;
|
||||
comparisonAt: number;
|
||||
limiterAt: number;
|
||||
}
|
||||
|
||||
function analyseRoute(path: string): VerbVerdict[] {
|
||||
const text = stripCommentLines(readFileSync(resolve(repoRoot, path), "utf8"));
|
||||
const blocks = topLevelBlocks(text);
|
||||
|
||||
const comparisonHelpers = helpersReaching(blocks, COMPARISON_NEEDLES);
|
||||
const limiterHelpers = helpersReaching(blocks, LIMITER_NEEDLES);
|
||||
|
||||
const verdicts: VerbVerdict[] = [];
|
||||
for (const block of blocks) {
|
||||
if (!HTTP_VERBS.includes(block.name as (typeof HTTP_VERBS)[number])) {
|
||||
continue;
|
||||
}
|
||||
if (!new RegExp(`^export\\s+const\\s+${block.name}\\b`).test(block.body)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const comparisonAt = firstIndexOfAny(block.body, [
|
||||
...COMPARISON_NEEDLES,
|
||||
...[...comparisonHelpers]
|
||||
.filter((h) => h !== block.name)
|
||||
.map((h) => `${h}(`),
|
||||
]);
|
||||
if (comparisonAt === -1) continue;
|
||||
|
||||
const limiterAt = firstIndexOfAny(block.body, [
|
||||
...LIMITER_NEEDLES,
|
||||
...[...limiterHelpers]
|
||||
.filter((h) => h !== block.name)
|
||||
.map((h) => `${h}(`),
|
||||
]);
|
||||
|
||||
verdicts.push({ path, verb: block.name, comparisonAt, limiterAt });
|
||||
}
|
||||
return verdicts;
|
||||
}
|
||||
|
||||
describe("webhook verb rate-limit guard", () => {
|
||||
const routes = findWebhookRoutes();
|
||||
const verdicts = routes.flatMap(analyseRoute);
|
||||
|
||||
it("finds the webhook routes it claims to judge", () => {
|
||||
// An empty match set is the failure mode this project has already been
|
||||
// bitten by: a guard that matches nothing is green because it looked at
|
||||
// nothing. Both counts are floors, not inventories.
|
||||
expect(routes.length).toBeGreaterThanOrEqual(5);
|
||||
expect(
|
||||
routes,
|
||||
"the Withings path-segment webhook must be in the scanned set",
|
||||
).toContain("src/app/api/withings/webhook/[token]/route.ts");
|
||||
expect(routes).toContain("src/app/api/telegram/webhook/route.ts");
|
||||
expect(routes).toContain("src/app/api/internal/deploy-webhook/route.ts");
|
||||
});
|
||||
|
||||
it("recognises the verbs that compare a secret", () => {
|
||||
expect(
|
||||
verdicts.length,
|
||||
"no secret-comparing webhook verb was recognised — the matchers have " +
|
||||
"drifted away from the code they are supposed to judge",
|
||||
).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
it("every secret-comparing verb rate-limits before it compares", () => {
|
||||
const offenders = verdicts
|
||||
.filter((v) => v.limiterAt === -1 || v.limiterAt > v.comparisonAt)
|
||||
.map((v) =>
|
||||
v.limiterAt === -1
|
||||
? ` - ${v.path} → ${v.verb}: compares the secret with no rate limit at all`
|
||||
: ` - ${v.path} → ${v.verb}: rate limit runs AFTER the comparison`,
|
||||
);
|
||||
|
||||
expect(
|
||||
offenders,
|
||||
[
|
||||
"Webhook verbs that compare a shared secret without a rate limit in front:",
|
||||
...offenders,
|
||||
"",
|
||||
"A 200-against-401 answer is an oracle. Constant-time comparison hides",
|
||||
"the timing channel, not the status code — the limiter is what makes",
|
||||
"guessing expensive. Apply the same per-source limit the POST uses,",
|
||||
"on the same bucket, before the comparison.",
|
||||
].join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -329,6 +329,31 @@ describe("GET /api/internal/deploy-webhook (reachability check)", () => {
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
// The reachability GET compares the same shared secret the POST does and
|
||||
// answers 200 against 401. Unlimited, that is a free oracle for guessing it.
|
||||
// A VALID secret over the limit must still be refused — that proves the
|
||||
// limiter runs before the comparison, not merely beside it.
|
||||
it("returns 429 for a valid secret when over the limit, on the POST bucket", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/internal/deploy-webhook", {
|
||||
method: "GET",
|
||||
headers: { "x-deploy-webhook-secret": "test-secret" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
expect(checkRateLimit).toHaveBeenCalledTimes(1);
|
||||
expect(String(vi.mocked(checkRateLimit).mock.calls[0][0])).toMatch(
|
||||
/^deploy-webhook:/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/internal/deploy-webhook (timestamp replay protection)", () => {
|
||||
|
||||
@@ -202,24 +202,39 @@ async function notifyAdminsOfFailure(event: NormalizedEvent): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "deploy.webhook" } });
|
||||
|
||||
// Rate-limit by client IP. 60 requests / minute is comfortably above
|
||||
// Coolify's per-event burst (typically 1-2 per deploy) but well below
|
||||
// a hostile actor flooding to fish for a working secret.
|
||||
/**
|
||||
* Rate-limit by client IP. 60 requests / minute is comfortably above
|
||||
* Coolify's per-event burst (typically 1-2 per deploy) but well below a
|
||||
* hostile actor flooding to fish for a working secret.
|
||||
*
|
||||
* Both verbs share the bucket, and both run it before the comparison: the
|
||||
* reachability GET checks the same secret and answers 200 against 401, so
|
||||
* leaving it unlimited would hand back the free guessing channel the POST
|
||||
* limit exists to close.
|
||||
*/
|
||||
async function applyWebhookRateLimit(
|
||||
request: NextRequest,
|
||||
): Promise<NextResponse | null> {
|
||||
const ip = getClientIp(request);
|
||||
const rl = await checkRateLimit(
|
||||
`deploy-webhook:${ip ?? "unknown"}`,
|
||||
60,
|
||||
60_000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ status: "rate_limited" },
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
}
|
||||
if (rl.allowed) return null;
|
||||
return NextResponse.json(
|
||||
{ status: "rate_limited" },
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "deploy.webhook" } });
|
||||
|
||||
const limited = await applyWebhookRateLimit(request);
|
||||
if (limited) return limited;
|
||||
|
||||
const ip = getClientIp(request);
|
||||
|
||||
if (!hasValidSecret(request)) {
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
@@ -293,6 +308,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
*/
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "deploy.webhook.verify" } });
|
||||
|
||||
const limited = await applyWebhookRateLimit(request);
|
||||
if (limited) return limited;
|
||||
|
||||
if (!hasValidSecret(request)) {
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -1155,3 +1155,29 @@ describe("Telegram webhook — interactive measurement reminder (v1.19.0)", () =
|
||||
expect(deleteMessage).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Telegram webhook — GET verification is rate-limited first", () => {
|
||||
// The reachability GET compares the same bot secret the POST does and
|
||||
// answers 200 against 401. Without the limit in front it is an unthrottled
|
||||
// oracle for guessing that secret, so a VALID secret over the limit must
|
||||
// still be refused — that ordering is the whole point.
|
||||
it("returns 429 for a valid secret when over the limit", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/telegram/webhook", {
|
||||
headers: { "x-telegram-bot-api-secret-token": "test-secret" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
expect(checkRateLimit).toHaveBeenCalledTimes(1);
|
||||
expect(String(vi.mocked(checkRateLimit).mock.calls[0][0])).toMatch(
|
||||
/^telegram-webhook:/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -32,17 +32,29 @@ function hasValidSecret(request: NextRequest): boolean {
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-source limit both verbs share, on one bucket. It runs before the
|
||||
* secret comparison on every verb that performs one: the comparison answers
|
||||
* 200 against 401, and a constant-time compare hides the timing channel but
|
||||
* not the status code, so the limiter is what makes guessing expensive.
|
||||
*/
|
||||
async function applyWebhookRateLimit(
|
||||
request: NextRequest,
|
||||
): Promise<NextResponse | null> {
|
||||
const ip = getClientIp(request);
|
||||
const rl = await checkRateLimit(`telegram-webhook:${ip}`, 120, 60 * 1000);
|
||||
if (rl.allowed) return null;
|
||||
return NextResponse.json(
|
||||
{ status: "rate_limited" },
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "telegram.webhook" } });
|
||||
|
||||
const ip = getClientIp(request);
|
||||
const rl = await checkRateLimit(`telegram-webhook:${ip}`, 120, 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ status: "rate_limited" },
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
}
|
||||
const limited = await applyWebhookRateLimit(request);
|
||||
if (limited) return limited;
|
||||
|
||||
if (!hasValidSecret(request)) {
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
@@ -84,6 +96,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "telegram.webhook.verify" } });
|
||||
|
||||
const limited = await applyWebhookRateLimit(request);
|
||||
if (limited) return limited;
|
||||
|
||||
if (!hasValidSecret(request)) {
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -216,3 +216,64 @@ describe("GET /api/withings/webhook/[token]", () => {
|
||||
expect(bad.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verification verbs are rate-limited before the secret comparison", () => {
|
||||
// A verification verb answers 200 for the right token and 401 for a wrong
|
||||
// one. Without a limit in front that is a free oracle for guessing the
|
||||
// secret — constant-time comparison hides the timing channel, not the
|
||||
// status code. Handing each verb a VALID token while over the limit is the
|
||||
// sharp proof of ordering: the comparison would have said 200.
|
||||
function overTheLimit(): void {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("HEAD returns 429 for a valid token when over the limit", async () => {
|
||||
overTheLimit();
|
||||
const res = await HEAD(
|
||||
new NextRequest("http://localhost/api/withings/webhook/test-secret", {
|
||||
method: "HEAD",
|
||||
}),
|
||||
paramsFor("test-secret"),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(checkRateLimit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("GET returns 429 for a valid token when over the limit", async () => {
|
||||
overTheLimit();
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/withings/webhook/test-secret"),
|
||||
paramsFor("test-secret"),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(checkRateLimit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("HEAD and GET share the POST bucket", async () => {
|
||||
await HEAD(
|
||||
new NextRequest("http://localhost/api/withings/webhook/test-secret", {
|
||||
method: "HEAD",
|
||||
}),
|
||||
paramsFor("test-secret"),
|
||||
);
|
||||
await GET(
|
||||
new NextRequest("http://localhost/api/withings/webhook/test-secret"),
|
||||
paramsFor("test-secret"),
|
||||
);
|
||||
await POST(
|
||||
jsonRequest("test-secret", { userid: "wu-1" }),
|
||||
paramsFor("test-secret"),
|
||||
);
|
||||
|
||||
const buckets = vi
|
||||
.mocked(checkRateLimit)
|
||||
.mock.calls.map((call) => String(call[0]));
|
||||
expect(buckets).toHaveLength(3);
|
||||
expect(new Set(buckets).size).toBe(1);
|
||||
expect(buckets[0]).toMatch(/^withings-webhook:/);
|
||||
});
|
||||
});
|
||||
@@ -55,10 +55,23 @@ export const POST = apiHandler(
|
||||
/**
|
||||
* Withings sends HEAD/GET to verify the URL when a subscription is
|
||||
* created.
|
||||
*
|
||||
* The verification verbs carry the same rate limit as the notification POST,
|
||||
* on the same bucket. They compare the same shared secret and answer 200
|
||||
* against 401, so without it they are a free oracle for guessing the token —
|
||||
* a constant-time comparison hides the timing channel, not the status code.
|
||||
*/
|
||||
export const HEAD = apiHandler(
|
||||
async (_request: NextRequest, context: RouteContext) => {
|
||||
async (request: NextRequest, context: RouteContext) => {
|
||||
annotate({ action: { name: "withings.webhook.verify" } });
|
||||
|
||||
// HEAD carries no body, so the limiter's JSON envelope is re-shaped into
|
||||
// a bodyless 429 that keeps the rate-limit headers.
|
||||
const limited = await applyWebhookRateLimit(request);
|
||||
if (limited) {
|
||||
return new NextResponse(null, { status: 429, headers: limited.headers });
|
||||
}
|
||||
|
||||
const { token } = await context.params;
|
||||
if (!(await verifyTokenSegment(token))) {
|
||||
return new NextResponse(null, { status: 401 });
|
||||
@@ -69,8 +82,12 @@ export const HEAD = apiHandler(
|
||||
);
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, context: RouteContext) => {
|
||||
async (request: NextRequest, context: RouteContext) => {
|
||||
annotate({ action: { name: "withings.webhook.verify" } });
|
||||
|
||||
const limited = await applyWebhookRateLimit(request);
|
||||
if (limited) return limited;
|
||||
|
||||
const { token } = await context.params;
|
||||
if (!(await verifyTokenSegment(token))) {
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
|
||||
@@ -473,3 +473,53 @@ describe("GET /api/withings/webhook", () => {
|
||||
expect(fail.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy verification verbs are rate-limited before the secret comparison", () => {
|
||||
// Same reasoning as the path-segment sibling: a verb that answers 200 for
|
||||
// the right secret and 401 for a wrong one is a guessing oracle unless the
|
||||
// limiter runs first. A VALID secret over the limit must still be refused.
|
||||
function overTheLimit(): void {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("HEAD returns 429 for a valid secret when over the limit", async () => {
|
||||
overTheLimit();
|
||||
const res = await HEAD(
|
||||
new NextRequest("http://localhost/api/withings/webhook", {
|
||||
method: "HEAD",
|
||||
headers: { "x-withings-webhook-secret": "test-secret" },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(checkRateLimit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("GET returns 429 for a valid secret when over the limit", async () => {
|
||||
overTheLimit();
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/withings/webhook", {
|
||||
headers: { "x-withings-webhook-secret": "test-secret" },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(checkRateLimit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("the legacy ?secret= form is limited on the same bucket as the POST", async () => {
|
||||
overTheLimit();
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/withings/webhook?secret=WRONG"),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
|
||||
const buckets = vi
|
||||
.mocked(checkRateLimit)
|
||||
.mock.calls.map((call) => String(call[0]));
|
||||
expect(buckets).toHaveLength(1);
|
||||
expect(buckets[0]).toMatch(/^withings-webhook:/);
|
||||
});
|
||||
});
|
||||
@@ -91,9 +91,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
/**
|
||||
* Withings sends a HEAD request to verify the webhook URL.
|
||||
*
|
||||
* Rate-limited on the same bucket as the notification POST, and before the
|
||||
* comparison: HEAD and GET check the same shared secret and answer 200 against
|
||||
* 401, which is an oracle for guessing it unless the limiter runs first.
|
||||
*/
|
||||
export const HEAD = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "withings.webhook.verify" } });
|
||||
|
||||
// HEAD carries no body, so the limiter's JSON envelope is re-shaped into a
|
||||
// bodyless 429 that keeps the rate-limit headers.
|
||||
const limited = await applyWebhookRateLimit(request);
|
||||
if (limited) {
|
||||
return new NextResponse(null, { status: 429, headers: limited.headers });
|
||||
}
|
||||
|
||||
const auth = await hasValidWebhookSecret(request);
|
||||
if (!auth.ok) {
|
||||
return new NextResponse(null, { status: 401 });
|
||||
@@ -104,6 +116,10 @@ export const HEAD = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "withings.webhook.verify" } });
|
||||
|
||||
const limited = await applyWebhookRateLimit(request);
|
||||
if (limited) return limited;
|
||||
|
||||
const auth = await hasValidWebhookSecret(request);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
|
||||
Reference in new issue
Block a user