Skip to content

fix(webhooks): export standalone signature verifier and stop it throwing on malformed input - #829

Open
woahwhattheheck wants to merge 4 commits into
Stellar-split:mainfrom
woahwhattheheck:fix/617-webhook-signature-verification
Open

fix(webhooks): export standalone signature verifier and stop it throwing on malformed input#829
woahwhattheheck wants to merge 4 commits into
Stellar-split:mainfrom
woahwhattheheck:fix/617-webhook-signature-verification

Conversation

@woahwhattheheck

Copy link
Copy Markdown

Closes #617.

Makes the standalone webhook signature verifier reachable from the package
root, adds WebhookVerificationError, and fixes a crash in the existing
verifier.

The bug

src/webhooks/verify.ts documents that a malformed signature returns false,
and #617 requires it to never throw. It currently throws TypeError for four
input classes, because the hex/length guard runs before the arguments ever
reach createHmac() / .update():

verifyWebhookSignature(body, sig, undefined) // TypeError: The "key" argument must be of type string...
verifyWebhookSignature(body, sig, null)      // TypeError
verifyWebhookSignature(body, sig, 12345)     // TypeError
verifyWebhookSignature(undefined, sig, secret) // TypeError: The "data" argument must be of type string...

This bites exactly where the helper is meant to be used. A webhook route
reading process.env.WEBHOOK_SECRET (undefined when the var is unset) or
req.body (undefined before a body parser runs) gets an unhandled TypeError
— a 500 — where the contract promises false and a clean 401.

Name collision, and how this PR avoids a breaking change

src/index.ts already exports a verifyWebhookSignature, but a different
one — the isomorphic verifier from ./webhookMiddleware.js:

export async function verifyWebhookSignature(
  payload: string | WebhookPayload, signature: string, secret: string,
): Promise<boolean>

It is async, accepts an object payload, and runs on WebCrypto with a Node
fallback. The helper in webhooks/verify.ts is synchronous, string-only and
Node-only. Re-exporting the latter under the same name would either be a
duplicate-export error or silently replace the former, which would break two
groups of existing callers: anyone passing a WebhookPayload object (the
sync path would throw inside createHmac().update()), and anyone running in a
browser or edge runtime (the sync path imports Node crypto).

So this PR leaves the existing verifyWebhookSignature export untouched and
adds the synchronous helper under a distinct name:

export {
  verifyWebhookSignature as verifyWebhookSignatureSync,
  assertWebhookSignature,
  WebhookVerificationError,
} from "./webhooks/verify.js";

Happy to rename or to consolidate the two verifiers instead if you'd prefer
one entry point — that is an API decision I did not want to make unilaterally
inside a bug fix.

Changes

  • src/webhooks/verify.ts
    • validate argument types before hashing, so the function is total and never
      throws; accepts string | Uint8Array for payload and secret
    • parameter order aligned to (payload, signature, secret), matching both
      the issue and the existing isomorphic verifier (this module had no
      importers, so the reorder is not observable to any caller)
    • add WebhookVerificationError, with Object.setPrototypeOf so
      instanceof survives an ES5 target
    • add assertWebhookSignature, the throwing wrapper
  • src/index.ts — re-export the three symbols above
  • test/webhooks.verify.test.ts — vitest coverage

Validation

  • Every acceptance criterion in Export verifyWebhookSignature as standalone function from webhooks/verify.ts #617: valid signature, wrong secret, tampered
    payload, mismatched length, non-hex, odd-length, empty, uppercase hex, and
    Buffer/Uint8Array inputs.
  • The four previously-throwing inputs now return false.
  • Differential check against the previous implementation over every input it
    survived: no behavioural divergence. The change only converts crashes
    into false; it does not alter any accept/reject decision.

verifyWebhookSignature threw TypeError for a non-string secret or payload
because the hex guard ran before createHmac()/update() received them, so a
webhook route reading an unset env var or an unparsed body got a 500 where
the documented contract promises false.

Guard argument types before hashing, align the parameter order with the
issue and with the isomorphic verifier, and add WebhookVerificationError
plus assertWebhookSignature for consumers who prefer exceptions.
Exports the synchronous verifier as verifyWebhookSignatureSync alongside
WebhookVerificationError and assertWebhookSignature. The existing async,
isomorphic verifyWebhookSignature export from ./webhookMiddleware.js is
left in place so no current consumer changes behaviour.
instanceof Uint8Array is realm-bound, so a Buffer created in another
realm - a worker thread, a vm context, or a test runner that isolates
modules - failed the check and verification returned false for a valid
signature. ArrayBuffer.isView tests the internal slot and is realm-safe.
@woahwhattheheck

Copy link
Copy Markdown
Author

Pushed 9fd26a6 and ran the project's own checks on this branch.

Tests: vitest run test/webhooks.verify.test.ts — 16/16 passing.

Typecheck: npm run lint (tsc --noEmit) reports 210 errors on this
branch and 210 errors on unmodified main
— identical. I ran the same job
against main with no changes to confirm the count is the pre-existing
baseline (missing exported members in ./types.js, ./priceOracle.js,
./soroban/footprint.js, and Object is possibly 'undefined' under
noUncheckedIndexedAccess). This PR adds none of them, and tsc reports zero
errors in src/webhooks/verify.ts. Nothing here is a prerequisite for this
change — flagging it only so the red lint isn't read as coming from this PR.

One fix from that run. The first CI run failed a case that passes locally:

verifyWebhookSignature(Buffer.from(payload), signature, Buffer.from(secret)) // false, expected true

I had guarded binary input with value instanceof Uint8Array. instanceof is
realm-bound, so a Buffer created in another realm — a worker thread, a vm
context, or a test runner that isolates modules — fails the prototype check
even though it is a valid input, and verification returned false for a good
signature. Switched to ArrayBuffer.isView(), which tests the internal slot
and is realm-safe. Worth having regardless of the test runner: it is the
difference between a webhook verifying correctly or not inside a worker.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Export verifyWebhookSignature as standalone function from webhooks/verify.ts

1 participant