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..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):