From 86157e235aeb0c087dfc46bb57e9f027ed5b51f2 Mon Sep 17 00:00:00 2001 From: zacchua Date: Fri, 2 Oct 2026 21:17:54 +0000 Subject: [PATCH 1/3] Reject blank webhook secrets Committed-By-Agent: goose Orbit-Session-Id: a8a3ff0b-2818-4677-9877-6974eda81782 --- stripe/_webhook.py | 2 +- tests/test_v2_event.py | 13 ++++++--- tests/test_webhook.py | 61 +++++++++++++++++++++++++++++++++++++----- 3 files changed, 66 insertions(+), 10 deletions(-) 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..a3373dc3a 100644 --- a/tests/test_v2_event.py +++ b/tests/test_v2_event.py @@ -21,7 +21,11 @@ ) 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 ( + BLANK_WEBHOOK_SECRETS, + BLANK_WEBHOOK_SECRET_IDS, + DUMMY_WEBHOOK_SECRET, +) EventParser = Callable[[str], ALL_EVENT_NOTIFICATIONS] @@ -237,7 +241,9 @@ def test_validates_signature( v2_payload_no_data, "bad header", DUMMY_WEBHOOK_SECRET ) - @pytest.mark.parametrize("secret", [None, ""]) + @pytest.mark.parametrize( + "secret", BLANK_WEBHOOK_SECRETS, ids=BLANK_WEBHOOK_SECRET_IDS + ) def test_rejects_missing_secret( self, stripe_client: StripeClient, @@ -252,7 +258,8 @@ 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 if secret is None else secret, ), secret, ) diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 08c9e20b7..bbf0978b0 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -23,6 +23,29 @@ DUMMY_WEBHOOK_SECRET = "whsec_test_secret" +BLANK_WEBHOOK_SECRETS = [ + None, + "", + " ", + "\t", + "\r", + "\n", + "\f", + "\v", + " \t\r\n\f\v", +] +BLANK_WEBHOOK_SECRET_IDS = [ + "none", + "empty", + "space", + "tab", + "carriage-return", + "line-feed", + "form-feed", + "vertical-tab", + "mixed", +] + def generate_header( payload=DUMMY_WEBHOOK_PAYLOAD, secret=DUMMY_WEBHOOK_SECRET, timestamp=None @@ -99,14 +122,19 @@ def test_construct_event_from_bytes(self): ) assert isinstance(event, stripe.Event) - @pytest.mark.parametrize("secret", [None, ""]) + @pytest.mark.parametrize( + "secret", BLANK_WEBHOOK_SECRETS, ids=BLANK_WEBHOOK_SECRET_IDS + ) def test_raise_on_missing_secret(self, secret): + header = generate_header( + secret=DUMMY_WEBHOOK_SECRET if secret is None else secret + ) 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 ) def test_raise_on_v2_payload(self): @@ -129,14 +157,19 @@ def test_raise_on_missing_header(self, header): DUMMY_WEBHOOK_PAYLOAD, header, DUMMY_WEBHOOK_SECRET ) - @pytest.mark.parametrize("secret", [None, ""]) + @pytest.mark.parametrize( + "secret", BLANK_WEBHOOK_SECRETS, ids=BLANK_WEBHOOK_SECRET_IDS + ) def test_raise_on_missing_secret(self, secret): + header = generate_header( + secret=DUMMY_WEBHOOK_SECRET if secret is None else 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 +232,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( @@ -274,14 +318,19 @@ def test_raise_on_v2_payload(self, stripe_mock_stripe_client): ) assert "parse_event_notification" in str(e.value) - @pytest.mark.parametrize("secret", [None, ""]) + @pytest.mark.parametrize( + "secret", BLANK_WEBHOOK_SECRETS, ids=BLANK_WEBHOOK_SECRET_IDS + ) def test_raise_on_missing_secret(self, stripe_mock_stripe_client, secret): + header = generate_header( + secret=DUMMY_WEBHOOK_SECRET if secret is None else 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): From ee0ee8320d03c7201fbd75d5845b5dce85f39e3a Mon Sep 17 00:00:00 2001 From: Zachary Chua Date: Fri, 2 Oct 2026 16:46:27 -0700 Subject: [PATCH 2/3] Add blank webhook secret changefile Committed-By-Agent: codex Co-authored-by: codex --- ...-10-02_zacchua_reject-blank-webhook-secrets.change.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .hark/changes/2026-10-02_zacchua_reject-blank-webhook-secrets.change.md 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. From be0fc72337cd67e730799f2441ac5786a040d239 Mon Sep 17 00:00:00 2001 From: Zachary Chua Date: Tue, 6 Oct 2026 11:13:10 -0700 Subject: [PATCH 3/3] Clarify missing webhook secret tests Committed-By-Agent: codex Co-authored-by: codex --- tests/test_v2_event.py | 29 +++++++++++--- tests/test_webhook.py | 88 +++++++++++++++++++++++------------------- 2 files changed, 72 insertions(+), 45 deletions(-) diff --git a/tests/test_v2_event.py b/tests/test_v2_event.py index a3373dc3a..11909fae9 100644 --- a/tests/test_v2_event.py +++ b/tests/test_v2_event.py @@ -22,8 +22,7 @@ from stripe.events._event_classes import ALL_EVENT_NOTIFICATIONS from stripe._webhook import WebhookPayload, WebhookSignature from tests.test_webhook import ( - BLANK_WEBHOOK_SECRETS, - BLANK_WEBHOOK_SECRET_IDS, + ASCII_WHITESPACE_WEBHOOK_SECRETS, DUMMY_WEBHOOK_SECRET, ) @@ -241,9 +240,7 @@ def test_validates_signature( v2_payload_no_data, "bad header", DUMMY_WEBHOOK_SECRET ) - @pytest.mark.parametrize( - "secret", BLANK_WEBHOOK_SECRETS, ids=BLANK_WEBHOOK_SECRET_IDS - ) + @pytest.mark.parametrize("secret", [None, ""]) def test_rejects_missing_secret( self, stripe_client: StripeClient, @@ -259,7 +256,27 @@ def test_rejects_missing_secret( v2_payload_no_data, WebhookSignature.generate_signature_header( v2_payload_no_data, - DUMMY_WEBHOOK_SECRET if secret is None else secret, + 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 bbf0978b0..2a884ef0a 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -23,27 +23,14 @@ DUMMY_WEBHOOK_SECRET = "whsec_test_secret" -BLANK_WEBHOOK_SECRETS = [ - None, - "", - " ", - "\t", - "\r", - "\n", - "\f", - "\v", - " \t\r\n\f\v", -] -BLANK_WEBHOOK_SECRET_IDS = [ - "none", - "empty", - "space", - "tab", - "carriage-return", - "line-feed", - "form-feed", - "vertical-tab", - "mixed", +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"), ] @@ -122,13 +109,20 @@ def test_construct_event_from_bytes(self): ) assert isinstance(event, stripe.Event) - @pytest.mark.parametrize( - "secret", BLANK_WEBHOOK_SECRETS, ids=BLANK_WEBHOOK_SECRET_IDS - ) + @pytest.mark.parametrize("secret", [None, ""]) def test_raise_on_missing_secret(self, secret): - header = generate_header( - secret=DUMMY_WEBHOOK_SECRET if secret is None else secret - ) + header = generate_header() + with pytest.raises( + SignatureVerificationError, + match="No webhook secret value was provided", + ): + stripe.Webhook.construct_event( + 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", @@ -157,13 +151,20 @@ def test_raise_on_missing_header(self, header): DUMMY_WEBHOOK_PAYLOAD, header, DUMMY_WEBHOOK_SECRET ) - @pytest.mark.parametrize( - "secret", BLANK_WEBHOOK_SECRETS, ids=BLANK_WEBHOOK_SECRET_IDS - ) + @pytest.mark.parametrize("secret", [None, ""]) def test_raise_on_missing_secret(self, secret): - header = generate_header( - secret=DUMMY_WEBHOOK_SECRET if secret is None else 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", @@ -318,13 +319,22 @@ def test_raise_on_v2_payload(self, stripe_mock_stripe_client): ) assert "parse_event_notification" in str(e.value) - @pytest.mark.parametrize( - "secret", BLANK_WEBHOOK_SECRETS, ids=BLANK_WEBHOOK_SECRET_IDS - ) + @pytest.mark.parametrize("secret", [None, ""]) def test_raise_on_missing_secret(self, stripe_mock_stripe_client, secret): - header = generate_header( - secret=DUMMY_WEBHOOK_SECRET if secret is None else 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",