Compare the webhook signature in constant time - #838
Conversation
construct_event verifies the HMAC with `signature != digest`. Python's `!=` on str short-circuits at the first differing byte, so how long the comparison takes depends on how much of the signature is correct. `speech_engine/resource.py` already uses hmac.compare_digest for the same job, so this is the odd one out rather than a new convention. Severity is low: exploiting a remote timing side channel on an HMAC compare is hard, and nothing here is exposed to a third party. It is one line, and the library already does it the other way elsewhere. Compare bytes rather than str. hmac.compare_digest raises TypeError on str arguments containing non-ASCII, and the signature comes from an attacker-controlled header, so the obvious str version turns a bad header into an unhandled crash. Added a test for that; it fails against the str version and passes against both this and the current code. Also added coverage for AsyncWebhooksClient.construct_event, a separate copy of the same method that had none.
|
Not a blocker — this is right, and encoding both sides before One adjacent case in the same function, if you would rather have it here than in a follow-up: the timestamp a few lines above is parsed unguarded, so an attacker-controlled In a typical webhook handler that surfaces as a 500 rather than a 400. The TS SDK does not behave this way: # Validate timestamp
- req_timestamp = int(timestamp) * 1000
+ try:
+ req_timestamp = int(timestamp) * 1000
+ except ValueError:
+ raise BadRequestError(body=BadRequestErrorBody(error="Invalid timestamp in signature header"))
tolerance = int(time.time() * 1000) - 30 * 60 * 1000(twice, once per client, like your change) and a test in the shape of the ones you added: def test_construct_event_non_numeric_timestamp():
"""A non-numeric t= must be rejected as a bad request, not raise ValueError.
The signature header is attacker-controlled, so a malformed timestamp has to
surface as the documented BadRequestError rather than an unhandled exception.
"""
client = ElevenLabs()
webhook_secret = "test_secret"
body = json.dumps({"event_type": "speech.completed", "id": "123456"})
sig_header = f"t=not-a-number,v0={'0' * 64}"
with pytest.raises(BadRequestError) as excinfo:
client.webhooks.construct_event(body, sig_header, webhook_secret)
assert "Invalid timestamp" in str(excinfo.value)With the patch applied the webhook suite passes with the two new cases (sync and async) and no changes to the existing seven. Happy to open it separately once this lands if you would rather keep this PR to one thing. |
construct_event parses the t= value from the signature header with a bare
int(), so an attacker-controlled non-numeric timestamp raises ValueError
instead of the BadRequestError the docstring promises. In a webhook handler
that surfaces as a 500 rather than a 400.
>>> client.webhooks.construct_event(body, f"t=abc,v0={'0' * 64}", secret)
ValueError: invalid literal for int() with base 10: 'abc'
Every other failure path in the same function raises BadRequestError. The JS
SDK does not diverge this way: Number('abc') is NaN, NaN < tolerance is
false, so it falls through to the signature comparison and returns the
normal error.
Guarded in both the sync and async copies, with a test for each.
Reported by @maksym-t-didww in review on elevenlabs#838.
|
Good catch, thanks. Folded it into this PR since it is the same function and a second one would just mean reviewing construct_event twice. Confirmed your read both ways: t=abc raises ValueError where every other failure path raises BadRequestError, and the JS SDK does not diverge (Number('abc') is NaN, NaN < tolerance is false, so it falls through to the signature compare). The parse now rejects a non-numeric timestamp as a bad request, guarded in the sync and async copies with a test each. Credited you in the commit. |
construct_eventcompares the webhook signature with!=, which returns as soon as two bytes differ. That leaks how long a shared prefix is, so an attacker who can send requests and time the response can recover a valid signature a character at a time without ever knowing the secret. Both the sync and async copies do it.This swaps in
hmac.compare_digestat both sites. Two lines.No behaviour changes, and that is worth saying plainly, because it means no test can show you the original problem. Accepting and rejecting the same signatures before and after is the whole point. So the test I added guards the other thing, which is the obvious wrong way to make this change.
hmac.compare_digestraisesTypeErroronstrarguments that contain non-ASCII. The signature arrives in an attacker-controlled header, socompare_digest(signature, digest)on the raw strings turns a header ofv0=caféinto an unhandledTypeErrorinstead of a clean rejection. Encoding both sides to bytes first avoids it.test_construct_event_non_ascii_signature_is_rejected_not_crashfails against that naive version and passes here. It is a real control.The second test covers
AsyncElevenLabs.webhooks.construct_event, which carries its own copy of this logic and had no coverage at all.Suite is 9 passed, up from 7.
Severity is low and I would not want it overstated. It needs an attacker who can send many webhook deliveries and time the responses across a network, which is noisy enough to make the attack awkward in practice. It is still the standard fix and it costs nothing.
I have not measured the timing difference against your service, so this rests on the usual argument for constant-time comparison.
Note
Medium Risk
Touches webhook signature verification on attacker-controlled headers; changes are standard hardening but affect security-sensitive request handling.
Overview
Hardens
construct_eventon both sync and async webhook clients so untrusted signature headers fail predictably and signatures are compared in constant time.Signature verification now uses
hmac.compare_digeston UTF-8 bytes instead of!=on strings, closing a timing side channel and avoidingTypeErrorwhen thev0=value contains non-ASCII. Malformedt=values are caught and returned asBadRequestError(“Invalid timestamp…”) instead of an unhandledValueError.Tests cover non-ASCII signatures, non-numeric timestamps (sync and async), and basic async
construct_eventparity with sync.Reviewed by Cursor Bugbot for commit ea069b3. Bugbot is set up for automated code reviews on this repo. Configure here.