Skip to content

fix(webhooks): reject decoded secrets below 16 bytes in verifySignature#1887

Open
ibondarenko1 wants to merge 1 commit into
openai:masterfrom
ibondarenko1:hardening/webhook-secret-min-length
Open

fix(webhooks): reject decoded secrets below 16 bytes in verifySignature#1887
ibondarenko1 wants to merge 1 commit into
openai:masterfrom
ibondarenko1:hardening/webhook-secret-min-length

Conversation

@ibondarenko1
Copy link
Copy Markdown

Problem

verifySignature at src/resources/webhooks/webhooks.ts:77-81 decodes the webhook secret two ways:

const decodedSecret =
  secret.startsWith('whsec_') ?
    Buffer.from(secret.replace('whsec_', ''), 'base64')
  : Buffer.from(secret, 'utf-8');

Buffer.from(str, 'base64') silently ignores characters outside the base64 alphabet. A misconfigured whsec_xyz! (operator typo, missing characters, or a non-base64 placeholder) decodes to a near-zero-byte buffer. The buffer is then passed to crypto.subtle.importKey as the HMAC-SHA256 key. A 1-byte key reduces the brute-force keyspace to 256 candidates; verification still completes and the SDK reports crypto.subtle.verify results as if the signature were validated correctly.

#validateSecret at line 119 only checks typeof secret === 'string' && secret.length > 0. It does not validate the post-decode buffer.

Change

After the decode at src/resources/webhooks/webhooks.ts:81, reject any decoded secret shorter than 16 bytes (128 bits). The error message points the consumer at the most likely cause: invalid base64 inside a whsec_ secret.

InvalidWebhookSignatureError is the existing error type used elsewhere in verifySignature for signature-validity failures, which keeps the error contract consistent.

One test in tests/api-resources/webhooks.test.ts previously passed a 3-byte secret (Buffer.from('foo').toString('base64') → 4 utf-8 bytes after the secret-decode fallback) to verify the "invalid signature" code path. The test now uses a 16+ byte garbage secret so it still exercises the signature-mismatch path rather than the new minimum-length check.

Impact

  • OpenAI-issued webhook secrets are 32 bytes (HMAC-SHA256 standard). Production consumers using the documented whsec_<base64> format are unaffected.
  • Consumers who accidentally configured a short or malformed secret get a clear error pointing at the misconfiguration, rather than silently accepting weak verification.
  • 16 bytes (128 bits) is the NIST SP 800-107 effective minimum for HMAC-SHA256. The same threshold is enforced by Stripe's webhook verifier and others.

Backwards compatibility

  • Public API unchanged.
  • Consumers using whsec_<valid-base64>: unaffected.
  • Consumers using raw UTF-8 strings of length 16 or more: unaffected.
  • Consumers using raw UTF-8 strings of length less than 16: now get an explicit error. This was already a misconfiguration; the change converts a silent acceptance into a visible one. The error message is descriptive enough for the consumer to fix.
  • Consumers using whsec_ followed by an invalid base64 fragment that decoded to less than 16 bytes: now get an explicit error.

Tests

  • yarn prettier --check src/resources/webhooks/webhooks.ts tests/api-resources/webhooks.test.ts: clean.
  • yarn eslint: clean on touched files.
  • yarn jest tests/api-resources/webhooks.test.ts: 14/14 pass, 12/12 snapshots match.

Why this is a hardening contribution

I was reading the webhook verifier flow and noticed that the Buffer.from(..., 'base64') silent-decode behavior could mask short-key configuration. The change converts a silent failure mode into an explicit one. Happy to revise the threshold (16 bytes is conservative; the actual OpenAI-issued length is 32 bytes) if you prefer to enforce the exact issued length instead.

Buffer.from(str, base64) silently ignores invalid characters, so a
misconfigured whsec_xyz! decodes to a near-zero-byte buffer that gets
passed to crypto.subtle.importKey as the HMAC key. OpenAI-issued webhook
secrets are 32 bytes; this check enforces the 16-byte (128-bit) minimum
effective HMAC-SHA256 key length per NIST SP 800-107.

The existing invalid-signature test used a 3-byte secret and asserted on
the signature-mismatch error path. Update it to use a 16+ byte garbage
secret so the test still exercises signature mismatch, which is its intent.
@ibondarenko1 ibondarenko1 requested a review from a team as a code owner May 18, 2026 07:27
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.

2 participants