diff --git a/spec/x-webhook.md b/spec/x-webhook.md index 8b901e5..033dd27 100644 --- a/spec/x-webhook.md +++ b/spec/x-webhook.md @@ -45,7 +45,7 @@ An `x-webhook` source fails startup when two routes share a public path, source The receiver handles two request forms on each exact path: -1. A CRC `GET` contains one bounded `crc_token` query value and no other query keys. The receiver returns JSON containing `response_token = sha256=`, computed over the token with the app consumer secret. The CRC path does not open Jazz. +1. A CRC `GET` contains one bounded `crc_token` query value and may contain one bounded `nonce` value. X's current registration service emits the nonce even though the public quickstart documents only `crc_token`; it is admitted solely as a cache-busting transport field and is not part of the HMAC. Any other key or duplicate fails closed. The receiver returns JSON containing `response_token = sha256=`, computed over the CRC token with the app consumer secret. The CRC path does not open Jazz. 2. An event `POST` requires JSON and one `x-twitter-webhooks-signature` header. The receiver buffers bounded raw bytes, computes Base64 HMAC-SHA256 over those exact bytes with the consumer secret, prepends `sha256=`, and compares the complete value in constant time before parsing JSON or opening Jazz. Wrong path, method, query shape, media type, length, signature multiplicity, or signature value fails before persistence. The receiver parses the common X Activity envelope, then requires an exact configured event type, filter user id, and subscription tag for the selected route. A valid X signature does not admit an undeclared subscription. diff --git a/src/connectors/x-webhook.ts b/src/connectors/x-webhook.ts index 037fabf..c303d10 100644 --- a/src/connectors/x-webhook.ts +++ b/src/connectors/x-webhook.ts @@ -22,6 +22,8 @@ export interface XCrcDiagnostic { queryKeys: string[]; tokenCount: number; tokenLength: number; + nonceCount: number; + nonceLength: number; } export interface XWebhookServerHandle { @@ -188,18 +190,24 @@ function handleCrc( ): void { const keys = [...url.searchParams.keys()]; const tokens = url.searchParams.getAll("crc_token"); + const nonces = url.searchParams.getAll("nonce"); const token = tokens[0] ?? ""; - const accepted = keys.length === 1 - && keys[0] === "crc_token" + const nonce = nonces[0] ?? ""; + const accepted = keys.length === 1 + nonces.length + && keys.every((key) => key === "crc_token" || key === "nonce") && tokens.length === 1 && Boolean(token) - && token.length <= 1_024; + && token.length <= 1_024 + && nonces.length <= 1 + && (nonces.length === 0 || (Boolean(nonce) && nonce.length <= 1_024)); try { onDiagnostic?.({ accepted, queryKeys: keys.slice(0, 10).map((key) => key.slice(0, 100)), tokenCount: tokens.length, tokenLength: token.length, + nonceCount: nonces.length, + nonceLength: nonce.length, }); } catch { // Observability must not alter X's challenge-response protocol. diff --git a/test/x-webhook.test.ts b/test/x-webhook.test.ts index 3eb7d13..7229cc9 100644 --- a/test/x-webhook.test.ts +++ b/test/x-webhook.test.ts @@ -65,12 +65,16 @@ describe("X Activity webhook", () => { expect(await response.json()).toEqual({ response_token: "sha256=iFUrz8TmBK5ze3siT/pRI6oBU8cd0cXwoCw+7/1/oMc=" }); expect(response.headers.get("cache-control")).toBe("no-store"); expect(append).not.toHaveBeenCalled(); + expect((await fetch(`${fixture.endpoint}?crc_token=one&nonce=cache-buster`)).status).toBe(200); expect((await fetch(`${fixture.endpoint}?crc_token=one&extra=two`)).status).toBe(400); expect((await fetch(`${fixture.endpoint}?crc_token=one&crc_token=two`)).status).toBe(400); + expect((await fetch(`${fixture.endpoint}?crc_token=one&nonce=first&nonce=second`)).status).toBe(400); expect(diagnostics).toEqual([ - { accepted: true, queryKeys: ["crc_token"], tokenCount: 1, tokenLength: 13 }, - { accepted: false, queryKeys: ["crc_token", "extra"], tokenCount: 1, tokenLength: 3 }, - { accepted: false, queryKeys: ["crc_token", "crc_token"], tokenCount: 2, tokenLength: 3 }, + { accepted: true, queryKeys: ["crc_token"], tokenCount: 1, tokenLength: 13, nonceCount: 0, nonceLength: 0 }, + { accepted: true, queryKeys: ["crc_token", "nonce"], tokenCount: 1, tokenLength: 3, nonceCount: 1, nonceLength: 12 }, + { accepted: false, queryKeys: ["crc_token", "extra"], tokenCount: 1, tokenLength: 3, nonceCount: 0, nonceLength: 0 }, + { accepted: false, queryKeys: ["crc_token", "crc_token"], tokenCount: 2, tokenLength: 3, nonceCount: 0, nonceLength: 0 }, + { accepted: false, queryKeys: ["crc_token", "nonce", "nonce"], tokenCount: 1, tokenLength: 3, nonceCount: 2, nonceLength: 5 }, ]); expect(JSON.stringify(diagnostics)).not.toContain("fixture-token"); await fixture.receiver.close();