Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion stripe/_webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 10 additions & 3 deletions tests/test_v2_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
Expand Down
61 changes: 55 additions & 6 deletions tests/test_webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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):
Expand Down
Loading