Skip to content

fix(cookie-signature): compare full signature in unsign (GHSA-63x4-8g8c-vhww) - #520

Merged
v1rtl merged 1 commit into
masterfrom
fix/ghsa-63x4-8g8c-vhww-cookie-signature
Aug 10, 2026
Merged

fix(cookie-signature): compare full signature in unsign (GHSA-63x4-8g8c-vhww)#520
v1rtl merged 1 commit into
masterfrom
fix/ghsa-63x4-8g8c-vhww-cookie-signature

Conversation

@v1rtl

@v1rtl v1rtl commented Aug 10, 2026

Copy link
Copy Markdown
Member

Fixes the outstanding advisory GHSA-63x4-8g8c-vhww — the last one still lacking a code fix.

The bug

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:

const macBuffer = Buffer.from(mac)
const valBuffer = Buffer.alloc(macBuffer.length)  // sized to the expected mac
valBuffer.write(val)                              // anything longer is truncated

A valid cookie with arbitrary data appended therefore verified successfully:

const cookie = sign('mysessionid', 'secret')
unsign(`${cookie}tampered`, 'secret')  // 'mysessionid' — expected false

Reproduced against master before the fix. A wrong secret was still correctly rejected; it is specifically trailing-garbage tampering that passed.

The fix

unsign now 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 with val by construction — the old comparison spent most of its work checking str against 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 on slice(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:

payload whole-string signature-only
typical cookie 2189 ns/op 2103 ns/op (−3.9%)
2 KB 8449 ns/op 4441 ns/op (−47.4%)

For the record, a Buffer-free variant using TextEncoder/Uint8Array was measured and rejected: TextEncoder.encode carries a ~1.15µs fixed cost, ~18× slower than Buffer.from at cookie-sized strings, for no security gain (Buffer is a Uint8Array subclass; the bug was alloc+write truncation, which Buffer.from cannot express).

Compatibility

Values produced by sign verify 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/app as 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:

× should reject a valid cookie with appended data
  AssertionError: expected 'mysessionid' to be false

Full suite: 841 passed | 3 skipped.

🤖 Generated with Claude Code

…-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
v1rtl force-pushed the fix/ghsa-63x4-8g8c-vhww-cookie-signature branch from bbce45e to e46a5cc Compare August 10, 2026 10:46
@v1rtl
v1rtl merged commit 37d9961 into master Aug 10, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant