diff --git a/.hark/changes/2026-10-02_zacchua_reject-blank-webhook-secrets.change.md b/.hark/changes/2026-10-02_zacchua_reject-blank-webhook-secrets.change.md new file mode 100644 index 000000000..2f03b4b74 --- /dev/null +++ b/.hark/changes/2026-10-02_zacchua_reject-blank-webhook-secrets.change.md @@ -0,0 +1,9 @@ +--- +title: Reject ASCII-whitespace-only webhook secrets +pr_url: https://github.com/stripe/stripe-python/pull/1940 +semver_level: patch +jira_tickets_closed: +- RUN_DEVSDK-3378 +--- + +- Reject webhook signing secrets made entirely of ASCII whitespace instead of using them as HMAC keys. diff --git a/stripe/_webhook.py b/stripe/_webhook.py index 9fc269e69..a9a041454 100644 --- a/stripe/_webhook.py +++ b/stripe/_webhook.py @@ -150,7 +150,7 @@ def verify_header( header, payload, ) - if not secret: + if secret is None or secret.strip(" \t\r\n\f\v") == "": raise SignatureVerificationError( "No webhook secret value was provided. It should start with `whsec_`", header, diff --git a/tests/test_v2_event.py b/tests/test_v2_event.py index 54a1f2e2d..11909fae9 100644 --- a/tests/test_v2_event.py +++ b/tests/test_v2_event.py @@ -21,7 +21,10 @@ ) from stripe.events._event_classes import ALL_EVENT_NOTIFICATIONS from stripe._webhook import WebhookPayload, WebhookSignature -from tests.test_webhook import DUMMY_WEBHOOK_SECRET +from tests.test_webhook import ( + ASCII_WHITESPACE_WEBHOOK_SECRETS, + DUMMY_WEBHOOK_SECRET, +) EventParser = Callable[[str], ALL_EVENT_NOTIFICATIONS] @@ -252,7 +255,28 @@ def test_rejects_missing_secret( stripe_client.parse_event_notification( v2_payload_no_data, WebhookSignature.generate_signature_header( - v2_payload_no_data, DUMMY_WEBHOOK_SECRET + v2_payload_no_data, + DUMMY_WEBHOOK_SECRET, + ), + secret, + ) + + @pytest.mark.parametrize("secret", ASCII_WHITESPACE_WEBHOOK_SECRETS) + def test_rejects_ascii_whitespace_secret( + self, + stripe_client: StripeClient, + v2_payload_no_data: str, + secret: str, + ): + with pytest.raises( + SignatureVerificationError, + match="No webhook secret value was provided", + ): + stripe_client.parse_event_notification( + v2_payload_no_data, + WebhookSignature.generate_signature_header( + v2_payload_no_data, + secret, ), secret, ) diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 08c9e20b7..2a884ef0a 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -23,6 +23,16 @@ DUMMY_WEBHOOK_SECRET = "whsec_test_secret" +ASCII_WHITESPACE_WEBHOOK_SECRETS = [ + pytest.param(" ", id="space"), + pytest.param("\t", id="tab"), + pytest.param("\r", id="carriage-return"), + pytest.param("\n", id="line-feed"), + pytest.param("\f", id="form-feed"), + pytest.param("\v", id="vertical-tab"), + pytest.param(" \t\r\n\f\v", id="mixed"), +] + def generate_header( payload=DUMMY_WEBHOOK_PAYLOAD, secret=DUMMY_WEBHOOK_SECRET, timestamp=None @@ -101,12 +111,24 @@ def test_construct_event_from_bytes(self): @pytest.mark.parametrize("secret", [None, ""]) def test_raise_on_missing_secret(self, secret): + header = generate_header() with pytest.raises( SignatureVerificationError, match="No webhook secret value was provided", ): stripe.Webhook.construct_event( - DUMMY_WEBHOOK_PAYLOAD, generate_header(), secret + DUMMY_WEBHOOK_PAYLOAD, header, secret + ) + + @pytest.mark.parametrize("secret", ASCII_WHITESPACE_WEBHOOK_SECRETS) + def test_raise_on_ascii_whitespace_secret(self, secret): + header = generate_header(secret=secret) + with pytest.raises( + SignatureVerificationError, + match="No webhook secret value was provided", + ): + stripe.Webhook.construct_event( + DUMMY_WEBHOOK_PAYLOAD, header, secret ) def test_raise_on_v2_payload(self): @@ -131,12 +153,24 @@ def test_raise_on_missing_header(self, header): @pytest.mark.parametrize("secret", [None, ""]) def test_raise_on_missing_secret(self, secret): + header = generate_header() + with pytest.raises( + SignatureVerificationError, + match="No webhook secret value was provided", + ): + stripe.WebhookSignature.verify_header( + DUMMY_WEBHOOK_PAYLOAD, header, secret + ) + + @pytest.mark.parametrize("secret", ASCII_WHITESPACE_WEBHOOK_SECRETS) + def test_raise_on_ascii_whitespace_secret(self, secret): + header = generate_header(secret=secret) with pytest.raises( SignatureVerificationError, match="No webhook secret value was provided", ): stripe.WebhookSignature.verify_header( - DUMMY_WEBHOOK_PAYLOAD, generate_header(), secret + DUMMY_WEBHOOK_PAYLOAD, header, secret ) @pytest.mark.parametrize( @@ -199,6 +233,17 @@ def test_valid_header_and_signature(self): DUMMY_WEBHOOK_PAYLOAD, header, DUMMY_WEBHOOK_SECRET, tolerance=10 ) + @pytest.mark.parametrize( + "secret", + [" \twhsec_test_secret\r\n", "\u00a0"], + ids=["surrounding-whitespace", "non-ascii-whitespace"], + ) + def test_preserves_nonblank_secret(self, secret): + header = generate_header(secret=secret) + assert stripe.WebhookSignature.verify_header( + DUMMY_WEBHOOK_PAYLOAD, header, secret + ) + def test_header_contains_valid_signature(self): header = generate_header() + ",v1=bad_signature" assert stripe.WebhookSignature.verify_header( @@ -276,12 +321,26 @@ def test_raise_on_v2_payload(self, stripe_mock_stripe_client): @pytest.mark.parametrize("secret", [None, ""]) def test_raise_on_missing_secret(self, stripe_mock_stripe_client, secret): + header = generate_header() + with pytest.raises( + SignatureVerificationError, + match="No webhook secret value was provided", + ): + stripe_mock_stripe_client.construct_event( + DUMMY_WEBHOOK_PAYLOAD, header, secret + ) + + @pytest.mark.parametrize("secret", ASCII_WHITESPACE_WEBHOOK_SECRETS) + def test_raise_on_ascii_whitespace_secret( + self, stripe_mock_stripe_client, secret + ): + header = generate_header(secret=secret) with pytest.raises( SignatureVerificationError, match="No webhook secret value was provided", ): stripe_mock_stripe_client.construct_event( - DUMMY_WEBHOOK_PAYLOAD, generate_header(), secret + DUMMY_WEBHOOK_PAYLOAD, header, secret ) def test_construct_event_inherits_requestor(self, http_client_mock):