fix: Separate an unreadable webhook payload from a forged one - #469
Merged
Conversation
SeamWebhook::verify raised Svix's WebhookVerificationException for a payload whose signature had already matched but whose body would not parse. The README maps that exception to a 400, so an unreadable delivery was answered with an error and Svix redelivered it on its full backoff schedule, while whoever read the logs saw a verification failure and went looking for a forgery. Raise InvalidWebhookPayloadError instead, a new SeamException, so the two cases can be answered differently: a signature that does not match may be forged, and a body that does not parse is genuinely from Seam and will not become readable however many times it arrives. Catch the parse failure explicitly rather than inferring it from a null event, and treat a correctly signed payload that is not an event the same way. It used to be returned as an Event with every field null, with nothing to tell the caller. Cast header names before lowercasing them, since an all-digit name arrives as an int key and would raise a TypeError. The parse path had no coverage at all. Add cases for malformed JSON, a non-object body, an empty body, and a signed non-event, along with the expired-timestamp and missing-header cases the suite was missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HH3wdHh4Y6Wjyc5uHwk5iG
razor-x
force-pushed
the
claude/php-audit-uoa7nb-m5-webhook-payload-error
branch
from
August 19, 2026 22:20
d5f1cdf to
43a0c6a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes finding M5 from the cross-SDK audit.
Renamed to
InvalidWebhookPayloadErrorper review, and rebased ontobetaat4ee0b79(4.0.0-beta.16).The problem
SeamWebhook::verifyraised Svix'sWebhookVerificationExceptionfor a payload whose signature had already matched:The message admits verification succeeded, but the exception type says otherwise, and the README maps that type to HTTP 400. So an unreadable delivery was answered with an error and Svix redelivered it across its full backoff schedule — a payload that will never parse, retried for hours — while whoever read the logs saw a verification failure and went looking for a forgery.
A correctly signed payload that parsed but wasn't an event was worse:
Event::from_jsonreturned an all-nullEventandverifyhanded it back with no signal at all.Neither path had any test coverage.
The fix
A new
Seam\InvalidWebhookPayloadError implements SeamExceptionfor "verified, but the body is not a readable event". Signature failures keep raisingWebhookVerificationException.The two cases now warrant different responses, and the README shows both:
Also in this PR:
json_last_error()rather than inferring it from a null event, so malformed JSON is distinguishable from a well-formed non-event.event_idas unreadable instead of returning an all-nullEvent.intkey and wouldTypeErrorinstrtolower.This also closes a hole in
SeamException's stated promise that one catch block covers everything the SDK raises — until now the webhook path could only throw an external Svix type.Tests
The suite had 4 tests covering the happy path and forgery. Now 13. Every new unreadable-payload case fails against
betain one of the two ways the audit described — three raiseWebhookVerificationException(parse failure read as forgery), two return an all-nullEventwith no exception:{not json,null, `` (empty),[1, 2], `{"hello":"world"}`Plus the expired-timestamp and missing-header cases the suite never had.
Full suite: 237 tests green, psalm clean,
npm run lintclean.Not in this PR
The audit also flags the svix constraint (
^1.40, locked at v1.99.1) as wide for a security-critical dependency, and the hard-coded 5-minute tolerance as unconfigurable. Both are dependency-policy calls rather than defects, so I left them alone — happy to tighten the constraint if you want it.Generated by Claude Code