Files
HealthLog/src/app/api/withings/webhook/route.ts
T
Marc-André Bombeck 20812b54f5 fix(webhooks): rate-limit every verb that checks the shared secret
The Withings entrypoints, the Telegram bot webhook and the Coolify deploy
hook limited POST and left the verification verbs unlimited. GET and HEAD
compare the same secret and answer 200 against 401, which is an oracle for
guessing it and a free load channel besides; timingSafeEqual protects the
timing channel, not the status code. The project's rule is that the
per-source limit runs before secret verification, and it was being applied
per handler rather than per file.

Each verification verb now runs the same limit its POST sibling used, on the
same bucket. Telegram and the deploy hook grow the small local helper the
Withings routes already share. HEAD re-shapes the limiter's JSON envelope
into a bodyless 429 and keeps the rate-limit headers.

The new guard walks every webhook route from the filesystem, resolves which
local helpers reach a comparison and which reach the limiter, and fails when
an exported verb reaches a comparison without the limiter in front of it. It
asserts a non-zero match count so an empty match set cannot read as green.
WHOOP already passed: it exports POST only.
2026-09-03 01:42:56 +02:00

130 lines
4.9 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import {
applyWebhookRateLimit,
processWithingsNotification,
timingSafeStringEqual,
} from "@/lib/withings/webhook-handler";
/**
* Legacy Withings webhook entrypoint.
*
* Pre-v1.4.25 callback URLs accepted the shared secret in two places:
*
* 1. `?secret=…` — the original form, which Withings preserves on
* every notification and which therefore lands in
* reverse-proxy access logs (the `query_string`
* column) and any error-tracking pipeline that
* captures `request.url`.
* 2. `X-Withings-Webhook-Secret` header — Withings does NOT support
* setting custom headers on notifications, so this
* path only fires for manual replay tests.
*
* v1.4.25 W17a moved the production path to
* `/api/withings/webhook/[token]`, where the secret travels as a path
* segment instead of a query parameter. This route stays alive for
* one release cycle so existing Withings subscriptions keep delivering
* while users re-subscribe (which also happens organically when they
* reconnect for the new `user.activity` OAuth scope shipped in W5d).
*
* Removal target: a future release once all live subscriptions have
* rotated to the path-segment form. The warning is still emitted on the
* legacy path so an operator watching the access log knows what to
* migrate to.
*
* v1.4.47 — dropped the in-memory `legacy_form_total` counter (was per-
* process, useless on a multi-container deploy; the access-log warning
* already covers the signal it provided).
*/
const MIGRATION_URL = "/api/withings/webhook/[token]" as const;
function hasValidWebhookSecret(
request: NextRequest,
): Promise<{ ok: boolean; via: "header" | "query" | "none" }> {
const expected = process.env.WITHINGS_WEBHOOK_SECRET;
if (!expected) {
getEvent()?.addWarning("WITHINGS_WEBHOOK_SECRET not configured");
return Promise.resolve({ ok: false, via: "none" });
}
const fromHeader = request.headers.get("x-withings-webhook-secret");
const fromQuery = request.nextUrl.searchParams.get("secret");
const received = fromHeader ?? fromQuery;
if (!received) return Promise.resolve({ ok: false, via: "none" });
const via: "header" | "query" = fromHeader ? "header" : "query";
return timingSafeStringEqual(expected, received).then((ok) => ({ ok, via }));
}
async function checkAndWarn(
request: NextRequest,
): Promise<NextResponse | null> {
const auth = await hasValidWebhookSecret(request);
if (!auth.ok) {
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
}
if (auth.via === "query") {
getEvent()?.addWarning(
`withings webhook secret received via legacy URL query — migrate to ${MIGRATION_URL} (re-subscribe with the path-segment token form)`,
);
}
return null;
}
/**
* Withings webhook notification endpoint (legacy form).
* Withings sends a POST when new measurements are available.
* The webhook sends: userid, startdate, enddate, appli.
*/
export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "withings.webhook.legacy" } });
const limited = await applyWebhookRateLimit(request);
if (limited) return limited;
const unauth = await checkAndWarn(request);
if (unauth) return unauth;
return processWithingsNotification(request);
});
/**
* 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 });
}
getEvent()?.setAuth({ auth_method: "webhook_secret" });
return new NextResponse(null, { status: 200 });
});
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 });
}
getEvent()?.setAuth({ auth_method: "webhook_secret" });
return NextResponse.json({ status: "ok" }, { status: 200 });
});