Repository navigation
Conversation
Converting the encoding argument of Hmac#digest can run a toString() that calls digest() on the same Hmac. The inner call frees the HMAC context. The outer call then had no context to finalize, and it encoded all 64 bytes of a stack buffer that nothing wrote. The encoded length now starts at zero. It takes the length that digestInto reports, and only after digestInto succeeds. The outer call returns an empty string or an empty Buffer, as Node does since nodejs/node#65112.
StatusClosed. Main has the fix from #47559 (bc55d56).
|
|
Updated 7:51 PM PT - Oct 26th, 2026
✅ @robobun, your commit 80031a19be1004359339e55585bbf9139c5d5183 passed in 🧪 To try this PR locally: bunx bun-pr 47026That installs a local version of the PR into your bun-47026 --bun |
|
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
Digest encoding and finalization src/jsc/bindings/node/crypto/JSHmac.cpp, test/js/node/crypto/crypto.hmac.test.ts |
The digest function records the written length and encodes from the saved buffer. Tests check nested finalization during encoding conversion, empty results from outer and subsequent digest calls, finalized-update errors, and digest behavior after stream completion. |
Suggested reviewers: alii, jarred-sumner
Priority:
Merge Risk: 
Minimal · previous review 7eb14
-
Reentrant digest calls return an empty result without exposing unwritten bytes. No merge-blocking risk was identified.
Pre-merge checks: 
4
-
Full details: Description check
-
Explanation
The description explains the problem, fix, scope, and verification results in detail. It does not use the template headings exactly, but it provides the required information through equivalent sections.
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
Comment @coderabbitai help to get the list of available commands.
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline findings, I also checked the sibling coerce-then-digest site in Hash#digest (src/jsc/bindings/node/crypto/JSHash.cpp) for the same uninitialized-bytes leak: an encoding hook that finalizes there leaves the digest cached in m_digest, so the outer call serves real bytes rather than a capacity-sized span. The digestInto failure path in the fixed function is unchanged (throws before mdLen is set).
Extended reasoning...
The diff is a small fix in jsHmacProtoFuncDigest that starts the encoded length at zero and sets it only after HMAC_Final succeeds, so an encoding coercion that re-enters digest() yields an empty result instead of 64 uninitialized stack bytes, plus a matrix regression test. It touches node:crypto native bindings, a security-sensitive surface (memory disclosure), which is why a human look remains worthwhile despite the fix itself being minimal and clearly correct. The sibling Hash#digest path was checked and does not share the defect.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🟣
src/jsc/bindings/node/crypto/JSHmac.cpp— Callers that pass a non-string encoding object to digest() on an already-finalized Hmac get an empty Buffer, where Node returns "". The finalized path at src/jsc/bindings/node/crypto/JSHmac.cpp:203 uses parseEnumeration, which returns nullopt for any non-string (src/jsc/bindings/JSBufferEncodingType.cpp:54), so line 206 falls through to the Buffer at 210. The PR fixes the non-finalized sibling of this exact function and its new test even calls digest(encodingArgument) on the finalized Hmac but discards the result. Fix: on the finalized path treat any truthy encoding that is not "buffer" as a string encoding, returning jsEmptyString, and assert that value in the matrix test.Why this was flagged
Trigger: any Hmac whose m_finalized is true (after digest(), end(), or a stream _flush) receives digest(x) where x is truthy and not a string, e.g. the same hook object the new matrix test passes at test/js/node/crypto/crypto.hmac.test.ts:487. JSHmac.cpp:203 calls parseEnumeration, which short-circuits to nullopt for non-strings at JSBufferEncodingType.cpp:54, so the
encoding.has_value()test at JSHmac.cpp:206 fails and line 210 returns an empty Buffer. Node's hash.js returns buf.toString(outputEncoding) on a zero-length buffer, which is "" for any truthy encoding. The base branch behaves the same, but the PR rewrites the non-finalized branch of the same function to match Node and the matrix test explicitly exercises this finalized path (line 487) while deliberately not asserting its return value, which is the pattern the review rules call routing a test around a runtime bug. Four finders raised it independently. Remedy: after parseEnumeration returns nullopt for a truthy non-string, return jsEmptyString, matching the base-branch comment's own intent and Node.Verification: The trigger is digest(x) on an Hmac whose m_finalized is already true, with x a truthy non-string. src/jsc/bindings/node/crypto/JSHmac.cpp:203 parseEnumeration returns nullopt for any non-string (src/jsc/bindings/JSBufferEncodingType.cpp:54-56), so line 210 returns an empty Buffer where Node returns "". The finalized branch is untouched by the diff, so the base already fails by the same route.
The 64 rows of the matrix ran inside one test, so the first failing row hid the other rows. The matrix is now a test.each table. Each row is a named test, and the row label in the assertions is gone.
|
Reply to this review. Result on an Hmac that is finalized at entry (
The cause is as the review says. The branch reads the argument with This PR does not change that branch. The PR removes the unwritten bytes on the non-finalized path. The finalized branch has a different defect: an empty Buffer in place of an empty string. The diff does not touch those lines, so I kept it out of this change. The description lists it under "Not changed here". No tracking issue exists for it yet. The script below and the cause above are enough to open one. If a maintainer wants the fix in this PR, I can add it. The branch needs the same test of the raw argument that Node has. The matrix calls Matrix shape. Done in 7eb146f. The matrix is a Scriptimport { createHmac } from "node:crypto";
const show = v => (typeof v === "string" ? JSON.stringify(v) : `Buffer(${v.length})`);
const finalized = () => {
const h = createHmac("sha256", "k").update("x");
h.digest();
return h;
};
console.log('digest() ', show(finalized().digest()));
console.log('digest("buffer") ', show(finalized().digest("buffer")));
console.log('digest("hex") ', show(finalized().digest("hex")));
console.log('digest("nonsense") ', show(finalized().digest("nonsense")));
console.log('digest("BUFFER") ', show(finalized().digest("BUFFER")));
console.log('digest(1) ', show(finalized().digest(1)));
console.log('digest({}) ', show(finalized().digest({})));
console.log('digest(Symbol()) ', show(finalized().digest(Symbol()))); |
There was a problem hiding this comment.
I re-reviewed after the second commit and found no bugs; the matrix now runs one test per row, which addresses my earlier comment. Because this is a native memory-disclosure fix in the node:crypto bindings, a human look is still worthwhile before merging.
What was reviewed:
- The new
mdLenflow injsHmacProtoFuncDigest:HMACCtxPointer::digestIntowritesbuf->lenonly on success, somdLenis 0 on the re-entrant/already-digested path and the failed-HMAC_Finalpath, and the encode span never covers unwrittenmdValuebytes. - Checked
jsHashProtoFuncDigestfor the same coerce-then-digest shape: it serves re-entrant calls from the cachedm_digest, so it is not affected. - Checked the test's "second
digest(encodingArgument)does not re-run the hook" assertion against the finalized branch:parseEnumeration<BufferEncodingType>returns nullopt for non-strings without coercing, so the count stays 1.
Extended reasoning...
The change touches two files: a ~10-line edit in src/jsc/bindings/node/crypto/JSHmac.cpp that initializes the encoded length to zero and sets it only after HMAC_Final succeeds, and a new 64-row test.each matrix plus a nested-conversion test in test/js/node/crypto/crypto.hmac.test.ts. It fixes an uninitialized-stack disclosure reachable when the encoding argument's toString/valueOf/Symbol.toPrimitive/Proxy trap re-enters digest() or end(), and cites the matching upstream Node change. The second commit converted the matrix from one looped test to test.each, resolving the only prior inline comment. No CODEOWNERS entry covers the changed paths and no findings were produced, but the change is a memory-safety fix inside the node:crypto native bindings, which is the deciding factor for deferring rather than approving outright.
|
Closing: #47559 landed this fix on main (bc55d56). It adds an I ran this PR's test file against main's sources. All 139 tests of Main has no test for this case. The 65 new tests are in this branch if a maintainer wants them as a test-only change. The difference on an Hmac that is finalized at entry is still on main. This comment has the repro and the cause. |
Problem
Hmac#digest(encoding)returns 64 bytes of uninitialized stack memory when thetoString()ofencodingcallsdigest()on the same Hmac.h.digest({ toString() { h.digest(); return "hex"; } })returns 128 hex characters that change per call.jsHmacProtoFuncDigest(src/jsc/bindings/node/crypto/JSHmac.cpp:226-247). The stack buffer length starts at its capacity, and onlyHMAC_Finallowers it. The innerdigest()frees the HMAC context, so the outer call skipsHMAC_Final.Fix
digestInto, theHMAC_Finalwrapper, succeeds.""or an empty Buffer, asdigest()does on a finalized Hmac. Node made the same change in crypto: prevent Hmac.digest() from returning uninitialized memory after stream use nodejs/node#65112.test/js/node/crypto/crypto.hmac.test.ts(65 new tests, all fail on a release build of main). Also the suites in Notes.Background
Hmac#digesttestsm_finalized, then converts a truthyencodingto a string. User code can run there, as in Node.m_finalizedtest after the conversion adds a branch per call. A conversion before the first test callstoString()on a finalized Hmac, unlike Node. A change inncryptois not reached on this path.Downsides
Notes
Repro.
128 chars: 9406dd82b90400000300000001000000,128 chars: 9406dd82b9040000030000000104000038fbcb094b99f9a73d862d0cb2b01f980 chars:twice38fbcb094b99f9a73d862d0cb2b01f98128 chars: a4e0587e587f0000f9e2fa33701b0000,128 chars: a4e0587e587f000039d6681bed3f000038fbcb094b99f9a73d862d0cb2b01f980 chars:twice38fbcb094b99f9a73d862d0cb2b01f98Measurements. The two release builds come from the same HEAD. The merge base build has the
JSHmac.cpphunk reverted in the working tree.Instructions that
jsHmacProtoFuncDigestexecutes per call. The count is from gdbnextiover the body of the function, with the callees stepped over. Two runs per binary gave the same counts.digest("hex")digest()digest({ toString })digest("hex")on a finalized HmacjsHmacProtoFuncDigest(nm -S): 1639 to 1636 bytes. One instruction differs:mov $0x40,%r14dbecomesxor %r14d,%r14d. It is in the block that runs only whenm_ctxis null.sizeof the stripped binary: text 107556528, data 116336, bss 1819272 in both builds. The file has 107730656 bytes in both builds.digest()call: 1.02 in both builds. The count is fromheapStats().objectTypeCountsover 20000 calls withBUN_JSC_useGC=0.JSHmac.cpp: 4 to 4. Prototype table rows: 2 to 2. Production files changed: 1.digest("hex")of the repro encodes: 64 to 0. Node v26.12.1: 0.BUN_JSC_validateExceptionChecks=1 bun bd test test/js/node/crypto/crypto.hmac.test.ts: 139 pass, 0 reports.Test matrix. The matrix has 64 rows, and
test.eachruns each row as one test. The axes: 2 digest algorithms, 4 kinds of conversion hook, 2 ways to finalize in the hook, 4 returned encodings. The 65th test is a nested conversion. Rows in which the outer call returns bytes:sizeof(mdValue): 64 of 64.Suites run on the debug build.
test/js/node/crypto/crypto.hmac.test.ts,crypto-lazyhash.test.ts,crypto-extra-memory.test.ts,crypto-invalid-this.test.ts,crypto-hmac-algorithm.test.ts, and fromtest/js/node/test/parallel/:test-crypto-hmac.js,test-crypto-hash.js,test-crypto-update-encoding.js,test-crypto-hash-stream-pipe.js,test-crypto-lazy-transform-writable.js,test-crypto-stream.js,test-crypto-classes.js.Failed
HMAC_Final. A faileddigestIntokeepsm_finalizedfalse, as before and as in Node.HMAC_Finaldoes not fail on an initialized context, so no test reaches that path. A laterdigest()on that path now encodes zero bytes.Scope. This PR changes
Hmac#digestonly. No other entry point encodes a capacity-sized length after a check that user code can invalidate..len = sizeof(...)occurs only in this function, anddigestIntohas one caller. The other entry points that run user code between a state check and the use of that state fail safe:Hash#digestwith the same hook returns the cached digest.Hash#copywith anoutputLengthgetter that callsdigest()throwsDigest copy error. For the hashers that do not useEVP_MD_CTXit returns a valid copy.Verify#verifywith apaddinggetter that callsverify()throwsFailed to finalize digest.Sign#signreads its context after all the option getters.Hmac#updateandHash#updateread the encoding only when it is a string.The self-review also asked for a public note and a tracking issue about code that this PR does not touch. I left that to the maintainers.
Not changed here.
digest(x)returns an empty Buffer whenxis truthy and not a known encoding string. Node returns"". A review comment on this PR has the repro and the cause.Hmacdigest has the same length guard, but it converts the encoding before them_finalizedtest. The new test counts the conversions and fails on that order.