mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
fix(security): Withings webhook secret as URL path segment, not query string
Move the Withings webhook shared secret from `?secret=…` to a path segment (`/api/withings/webhook/<secret>`) so it never lands in the reverse-proxy `query_string` access-log column or GlitchTip URL/ breadcrumb capture surface (W10 finding M-2). Withings has no facility for custom HTTP headers on outgoing notifications and never signs the body — every Notify.subscribe call accepts only action/callbackurl/appli/client_id/nonce/signature, so the callback URL is the only authenticity surface a subscriber controls. Path-segment binding is the largest practical shift away from loggable query strings that Withings supports end-to-end. The legacy `/api/withings/webhook` route stays alive for one release cycle (removal target: v1.4.27) so existing subscriptions keep delivering. The query-string path now emits a deprecation breadcrumb on every hit. Active users re-subscribe organically when they reconnect for the v1.4.25 `user.activity` OAuth-scope upgrade. Tests: 8 new for the path-segment route (valid / invalid / missing / unset-env / prefix-match / rate-limited / HEAD / GET), 3 new for `getWithingsWebhookCallbackUrl()`. Existing legacy-route tests continue to pass over the refactored shared helper.
This commit is contained in:
1 parent
b4df3ae8cd
commit
a1ffa497f1
7 files changed
+689
-87
No files matched your search
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// --- Module-boundary mocks must come before importing the route. ---
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
withingsConnection: { findFirst: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/withings/sync", () => ({
|
||||
syncUserMeasurements: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => ({
|
||||
setAuth: vi.fn(),
|
||||
addWarning: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST, GET, HEAD } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { syncUserMeasurements } from "@/lib/withings/sync";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
const ORIGINAL_SECRET = process.env.WITHINGS_WEBHOOK_SECRET;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
process.env.WITHINGS_WEBHOOK_SECRET = "test-secret";
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 30,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(getEvent).mockReturnValue({
|
||||
setAuth: vi.fn(),
|
||||
addWarning: vi.fn(),
|
||||
} as never);
|
||||
vi.mocked(syncUserMeasurements).mockResolvedValue(undefined as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_SECRET === undefined) delete process.env.WITHINGS_WEBHOOK_SECRET;
|
||||
else process.env.WITHINGS_WEBHOOK_SECRET = ORIGINAL_SECRET;
|
||||
});
|
||||
|
||||
function jsonRequest(token: string, body: unknown): NextRequest {
|
||||
return new NextRequest(
|
||||
`http://localhost/api/withings/webhook/${encodeURIComponent(token)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function paramsFor(token: string) {
|
||||
return { params: Promise.resolve({ token }) };
|
||||
}
|
||||
|
||||
describe("POST /api/withings/webhook/[token] (path-segment secret)", () => {
|
||||
it("returns 200 + triggers sync when the path token matches WITHINGS_WEBHOOK_SECRET", async () => {
|
||||
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
|
||||
userId: "user-42",
|
||||
withingsUserId: "wu-1",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest("test-secret", { userid: "wu-1" }),
|
||||
paramsFor("test-secret"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { status: string };
|
||||
expect(body.status).toBe("ok");
|
||||
|
||||
expect(prisma.withingsConnection.findFirst).toHaveBeenCalledWith({
|
||||
where: { withingsUserId: "wu-1" },
|
||||
});
|
||||
expect(syncUserMeasurements).toHaveBeenCalledTimes(1);
|
||||
expect(syncUserMeasurements).toHaveBeenCalledWith("user-42");
|
||||
});
|
||||
|
||||
it("returns 401 when the path token does NOT match the configured secret", async () => {
|
||||
const res = await POST(
|
||||
jsonRequest("WRONG", { userid: "wu-1" }),
|
||||
paramsFor("WRONG"),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(syncUserMeasurements).not.toHaveBeenCalled();
|
||||
expect(prisma.withingsConnection.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 401 when the path token is the empty string (missing)", async () => {
|
||||
// Next.js never routes /api/withings/webhook/ to this handler — that
|
||||
// path lands on the legacy route. The empty-string case here covers
|
||||
// direct invocations and adversarial proxies that strip the segment.
|
||||
const res = await POST(
|
||||
new NextRequest("http://localhost/api/withings/webhook/", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ userid: "wu-1" }),
|
||||
}),
|
||||
paramsFor(""),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(syncUserMeasurements).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 401 when WITHINGS_WEBHOOK_SECRET is unset, even if the URL carries a token", async () => {
|
||||
delete process.env.WITHINGS_WEBHOOK_SECRET;
|
||||
const addWarning = vi.fn();
|
||||
vi.mocked(getEvent).mockReturnValue({
|
||||
setAuth: vi.fn(),
|
||||
addWarning,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest("anything", { userid: "wu-1" }),
|
||||
paramsFor("anything"),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(addWarning).toHaveBeenCalledWith(
|
||||
"WITHINGS_WEBHOOK_SECRET not configured",
|
||||
);
|
||||
});
|
||||
|
||||
it("constant-time compare rejects a token of the wrong length without short-circuiting on prefix match", async () => {
|
||||
// "test-secret" is the configured value; "test-secret-extra" shares
|
||||
// the full prefix. A non-timing-safe compare would still return false
|
||||
// here, so the assertion is on the response only — the security
|
||||
// property is exercised by the underlying timingSafeEqual call shape.
|
||||
const res = await POST(
|
||||
jsonRequest("test-secret-extra", { userid: "wu-1" }),
|
||||
paramsFor("test-secret-extra"),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 429 when rate-limited, before the auth check", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const res = await POST(
|
||||
jsonRequest("WRONG", { userid: "wu-1" }),
|
||||
paramsFor("WRONG"),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(syncUserMeasurements).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HEAD /api/withings/webhook/[token]", () => {
|
||||
it("returns 200 for a valid token, 401 otherwise", async () => {
|
||||
const okReq = new NextRequest(
|
||||
"http://localhost/api/withings/webhook/test-secret",
|
||||
{ method: "HEAD" },
|
||||
);
|
||||
const ok = await HEAD(okReq, paramsFor("test-secret"));
|
||||
expect(ok.status).toBe(200);
|
||||
|
||||
const badReq = new NextRequest(
|
||||
"http://localhost/api/withings/webhook/WRONG",
|
||||
{ method: "HEAD" },
|
||||
);
|
||||
const bad = await HEAD(badReq, paramsFor("WRONG"));
|
||||
expect(bad.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/withings/webhook/[token]", () => {
|
||||
it("returns 200 + ok body for a valid token, 401 otherwise", async () => {
|
||||
const okReq = new NextRequest(
|
||||
"http://localhost/api/withings/webhook/test-secret",
|
||||
);
|
||||
const ok = await GET(okReq, paramsFor("test-secret"));
|
||||
expect(ok.status).toBe(200);
|
||||
|
||||
const badReq = new NextRequest(
|
||||
"http://localhost/api/withings/webhook/WRONG",
|
||||
);
|
||||
const bad = await GET(badReq, paramsFor("WRONG"));
|
||||
expect(bad.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
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";
|
||||
|
||||
/**
|
||||
* Withings webhook entrypoint (v1.4.25 W17a — path-segment secret).
|
||||
*
|
||||
* The shared secret travels in the URL path (`/api/withings/webhook/<secret>`)
|
||||
* rather than the query string. Withings has no facility for adding HTTP
|
||||
* headers or signing webhook bodies, so the only authenticity surface a
|
||||
* subscriber controls is the callback URL itself; moving the secret out of
|
||||
* the query string keeps it out of the `query_string` column most reverse
|
||||
* proxies log by default (Caddy, nginx, Coolify) and out of GlitchTip's
|
||||
* URL/breadcrumb capture path which already strips `?…` aggressively.
|
||||
*
|
||||
* The legacy `?secret=` form at `/api/withings/webhook` stays alive for one
|
||||
* release cycle so existing Withings subscriptions keep delivering while
|
||||
* users re-subscribe.
|
||||
*/
|
||||
interface RouteContext {
|
||||
params: Promise<{ token: string }>;
|
||||
}
|
||||
|
||||
async function verifyTokenSegment(
|
||||
token: string | undefined,
|
||||
): Promise<boolean> {
|
||||
const expected = process.env.WITHINGS_WEBHOOK_SECRET;
|
||||
if (!expected) {
|
||||
getEvent()?.addWarning("WITHINGS_WEBHOOK_SECRET not configured");
|
||||
return false;
|
||||
}
|
||||
if (!token) return false;
|
||||
return timingSafeStringEqual(expected, token);
|
||||
}
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, context: RouteContext) => {
|
||||
annotate({ action: { name: "withings.webhook" } });
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
return processWithingsNotification(request);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Withings sends HEAD/GET to verify the URL when a subscription is
|
||||
* created.
|
||||
*/
|
||||
export const HEAD = apiHandler(
|
||||
async (_request: NextRequest, context: RouteContext) => {
|
||||
annotate({ action: { name: "withings.webhook.verify" } });
|
||||
const { token } = await context.params;
|
||||
if (!(await verifyTokenSegment(token))) {
|
||||
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, context: RouteContext) => {
|
||||
annotate({ action: { name: "withings.webhook.verify" } });
|
||||
const { token } = await context.params;
|
||||
if (!(await verifyTokenSegment(token))) {
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
getEvent()?.setAuth({ auth_method: "webhook_secret" });
|
||||
return NextResponse.json({ status: "ok" }, { status: 200 });
|
||||
},
|
||||
);
|
||||
@@ -1,110 +1,83 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { syncUserMeasurements } from "@/lib/withings/sync";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { getClientIp, safeJson } from "@/lib/api-response";
|
||||
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";
|
||||
|
||||
/**
|
||||
* Audit C-3 / phase P2: Withings legacy callback URLs include the shared
|
||||
* secret as a `?secret=…` query param, which leaks into reverse-proxy
|
||||
* access logs and any error-tracking pipeline that captures `request.url`
|
||||
* (see `reportToGlitchtip`). New deployments should configure Withings to
|
||||
* send the secret via the `X-Withings-Webhook-Secret` header instead.
|
||||
* Legacy Withings webhook entrypoint.
|
||||
*
|
||||
* The query-param fallback stays for backwards compatibility with existing
|
||||
* Withings configurations; remove once all integrators have migrated.
|
||||
* 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: v1.4.27 (after all live subscriptions have rotated).
|
||||
*/
|
||||
function hasValidWebhookSecret(request: NextRequest): boolean {
|
||||
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 false;
|
||||
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 false;
|
||||
if (!received) return Promise.resolve({ ok: false, via: "none" });
|
||||
|
||||
if (!fromHeader && fromQuery) {
|
||||
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 X-Withings-Webhook-Secret header",
|
||||
"withings webhook secret received via legacy URL query — migrate to /api/withings/webhook/[token]",
|
||||
);
|
||||
}
|
||||
|
||||
const a = Buffer.from(expected, "utf8");
|
||||
const b = Buffer.from(received, "utf8");
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Withings webhook notification endpoint.
|
||||
* Withings webhook notification endpoint (legacy form).
|
||||
* Withings sends a POST when new measurements are available.
|
||||
* The webhook sends: userid, startdate, enddate, appli
|
||||
* The webhook sends: userid, startdate, enddate, appli.
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "withings.webhook" } });
|
||||
annotate({ action: { name: "withings.webhook.legacy" } });
|
||||
|
||||
const ip = getClientIp(request);
|
||||
const rl = await checkRateLimit(`withings-webhook:${ip}`, 30, 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 (!hasValidWebhookSecret(request)) {
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
const unauth = await checkAndWarn(request);
|
||||
if (unauth) return unauth;
|
||||
|
||||
getEvent()?.setAuth({ auth_method: "webhook_secret" });
|
||||
|
||||
// Withings sends form-encoded or JSON depending on version
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
let withingsUserId: string | null = null;
|
||||
|
||||
if (contentType.includes("application/x-www-form-urlencoded")) {
|
||||
const formData = await request.formData();
|
||||
withingsUserId = formData.get("userid") as string;
|
||||
} else {
|
||||
const { data: body, error: jsonError } = await safeJson<{
|
||||
userid?: string | number;
|
||||
}>(request);
|
||||
if (jsonError) return jsonError;
|
||||
withingsUserId = body.userid?.toString() ?? null;
|
||||
}
|
||||
|
||||
if (!withingsUserId) {
|
||||
return NextResponse.json({ status: "ignored" }, { status: 200 });
|
||||
}
|
||||
|
||||
annotate({ meta: { withings_user_id: withingsUserId } });
|
||||
|
||||
// Find user by Withings user ID
|
||||
const connection = await prisma.withingsConnection.findFirst({
|
||||
where: { withingsUserId },
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
getEvent()?.addWarning(
|
||||
"Webhook for unknown withings user: " + withingsUserId,
|
||||
);
|
||||
return NextResponse.json({ status: "unknown_user" }, { status: 200 });
|
||||
}
|
||||
|
||||
// Sync measurements (non-blocking response for Withings)
|
||||
syncUserMeasurements(connection.userId).catch((err) => {
|
||||
getEvent()?.addWarning(
|
||||
"Sync failed for user " + connection.userId + ": " + err,
|
||||
);
|
||||
});
|
||||
|
||||
return NextResponse.json({ status: "ok" }, { status: 200 });
|
||||
return processWithingsNotification(request);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -112,8 +85,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
*/
|
||||
export const HEAD = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "withings.webhook.verify" } });
|
||||
|
||||
if (!hasValidWebhookSecret(request)) {
|
||||
const auth = await hasValidWebhookSecret(request);
|
||||
if (!auth.ok) {
|
||||
return new NextResponse(null, { status: 401 });
|
||||
}
|
||||
getEvent()?.setAuth({ auth_method: "webhook_secret" });
|
||||
@@ -122,8 +95,8 @@ export const HEAD = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "withings.webhook.verify" } });
|
||||
|
||||
if (!hasValidWebhookSecret(request)) {
|
||||
const auth = await hasValidWebhookSecret(request);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
getEvent()?.setAuth({ auth_method: "webhook_secret" });
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { getWithingsWebhookCallbackUrl } from "../sync";
|
||||
|
||||
/**
|
||||
* v1.4.25 W17a — Withings webhook callback URL must put the shared
|
||||
* secret in the PATH (not the `?secret=` query parameter) so that the
|
||||
* value never lands in a reverse-proxy `query_string` access-log
|
||||
* column. Withings preserves the full URL on every notification, so
|
||||
* the format chosen at subscribe time is what every inbound delivery
|
||||
* carries thereafter.
|
||||
*/
|
||||
describe("getWithingsWebhookCallbackUrl", () => {
|
||||
const ORIGINAL_BASE = process.env.NEXT_PUBLIC_APP_URL;
|
||||
const ORIGINAL_SECRET = process.env.WITHINGS_WEBHOOK_SECRET;
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_BASE === undefined) delete process.env.NEXT_PUBLIC_APP_URL;
|
||||
else process.env.NEXT_PUBLIC_APP_URL = ORIGINAL_BASE;
|
||||
if (ORIGINAL_SECRET === undefined)
|
||||
delete process.env.WITHINGS_WEBHOOK_SECRET;
|
||||
else process.env.WITHINGS_WEBHOOK_SECRET = ORIGINAL_SECRET;
|
||||
});
|
||||
|
||||
it("encodes the secret as the final path segment, not as ?secret=", () => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = "https://healthlog.example.com";
|
||||
process.env.WITHINGS_WEBHOOK_SECRET = "super-secret-value";
|
||||
|
||||
const url = getWithingsWebhookCallbackUrl();
|
||||
|
||||
expect(url).toBe(
|
||||
"https://healthlog.example.com/api/withings/webhook/super-secret-value",
|
||||
);
|
||||
expect(url).not.toContain("?secret=");
|
||||
expect(url).not.toContain("?");
|
||||
});
|
||||
|
||||
it("URL-encodes path-unsafe characters in the secret", () => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = "https://healthlog.example.com";
|
||||
process.env.WITHINGS_WEBHOOK_SECRET = "a/b c?d";
|
||||
|
||||
const url = getWithingsWebhookCallbackUrl();
|
||||
|
||||
// `/`, ` `, and `?` would all break the path semantics if injected
|
||||
// raw; encodeURIComponent yields `a%2Fb%20c%3Fd`.
|
||||
expect(url).toBe(
|
||||
"https://healthlog.example.com/api/withings/webhook/a%2Fb%20c%3Fd",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the bare legacy URL when WITHINGS_WEBHOOK_SECRET is unset", () => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = "https://healthlog.example.com";
|
||||
delete process.env.WITHINGS_WEBHOOK_SECRET;
|
||||
|
||||
const url = getWithingsWebhookCallbackUrl();
|
||||
|
||||
expect(url).toBe("https://healthlog.example.com/api/withings/webhook");
|
||||
});
|
||||
});
|
||||
@@ -19,14 +19,32 @@ import {
|
||||
recordSyncSuccess,
|
||||
} from "@/lib/integrations/status";
|
||||
|
||||
/**
|
||||
* Build the callback URL handed to Withings at `Notify.subscribe` time.
|
||||
*
|
||||
* v1.4.25 W17a moved the shared secret from `?secret=…` (query
|
||||
* parameter, captured by every reverse-proxy access log) to a path
|
||||
* segment (`/api/withings/webhook/<secret>`). Withings has no
|
||||
* mechanism for setting custom HTTP headers on outgoing notifications
|
||||
* and never signs the body, so the callback URL is the only
|
||||
* authenticity surface a subscriber controls — the path-segment form
|
||||
* is the largest practical shift away from the loggable query string.
|
||||
*
|
||||
* When `WITHINGS_WEBHOOK_SECRET` is unset (dev / new install before
|
||||
* provisioning) we fall back to the bare legacy URL so subscribe
|
||||
* doesn't 500 — the route handler will then reject every inbound
|
||||
* delivery with 401 anyway.
|
||||
*/
|
||||
export function getWithingsWebhookCallbackUrl(): string {
|
||||
const baseUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/withings/webhook`;
|
||||
const secret = process.env.WITHINGS_WEBHOOK_SECRET;
|
||||
if (!secret) return baseUrl;
|
||||
|
||||
const url = new URL(baseUrl);
|
||||
url.searchParams.set("secret", secret);
|
||||
return url.toString();
|
||||
// Path-segment form. Withings preserves the full callback URL on
|
||||
// every notification, so once a subscription is created with this
|
||||
// URL every delivery carries the secret in the path rather than the
|
||||
// query string. Encode for safety even though we expect a strong
|
||||
// random secret.
|
||||
return `${baseUrl}/${encodeURIComponent(secret)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Shared body processing for the Withings webhook endpoint family.
|
||||
*
|
||||
* Two routes share this logic:
|
||||
*
|
||||
* - `POST /api/withings/webhook` (legacy: secret via header or
|
||||
* `?secret=` query, kept alive
|
||||
* during the migration window)
|
||||
* - `POST /api/withings/webhook/[token]` (v1.4.25 W17a: secret as a
|
||||
* path segment so it never
|
||||
* reaches a reverse-proxy
|
||||
* `query_string` access-log
|
||||
* column nor the GlitchTip
|
||||
* URL/breadcrumb surface)
|
||||
*
|
||||
* Withings has no public mechanism for adding HTTP headers to outgoing
|
||||
* notifications and never signs the body — every `notify_subscribe`
|
||||
* call carries exactly six parameters (action, callbackurl, appli,
|
||||
* client_id, nonce, signature). The strongest authenticity surface a
|
||||
* subscriber controls is therefore the callback URL itself. Moving the
|
||||
* shared secret from `?secret=` (logged) to a path segment (also in the
|
||||
* URL, but never logged as a query parameter and uniformly redactable
|
||||
* by a single proxy rule) is the largest shift Withings supports
|
||||
* end-to-end.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { syncUserMeasurements } from "@/lib/withings/sync";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
|
||||
export type WithingsWebhookAuthOutcome =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: "missing_secret" | "mismatch" | "not_configured" };
|
||||
|
||||
/**
|
||||
* Constant-time comparison helper. Returns false unless both inputs have
|
||||
* the same byte length AND match exactly.
|
||||
*/
|
||||
export async function timingSafeStringEqual(
|
||||
expected: string,
|
||||
received: string,
|
||||
): Promise<boolean> {
|
||||
const { timingSafeEqual } = await import("node:crypto");
|
||||
const a = Buffer.from(expected, "utf8");
|
||||
const b = Buffer.from(received, "utf8");
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the rate-limit envelope every Withings webhook entrypoint
|
||||
* shares. Returns a `NextResponse` when the request must be rejected,
|
||||
* `null` when it should continue.
|
||||
*/
|
||||
export async function applyWebhookRateLimit(
|
||||
request: NextRequest,
|
||||
): Promise<NextResponse | null> {
|
||||
const ip = getClientIp(request);
|
||||
const rl = await checkRateLimit(`withings-webhook:${ip}`, 30, 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ status: "rate_limited" },
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Once the request is authorised, decode the body, look the user up,
|
||||
* and trigger a (non-blocking) sync. Returns the response Withings
|
||||
* should see — `200 ok` on success, `200 ignored` when the body has no
|
||||
* `userid`, `200 unknown_user` when the `userid` does not map to any
|
||||
* `WithingsConnection`. Withings retries on non-2xx, so we deliberately
|
||||
* return 200 even for "unrecognised user" so they don't queue retries
|
||||
* for a deleted account forever.
|
||||
*/
|
||||
export async function processWithingsNotification(
|
||||
request: NextRequest,
|
||||
): Promise<Response> {
|
||||
getEvent()?.setAuth({ auth_method: "webhook_secret" });
|
||||
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
let withingsUserId: string | null = null;
|
||||
|
||||
if (contentType.includes("application/x-www-form-urlencoded")) {
|
||||
const formData = await request.formData();
|
||||
withingsUserId = formData.get("userid") as string;
|
||||
} else {
|
||||
const { data: body, error: jsonError } = await safeJson<{
|
||||
userid?: string | number;
|
||||
}>(request);
|
||||
if (jsonError) return jsonError;
|
||||
withingsUserId = body.userid?.toString() ?? null;
|
||||
}
|
||||
|
||||
if (!withingsUserId) {
|
||||
return NextResponse.json({ status: "ignored" }, { status: 200 });
|
||||
}
|
||||
|
||||
annotate({ meta: { withings_user_id: withingsUserId } });
|
||||
|
||||
const connection = await prisma.withingsConnection.findFirst({
|
||||
where: { withingsUserId },
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
getEvent()?.addWarning(
|
||||
"Webhook for unknown withings user: " + withingsUserId,
|
||||
);
|
||||
return NextResponse.json({ status: "unknown_user" }, { status: 200 });
|
||||
}
|
||||
|
||||
syncUserMeasurements(connection.userId).catch((err) => {
|
||||
getEvent()?.addWarning(
|
||||
"Sync failed for user " + connection.userId + ": " + err,
|
||||
);
|
||||
});
|
||||
|
||||
return NextResponse.json({ status: "ok" }, { status: 200 });
|
||||
}
|
||||
Reference in new issue
Block a user