Repository navigation
Conversation
ratomirjovanovic
left a comment
There was a problem hiding this comment.
This replaces === with crypto.timingSafeEqual in validateWebhookSignature, which is the right fix for #479. Two things need changing first. The new tests are nested inside an existing test, so Mocha never runs them. And decoding the incoming signature with Buffer.from(signature, 'hex') makes the check accept strings that the old === rejected. Also, #465, #469 and #473 are still open for the same issue, so the maintainers may want to settle on one of them.
| return expectedSignature === signature; | ||
| // Use constant-time comparison to prevent timing attacks (CWE-208). | ||
| const a = Buffer.from(expectedSignature, 'hex'); | ||
| const b = Buffer.from(signature, 'hex'); |
There was a problem hiding this comment.
Buffer.from(str, 'hex') is lenient. It stops at the first non-hex character and drops a trailing odd nibble, so with this change all of these now return true for a correct HMAC, where the old === returned false:
- the signature in upper case
- the signature followed by any non-hex text, e.g.
sig + "zz" - the signature followed by one extra hex character, e.g.
sig + "f"
None of these let anyone forge a signature, but a verification function shouldn't start accepting values it rejected before. Separately, isDefined() only rejects undefined, so a null signature now throws ERR_INVALID_ARG_TYPE from Buffer.from where it used to return false.
If you compare the hex strings as bytes, you keep the exact semantics of === and still get a constant-time comparison:
const a = Buffer.from(expectedSignature, 'utf8');
const b = Buffer.from(String(signature), 'utf8');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);Since validatePaymentVerification returns this function's result, the same change also covers payment signature checks. A test with correctSignature + "zz" and one with correctSignature.toUpperCase() would pin this behavior.
| 'Validates webhook signature' | ||
| ); | ||
|
|
||
| it('validateWebhookSignature uses timing-safe comparison', () => { |
There was a problem hiding this comment.
These two it(...) blocks are inside the callback of the existing it('validateWebhookSignature', ...), because that test's closing }); was moved down to line 136. Mocha doesn't run an it registered from inside a running test: it gets added to the root suite after that suite has already run, and nothing reports it. I checked this with mocha 9: an outer test containing two failing inner its passes, and the inner tests never show up in the spec reporter. So the 379 passing tests don't include either of the new ones.
Fix: close the existing test after its assert.ok(...) (line 107), then remove the extra }); at line 136. Also, the first new test is the same as the existing one with a different wrong signature, so it doesn't check that a constant-time comparison is used. You could drop it, or spy on crypto.timingSafeEqual and assert that it gets called.
The API accepts '0' and '1' as strings (as documented). Update the TypeScript typings to match the actual API behavior.
…idation [closes razorpay#479] Replace === with crypto.timingSafeEqual to prevent timing attacks (CWE-208). Reject non-hex signatures or mismatched lengths early.
|
Thanks for the update. The last commit (33c1dcb) still has unresolved merge conflict markers ( |
|
Thanks @ratomirjovanovic for the detailed review. Addressed all of your points:
Ready for re-review when you get a chance. |
|
Thanks, the conflict markers are gone and the lowercase hex and length checks look right. Two things are still open:
One smaller point: the new |
|
Thanks again for catching that. Moved the two timing-safe tests to top-level so Mocha runs them properly. |
|
Addressed the latest feedback:
Tests are top-level and the conflicts are resolved. Ready for re-review. |
ratomirjovanovic
left a comment
There was a problem hiding this comment.
Thanks, this looks right now. The two new tests run at the top level, the fail_existing change is gone, and comparing the hex strings as UTF-8 buffers after the length and lowercase-hex check keeps the old === semantics while making the comparison constant-time.
One small, non-blocking point on the tests: wrongSignature in the first new test is 61 characters, so the length check rejects it before timingSafeEqual runs. A wrong signature of the right length (for example the correct one with its last character changed) would exercise the comparison itself, and an upper-case copy of the correct signature would pin the case handling we discussed earlier.
Fixes #479
Problem
validateWebhookSignature()compares HMAC-SHA256 digests using===, which is not constant-time. String comparison short-circuits at the first mismatched character, allowing an attacker to recover the signature byte-by-byte via timing measurements (CWE-208).Root Cause
In
lib/utils/razorpay-utils.js, line 102:Solution
Replace with
crypto.timingSafeEqual()which performs constant-time comparison:Testing
Notes