Skip to content

Commit 3e44253

Browse files
committed
feat(errors): add SignatureVerificationError
Raised for webhook signature mismatches. Stores the received Shade-Signature header and, via the from_mismatch factory, produces a message explaining the likely causes (wrong secret or tampered payload) without ever exposing the expected HMAC value.
1 parent 0cf3f50 commit 3e44253

3 files changed

Lines changed: 75 additions & 0 deletions

File tree

src/shade/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
HTTPError,
1515
RateLimitError,
1616
ShadeError,
17+
SignatureVerificationError,
1718
)
1819

1920
__version__ = "0.1.0"
@@ -33,6 +34,7 @@
3334
"RateLimitError",
3435
"ShadeClient",
3536
"ShadeError",
37+
"SignatureVerificationError",
3638
"SyncHTTPClient",
3739
"config",
3840
"api_base",

src/shade/errors.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,40 @@ def from_response(
163163
return cls(message, status_code=404, response_body=response_body)
164164

165165

166+
class SignatureVerificationError(ShadeError):
167+
"""
168+
Raised by ``Webhook.construct_event()`` when the HMAC-SHA256 signature in
169+
the ``Shade-Signature`` header does not match the signature computed for
170+
the payload.
171+
172+
Attributes:
173+
header: The raw ``Shade-Signature`` header value as received.
174+
"""
175+
176+
def __init__(
177+
self,
178+
message: str,
179+
header: Optional[str] = None,
180+
) -> None:
181+
super().__init__(message)
182+
self.header = header
183+
184+
@classmethod
185+
def from_mismatch(cls, header: Optional[str] = None) -> "SignatureVerificationError":
186+
"""Construct the error for a computed/received signature mismatch.
187+
188+
The expected signature is deliberately not accepted as an argument
189+
here, so it can never end up in the exception message.
190+
"""
191+
message = (
192+
"Webhook signature verification failed: the received signature "
193+
"does not match the signature computed for this payload. This "
194+
"usually means the webhook secret is incorrect, or the payload "
195+
"was modified in transit."
196+
)
197+
return cls(message, header=header)
198+
199+
166200
class NetworkError(ShadeError):
167201
"""Raised when the SDK cannot complete a network request."""
168202

tests/test_errors.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
NotFoundError,
88
RateLimitError,
99
ShadeError,
10+
SignatureVerificationError,
1011
)
1112
from shade.errors import raise_for_invalid_request
1213

@@ -69,6 +70,7 @@ def test_package_root_exports_error_classes():
6970
assert shade.NetworkError is NetworkError
7071
assert shade.NotFoundError is NotFoundError
7172
assert shade.RateLimitError is RateLimitError
73+
assert shade.SignatureVerificationError is SignatureVerificationError
7274

7375

7476
def test_invalid_request_error_parses_param_from_body():
@@ -211,3 +213,40 @@ def test_not_found_error_invalid_json_body():
211213

212214
assert error.resource_type is None
213215
assert error.resource_id is None
216+
217+
218+
def test_signature_verification_error_is_shade_error():
219+
error = SignatureVerificationError("bad signature", header="t=1,v1=abc")
220+
221+
assert isinstance(error, ShadeError)
222+
assert str(error) == "bad signature"
223+
assert error.header == "t=1,v1=abc"
224+
225+
226+
def test_signature_verification_error_header_optional():
227+
error = SignatureVerificationError("bad signature")
228+
229+
assert error.header is None
230+
231+
232+
def test_signature_verification_error_from_mismatch_stores_header():
233+
error = SignatureVerificationError.from_mismatch(header="t=1,v1=deadbeef")
234+
235+
assert isinstance(error, ShadeError)
236+
assert error.header == "t=1,v1=deadbeef"
237+
238+
239+
def test_signature_verification_error_from_mismatch_explains_causes():
240+
error = SignatureVerificationError.from_mismatch(header="t=1,v1=deadbeef")
241+
242+
message = str(error)
243+
assert "secret" in message
244+
assert "payload" in message
245+
246+
247+
def test_signature_verification_error_never_includes_expected_signature():
248+
expected_signature = "supersecrethmacvalue"
249+
error = SignatureVerificationError.from_mismatch(header="t=1,v1=deadbeef")
250+
251+
assert expected_signature not in str(error)
252+
assert not hasattr(error, "expected_signature")

0 commit comments

Comments
 (0)