From 3faa04a80a512059d70c280fbc524d8947b721f1 Mon Sep 17 00:00:00 2001 From: Cameron Pfiffer Date: Mon, 10 Aug 2026 15:50:42 -0700 Subject: [PATCH] Accept X registration CRC nonces. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admit the provider-observed nonce as a bounded cache-busting field while keeping the CRC token as the sole HMAC input and rejecting every other query shape. 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- spec/x-webhook.md | 2 +- src/connectors/x-webhook.ts | 14 +++++++++++--- test/x-webhook.test.ts | 10 +++++++--- 3 files changed, 19 insertions(+), 7 deletions(-) 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(); -- 2.51.2