Files
HealthLog/src/lib/withings/__tests__/sync.test.ts
T
Marc-André Bombeck a1ffa497f1 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.
2026-05-14 17:46:25 +02:00

59 lines
2.2 KiB
TypeScript

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");
});
});