fix(cookie-signature): compare full signature in unsign (GHSA-63x4-8g8c-vhww) - #520
Merged
Merged
Conversation
…-8g8c-vhww) `unsign` copied the untrusted value into a buffer allocated to the length of the expected MAC, so `Buffer.write` silently discarded everything past that length. Only a prefix of the value ever reached `timingSafeEqual`, and a valid cookie with arbitrary data appended verified successfully: const cookie = sign('mysessionid', 'secret') unsign(`${cookie}tampered`, 'secret') // 'mysessionid', expected false A wrong secret was still rejected correctly; it is specifically trailing garbage that passed. `unsign` now compares the signature alone. The payload is sliced out of the value being verified, so recomputing the whole signed string and comparing it against the whole cookie only ever compared the payload against itself; the signature is the untrusted part. An unpadded base64 SHA-256 digest is always 43 bytes, so the expected side is fixed width, and a signature of any other length is rejected outright instead of being truncated to fit. A value containing no `.` is handled explicitly rather than relying on `slice(0, -1)` producing a mismatching length, and a shared `hmac` helper means `unsign` no longer builds a signed string it discards. Signature-only comparison was differentially tested against whole-string comparison over 432 probes (empty, multibyte, 2 KB and dot-laden payloads, four secrets, six tampering shapes) with zero disagreements. It is also faster, since the redundant prefix encode scaled with payload size: -4% for a typical cookie, -47% for 2 KB. Values produced by `sign` verify exactly as before; only values that were never validly signed change from accepted to rejected. Adds a regression test for the reported PoC, plus coverage for truncated signatures, values with no `.` separator, and values containing dots. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v1rtl
force-pushed
the
fix/ghsa-63x4-8g8c-vhww-cookie-signature
branch
from
August 10, 2026 10:46
bbce45e to
e46a5cc
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the outstanding advisory GHSA-63x4-8g8c-vhww — the last one still lacking a code fix.
The bug
unsigncopied the untrusted value into a buffer allocated to the length of the expected MAC, soBuffer.writesilently discarded everything past that length. Only a prefix of the value ever reachedtimingSafeEqual:A valid cookie with arbitrary data appended therefore verified successfully:
Reproduced against
masterbefore the fix. A wrong secret was still correctly rejected; it is specifically trailing-garbage tampering that passed.The fix
unsignnow compares only the signature, rather than recomputing the whole signed string and comparing that against the whole cookie.The payload is sliced out of the value being verified, so the recomputed
`${str}.${sig}`shares its prefix withvalby construction — the old comparison spent most of its work checkingstragainst itself. Only the signature is untrusted, and an unpadded base64 SHA-256 digest is always exactly 43 bytes, so the expected side is fixed-width. A signature of any other length is rejected outright instead of being truncated to fit.A value containing no
.is now handled explicitly rather than relying onslice(0, -1)happening to produce a mismatching length.Equivalence and performance
Signature-only comparison was differentially tested against whole-string comparison over 432 probes (payloads including empty/multibyte/2 KB/dot-laden, four secrets, and six tampering shapes): zero disagreements.
It is also faster, since the redundant prefix encode scaled with payload size:
For the record, a
Buffer-free variant usingTextEncoder/Uint8Arraywas measured and rejected:TextEncoder.encodecarries a ~1.15µs fixed cost, ~18× slower thanBuffer.fromat cookie-sized strings, for no security gain (Bufferis aUint8Arraysubclass; the bug wasalloc+writetruncation, whichBuffer.fromcannot express).Compatibility
Values produced by
signverify exactly as before — the round trip is unchanged. Only values that were never validly signed change from accepted to rejected. No other package needed a source change; changesets will bump@tinyhttp/res/@tinyhttp/appas dependents.Tests
Added a regression test for the reporter's PoC, plus coverage for truncated signatures, values with no
.separator, and values containing dots.Verified the regression test actually guards the vuln — against the unfixed source it fails:
Full suite: 841 passed | 3 skipped.
🤖 Generated with Claude Code