mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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.
59 lines
2.2 KiB
TypeScript
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");
|
|
});
|
|
});
|