Skip to content

crypto: compute X509Certificate validFromDate/validToDate from the ASN.1 time - #42363

Closed
robobun wants to merge 3 commits into
mainfrom
robobun/768e8b52/x509-validity-date-from-asn1-time
Closed

robobun wants to merge 3 commits into
mainfrom
robobun/768e8b52/x509-validity-date-from-asn1-time

Conversation

@robobun

@robobun robobun commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • X509Certificate validToDate / validFromDate move a validity year 0000-0099 to 1950-2049. A certificate that expired in the year 30 reports validToDate = 2030-12-31T23:59:59.000Z, so cert.validToDate > new Date() is true. Node v26.3.0 reports 0030-12-31T23:59:59.000Z.
  • The cause is src/jsc/bindings/JSX509CertificatePrototype.cpp:722-731 (validFromDate has the same code). The getter parses the validTo text (Dec 31 23:59:59 30 GMT) with WTF::parseDate, which reads the year 30 as 2030.
  • The same round trip gives Invalid Date for a UTCTime with a +hhmm offset (the text is Bad time value). Node returns the instant.

Fix

  • New ncrypto functions X509View::getValidFromTime / getValidToTime convert the ASN1_TIME to POSIX seconds. The getters build the Date from seconds * 1000, as Node does (crypto_x509.cc, ncrypto.cc).
  • The conversion is ASN1_TIME_to_posix_nonstandard. BoringSSL's X509 parser accepts the +hhmm form and the strict ASN1_TIME_to_posix refuses it. OpenSSL's ASN1_TIME_to_tm, which Node uses, applies the offset.
  • A failed conversion gives Invalid Date, as before.
  • Verified: test/js/node/crypto/x509.test.ts (new block: bun 1.4.3 fails 6 of 14, Node v26.3.0 passes the same assertions). Also test-crypto-x509.js, test-tls-getcertificate-x509.js, test/regression/issue/21274.test.ts.

Background

  • An X.509 validity time is an ASN1_TIME: a UTCTime (YYMMDDHHMMSSZ, years 1950-2049) or a GeneralizedTime (YYYYMMDDHHMMSSZ). RFC 5280 has no encoding for a year before 1950, but a GeneralizedTime year 0000-0099 parses in BoringSSL and in OpenSSL.
  • ASN1_TIME_print does not pad the year, so the year 30 prints as 30.
  • validFrom / validTo, toLegacyObject() and TLS verification do not use the Date getters. This change does not touch them.
Notes

Repro (mints a certificate that is valid from the year 20 to the year 30):

d=$(mktemp -d); cd "$d"
printf '[ca]\ndefault_ca=c\n[c]\ndir=.\ndatabase=./index.txt\nnew_certs_dir=./n\nserial=./serial\ndefault_md=sha256\npolicy=p\nunique_subject=no\n[p]\ncommonName=supplied\n[req]\ndistinguished_name=dn\nprompt=no\n[dn]\nCN=localhost\n' > ca.cnf
mkdir n; : > index.txt; echo 1000 > serial
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out key.pem
openssl req -new -key key.pem -config ca.cnf -out csr.pem
openssl ca -batch -config ca.cnf -selfsign -keyfile key.pem -in csr.pem -startdate 00200101000000Z -enddate 00301231235959Z -out c.pem -notext
cat > t.js <<'EOF'
const c = new (require("crypto").X509Certificate)(require("fs").readFileSync("c.pem"));
console.log(c.validTo, "|", c.validToDate.toISOString(), "|", c.validToDate > new Date());
EOF
bun t.js
node t.js
validTo validToDate validToDate > new Date()
bun 1.4.3 Dec 31 23:59:59 30 GMT 2030-12-31T23:59:59.000Z true
this branch Dec 31 23:59:59 30 GMT 0030-12-31T23:59:59.000Z false
node v26.3.0 Dec 31 23:59:59 30 GMT 0030-12-31T23:59:59.000Z false

The old parser moved the years 0-49 to 2000-2049 and the years 50-99 to 1950-1999. A year of 100 or later was already exact. The new test writes the years 0000, 0049, 0050, 0099, 0100, 1601, 1969, 1970, 2038, 2049, 2050 and 9999 over one GeneralizedTime template. The 1949 / 1950 / 2049 / 2050 UTCTime boundary is already in test-crypto-x509.js.

UTCTime offset. The test certificate has notBefore 200101000000-0530 and notAfter 301231235959+0100.

validFrom validFromDate validTo validToDate
bun 1.4.3 Bad time value Invalid Date Bad time value Invalid Date
this branch Bad time value 2020-01-01T05:30:00.000Z Bad time value 2030-12-31T22:59:59.000Z
node v26.3.0 Jan 1 05:30:00 2020 GMT 2020-01-01T05:30:00.000Z Dec 31 22:59:59 2030 GMT 2030-12-31T22:59:59.000Z

The validFrom / validTo strings for that form still differ from Node. BoringSSL's ASN1_TIME_print parses with allow_timezone_offset=0. That is a different getter with a different cause, and this PR leaves it alone.

Why a failed conversion is not reachable after a successful parse: x_x509.cc validates both times with allow_utc_timezone_offset=1, which is what ASN1_TIME_to_posix_nonstandard accepts. OPENSSL_tm_to_posix covers the years 0000-9999, which is all a GeneralizedTime can hold. The std::optional is there because Node's version ignores the return code and reads an uninitialized int64_t on failure.

The conversion does not touch the OpenSSL error queue, so the new functions have no ClearErrorOnReturn.

The old getters returned undefined when the printed text was empty. ASN1_TIME_print always prints something, so that branch was not reachable. Node always returns a Date.


[human-review] gate passed · iteration 1 · 4 files touched

fails on main (without fix)
ASAN without fix: 6 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/node/crypto/x509.test.ts"
bun test v1.4.3 (4ff919377)

test/js/node/crypto/x509.test.ts:
(pass) X509Certificate.checkHost() > "sub.wildcard.example.com" returns the subjectAltName entry that matched [4.25ms]
(pass) X509Certificate.checkHost() > "SUB.WILDCARD.EXAMPLE.COM" returns the subjectAltName entry that matched [0.71ms]
(pass) X509Certificate.checkHost() > "exact.example.com" returns the subjectAltName entry that matched [0.42ms]
(pass) X509Certificate.checkHost() > "EXACT.EXAMPLE.COM" returns the subjectAltName entry that matched [0.39ms]
(pass) X509Certificate.checkHost() > "a.b.wildcard.example.com" does not match [2.27ms]
(pass) X509Certificate.checkHost() > "wildcard.example.com" does not match [0.60ms]
(pass) X509Certificate.checkHost() > "wildcard-san.example.com" does not match [0.35ms]
(pass) X509Certificate.checkHost() > "nomatch.example.org" does not match [0.33ms]
(pass) X509Certificate.checkHost() > wildcards: false only disables the wildcard entry [5.42ms]
(pass) X509Certificate.checkHost() > "agent1" falls b
... (truncated)

release without fix: all passed
bun test v1.4.3-canary.1 (b1cb69ec6)

test/js/node/crypto/x509.test.ts:
(pass) X509Certificate.checkHost() > "sub.wildcard.example.com" returns the subjectAltName entry that matched [0.09ms]
(pass) X509Certificate.checkHost() > "SUB.WILDCARD.EXAMPLE.COM" returns the subjectAltName entry that matched [0.01ms]
(pass) X509Certificate.checkHost() > "exact.example.com" returns the subjectAltName entry that matched
(pass) X509Certificate.checkHost() > "EXACT.EXAMPLE.COM" returns the subjectAltName entry that matched
(pass) X509Certificate.checkHost() > "a.b.wildcard.example.com" does not match [0.03ms]
(pass) X509Certificate.checkHost() > "wildcard.example.com" does not match
(pass) X509Certificate.checkHost() > "wildcard-san.example.com" does not match
(pass) X509Certificate.checkHost() > "nomatch.example.org" does not match
(pass) X509Certificate.checkHost() > wildcards: false only disables the wildcard entry [0.04ms]
(pass) X509Certificate.checkHost() > "agent1" falls back to the subject CN and returns it [0.02ms]
(pass) X509Certificate.checkHost() > "AGENT1" falls back to the subject CN and returns it
(pass) X509Certificate.checkHost() > "AgEnT1" falls back to the sub
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/node/crypto/x509.test.ts"
bun test v1.4.3 (4ff919377)

test/js/node/crypto/x509.test.ts:
(pass) X509Certificate.checkHost() > "sub.wildcard.example.com" returns the subjectAltName entry that matched [3.35ms]
(pass) X509Certificate.checkHost() > "SUB.WILDCARD.EXAMPLE.COM" returns the subjectAltName entry that matched [0.67ms]
(pass) X509Certificate.checkHost() > "exact.example.com" returns the subjectAltName entry that matched [0.34ms]
(pass) X509Certificate.checkHost() > "EXACT.EXAMPLE.COM" returns the subjectAltName entry that matched [0.33ms]
(pass) X509Certificate.checkHost() > "a.b.wildcard.example.com" does not match [2.62ms]
(pass) X509Certificate.checkHost() > "wildcard.example.com" does not match [0.52ms]
(pass) X509Certificate.checkHost() > "wildcard-san.example.com" does not match [0.32ms]
(pass) X509Certificate.checkHost() > "nomatch.example.org" does not match [0.31ms]
(pass) X509Certificate.checkHost() > wildcards: false only disables the wildcard entry [5.66ms]
(pass) X509Certificate.checkHost() > "agent1" falls b
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 764ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/16] cxx obj/unified/UnifiedSource-src_jsc_bindings-2.cpp.o
[2/16] cxx obj/unified/UnifiedSource-src_jsc_bindings-1.cpp.o
[3/16] cxx obj/src/jsc/bindings/webcore/SerializedScriptValue.cpp.o
[4/16] cxx obj/src/jsc/bindings/webcrypto/SubtleCrypto.cpp.o
[5/16] cxx obj/src/jsc/bindings/BunProcess.cpp.o
[6/16] cxx obj/unified/UnifiedSource-src_jsc_modules-0.cpp.o
[7/16] cxx obj/unified/UnifiedSource-src_jsc_bindings_node_crypto-0.cpp.o
[8/16] cxx obj/src/jsc/bindings/ZigGlobalObject.cpp.o
[9/16] cxx obj/unified/UnifiedSource-src_jsc_bindings-5.cpp.o
[10/16] cxx obj/unified/UnifiedSource-src_jsc_bindings_node_crypto-1.cpp.o
[11/16] cxx obj/unified/UnifiedSource-src_jsc_bindings-3.cpp.o
[12/16] gen cpp.rs (cppbind)
[12/16] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_runtime v0.0.0 (/workspace/bun/src/runtime)
�[1m�[92m    Finished�[0m `release` profile [optimized + debuginfo] target(s) in 6m 21s
[13/16] link bun-profile
[15/16] strip bun
[15/16] bun-profile --revision
1.4.3-canary.1+13f
... (truncated)
diff hotspot
src/jsc/bindings/JSX509CertificatePrototype.cpp | 29 +++--------
 src/jsc/bindings/ncrypto.cpp                    | 20 +++++++
 src/jsc/bindings/ncrypto.h                      |  3 ++
 test/js/node/crypto/x509.test.ts                | 69 +++++++++++++++++++++++++
 4 files changed, 100 insertions(+), 21 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                             reads  edits  tests
src/jsc/bindings/JSX509CertificatePrototype.cpp      3      4      9
src/jsc/bindings/ncrypto.cpp                         3      3      9
src/jsc/bindings/ncrypto.h                           1      2      9
test/js/node/crypto/x509.test.ts                     1      1      9

…he ASN.1 time

The two getters printed the time as text ("Dec 31 23:59:59 30 GMT") and
parsed that text back with WTF::parseDate. The two-digit-year rule of the
parser moved the years 0000-0099 to 1950-2049, so a certificate that
expired in the year 30 reported validToDate = 2030-12-31.

Convert the ASN1_TIME to POSIX seconds with BoringSSL and build the Date
from that, as Node does (ncrypto X509View::getValidFromTime /
getValidToTime). ASN1_TIME_to_posix_nonstandard also applies a "+hhmm"
offset on a UTCTime. The X509 parser accepts that form and the printed
text for it is "Bad time value", so the getters returned Invalid Date
where Node returns the instant.
@coderabbitai

coderabbitai Bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 9 days. After that, they cost $0.25 per reviewed file.

Or wait 43 seconds for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: b2d9d1f6-71e1-499c-84fd-581b8796b354

📥 Commits

Reviewing files that changed from the base of the PR and between 158ff6c and 13fbb75.

📒 Files selected for processing (4)
  • src/jsc/bindings/JSX509CertificatePrototype.cpp
  • src/jsc/bindings/ncrypto.cpp
  • src/jsc/bindings/ncrypto.h
  • test/js/node/crypto/x509.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:16 PM PT - Sep 11th, 2026

✅ @robobun, your commit 13fbb75898b2e36e0d032368a2a6b1e7c7b5a138 passed in Build #114406! 🎉


🧪   To try this PR locally:

bunx bun-pr 42363

That installs a local version of the PR into your bun-42363 executable, so you can run:

bun-42363 --bun

@robobun

robobun commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Closed as superseded. #47820 (commit 5e08ec1) landed the same change on main: validFromDate / validToDate come from X509View::getValidFromTime() / getValidToTime().

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — replaces the lossy text round-trip with a direct ASN.1→POSIX conversion, matching Node's implementation.

What was reviewed:

  • The two getters after simplification: scope is still used by throwThisTypeError, and DateInstance::create cannot throw, so no missing exception check.
  • int64_t * 1000.0 range: year 0000 (~-6.2e13 ms) through 9999 (~2.5e14 ms) are exact in a double.
  • asn1TimeToPosix null-checks both cert_ and the ASN1_TIME*; the _nonstandard variant is the right choice given BoringSSL's X509 parser accepts the +hhmm UTCTime form.
  • Test fixture: certForYear overwrites the 4-byte year inside a fixed-length GeneralizedTime, so DER lengths stay valid; boundary years and the UTCTime-offset case are covered.
Extended reasoning...

Overview

This PR fixes X509Certificate.prototype.validFromDate / validToDate in src/jsc/bindings/JSX509CertificatePrototype.cpp. The old implementation printed the certificate's ASN1_TIME to a human-readable string via validFrom/validTo, then re-parsed it with WTF::parseDate. Because ASN1_TIME_print renders the year 30 as the two-character string 30, the date parser interpreted it as 2030, so a certificate that expired in year 0030 reported validToDate > new Date() as true. The fix adds X509View::getValidFromTime() / getValidToTime() in ncrypto.{h,cpp} that call BoringSSL's ASN1_TIME_to_posix_nonstandard to get seconds-since-epoch directly, and a small validityDate helper that constructs the DateInstance from seconds * 1000.0 (or PNaN → Invalid Date on failure). Tests are added to the existing test/js/node/crypto/x509.test.ts covering GeneralizedTime years across the full 0000–9999 range plus the UTCTime +hhmm offset case.

Security risks

The changed getters are read-only accessors on an already-parsed certificate and are not part of the TLS verification path (which the PR notes explicitly). The behavior change is strictly in the correct direction: a certificate that expired ~2000 years ago no longer reports as valid to callers doing validToDate > new Date(). There is no new parsing of untrusted bytes here — ASN1_TIME_to_posix_nonstandard operates on an ASN1_TIME that BoringSSL's X509 parser already validated, and the helper null-checks both cert_ and the returned ASN1_TIME*. No new allocation, no error-queue interaction, no user-JS re-entry.

Level of scrutiny

Moderate. It touches crypto-adjacent C++ bindings, but the change is a net simplification: ~20 lines of string-roundtrip code deleted per getter, replaced by a one-line call to a BoringSSL primitive, mirroring Node's crypto_x509.cc approach that the PR cites. The remaining ThrowScope in each getter is still used by the this-type error path, and the new tail call cannot throw, so exception-check discipline is intact. Both sibling getters are fixed symmetrically per the "fix the whole class" rule. CODEOWNERS only covers *.d.ts, so none of these files are owned.

Other factors

The tests follow repo conventions: added to the existing module test file, test.each for the year matrix, .toEqual on a combined object for the primary regression assertion, no sleeps/network/timeouts. The certForYear fixture technique (overwrite the 4 ASCII year bytes inside a fixed-length GeneralizedTime in the DER) keeps every DER length byte valid and lets one template cover twelve boundary years. The PR description shows the tests fail on bun 1.4.3 and pass on Node v26.3.0, satisfying the USE_SYSTEM_BUN=1 requirement. The bug hunt ran to a dry streak with no findings and no candidate concerns to rule out. No prior reviewer objections exist on the timeline.

Comment thread src/jsc/bindings/ncrypto.cpp Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — focused fix that mirrors Node's ASN1_TIME → POSIX-seconds path and removes the lossy string round-trip.

What was reviewed:

  • validityDate() and asn1TimeToPosix(): nullopt → PNaN → Invalid Date; int64_t seconds * 1000.0 stays exact for years 0000-9999; seconds is only read on the success return.
  • ThrowScope hygiene after the string calls were dropped: DateInstance::create doesn't throw, so the remaining scope is only for the bad-receiver path — no missing RETURN_IF_EXCEPTION.
  • Confirmed wtf/DateMath.h has no remaining users in the file; both sibling getters go through the same helper.
  • Tests: hermetic, in the existing x509 file, test.each over the 0000/49/50/99/100/1601/1969/1970/2038/2049/2050/9999 boundaries plus the UTCTime +hhmm offset case; the DER-rewrite fixture keeps lengths intact.
Extended reasoning...

Overview

The PR replaces the validFromDate/validToDate implementation in src/jsc/bindings/JSX509CertificatePrototype.cpp, which previously formatted the ASN.1 time to text via BIO and re-parsed it with WTF::parseDate. That round-trip mapped two-digit printed years into 1950-2049, so a GeneralizedTime year of 0030 came back as 2030. The new code adds X509View::getValidFromTime()/getValidToTime() in ncrypto.{h,cpp} returning std::optional<int64_t> POSIX seconds via BoringSSL's ASN1_TIME_to_posix_nonstandard, and a small validityDate() helper builds a DateInstance from seconds * 1000.0 (or PNaN on nullopt). The now-unused wtf/DateMath.h include is dropped. Tests are added to the existing test/js/node/crypto/x509.test.ts.

Security risks

This is crypto-adjacent but read-only and informational: these getters are not on Bun's TLS certificate-verification path (that runs inside BoringSSL). The behavioral change is strictly in the safe direction — a certificate that expired in year 30 previously reported validToDate in 2030 (so a naive cert.validToDate > new Date() check passed); it now correctly reports 0030. The _nonstandard variant is chosen deliberately, with a comment, to match what Node/OpenSSL do for the RFC-5280-forbidden UTCTime +hhmm offset that BoringSSL's X509 parser nonetheless accepts. No new allocation, no error-queue interaction, and the uninitialized-read hazard in Node's equivalent (which ignores the return code) is avoided by the std::optional.

Level of scrutiny

Medium. The production change is ~25 lines across three files, mechanically mirrors Node's crypto_x509.cc/ncrypto.cc reference implementation (cited in the PR), and both sibling getters are fixed symmetrically through one shared helper per REVIEW.md's dedup rule. I checked that the removed JS-throwing calls (validTo(), ->view()) leave no dangling RETURN_IF_EXCEPTION obligations — DateInstance::create and the BoringSSL calls do not throw JS exceptions, so the remaining ThrowScope is used only for throwThisTypeError. The int64_t → double widening keeps millisecond precision exact across the entire 0000-9999 GeneralizedTime range (well under 2^53).

Other factors

Test coverage is thorough and follows repo conventions: added to the existing module test file, describe/test.each over twelve boundary years including the old 0-49/50-99 windowing edges and the 1970/2038 epoch boundaries, a combined-object .toEqual assertion, and a separate UTCTime-offset fixture. The gate evidence in the PR shows 6 failures on main and all-pass on the branch under both debug+ASAN and release. No CODEOWNERS cover the changed paths. A prior COMMENTED review and github-actions formatting comments were followed by two commits; the current diff is clean.

Jarred-Sumner pushed a commit that referenced this pull request Sep 15, 2026
… UTF-8 (#42798)

### Problem
- A non-ASCII string of 2^30 Latin-1 characters or more aborts the
process: `panic(main thread): abort() called`, exit 134, also inside
`try` / `catch`. Each API that reads it through `Bun::UTF8View` does
this, for example `x509.checkHost("\u00e9".repeat(2 ** 30))`. Fuzzing
found it, with no user report.
- `UTF8View` (`src/jsc/bindings/BunString.h:18`) called `utf8()`, which
is `tryGetUTF8()` plus `RELEASE_ASSERT`. It had no failure channel.

### Fix
- `UTF8View` has no public constructor. `UTF8View::tryCreate()` returns
`std::nullopt` when `tryGetUTF8()` fails, and an overload throws
`RangeError: Out of memory`, as #42202 and #42308 do.
- An 8-bit ASCII string is still borrowed, never copied. Each user
passes a pointer and a length. A `const char*` consumer always needs a
copy, and that case is not in this PR (Notes).
- These throw now: `new X509Certificate(string)`, `hkdf` / `hkdfSync`
(`salt`, `info`), `bun:sqlite` SQL text and string parameters, `new
Bun.CookieMap(string)`. `checkHost`, `checkEmail`,
`require.resolve.paths` and `Module._resolveLookupPaths` only look the
string up, so they answer "no match". Removed `keyFromString`,
`passphraseFromBufferSource`, `keyFromPublicString` (no caller).
- Verified: `test/js/bun/util/utf8-conversion-limit.test.ts`. The
unfixed build aborts at the first case. One row shows that an ASCII
string is not copied.

### Background
- A JSC string is Latin-1 (8-bit) or UTF-16, with no NUL terminator.
`UTF8View` borrows an 8-bit all-ASCII string and converts any other
string with WTF.
- WTF reserves 2 bytes per Latin-1 character and a buffer holds at most
2^31 - 1 bytes, so 2^30 Latin-1 characters fail. A 16-bit string fails
at a UTF-8 form of 2^31 bytes.
- JSC throws `RangeError: Out of memory` for a string that it cannot
create.

<details><summary>Notes</summary>

**Repro, on release 1.4.3 canary (09bb546, linux x64).** Each line
aborts on its own with exit 134, at 1.1 GB RSS in about 0.6 s.

```js
const crypto = require("node:crypto");
const { Database } = require("bun:sqlite");
const long = "\u00e9".repeat(2 ** 30); // Latin-1, not ASCII
const x509 = new crypto.X509Certificate(pem);
const db = new Database(":memory:");

x509.checkHost(long);
x509.checkEmail(long);
new crypto.X509Certificate(long);
crypto.hkdfSync("sha256", "key", long, "info", 8); // salt
crypto.hkdfSync("sha256", "key", "salt", long, 8); // info
crypto.hkdf("sha256", "key", long, "info", 8, () => {});
require.resolve.paths(long);
require("node:module")._resolveLookupPaths(long);
db.run(long);
db.prepare(long);
db.prepare("SELECT length(?)").get(long);
new Bun.CookieMap("a=%41" + long); // the value is converted only when the header has a "%"
```

An ASCII string of the same length is clean in all of them, because
`UTF8View` borrows it. A 16-bit string aborts too: 715,827,883 x U+0800
has a UTF-8 form of 2^31 + 1 bytes. With this PR all of these cases
throw or answer "no match" for that string as well. One character below
the limit (2^30 - 1 x U+00E9) still converts: `checkHost` returns
`undefined` after 8 s on release.

**Every `UTF8View` user, and what it does when the conversion fails**

| User | Now |
| --- | --- |
| `new X509Certificate(string)` (`JSX509Certificate.cpp`) | throws |
| `X509Certificate#checkHost`, `#checkEmail`
(`JSX509CertificatePrototype.cpp`) | return `undefined` |
| `hkdf`, `hkdfSync`: `salt`, `info` (`CryptoHkdf.cpp`) | throws |
| `Database#run`, `#exec`, `#prepare`, `#query`: SQL text
(`JSSQLStatement.cpp`) | throws |
| `bun:sqlite` string parameter (`JSSQLStatement.cpp` `rebindValue`) |
throws |
| `new Bun.CookieMap(string)`, `request.cookies` (`CookieMap.cpp`) |
`Exception { OutOfMemoryError }`, which both callers throw |
| `require.resolve.paths` (`JSCommonJSModule.cpp`),
`Module._resolveLookupPaths` (`NodeModuleModule.cpp`) | the request is
not a builtin, continue |
| `decodeURIComponentSIMD` (`bun:internal-for-testing`) | throws |
| `X509Certificate#validToDate`, `#validFromDate` | return `undefined`.
The string is the 24 characters that `ASN1_TIME_print` writes, so this
cannot happen. #42363 removes both uses. |
| native `WebSocket` `onMessage` (`WebSocket.cpp`, used by the Chrome
backend of `Bun.WebView`) | drops the message. The client fails a
message longer than `MAX_RECEIVE_MESSAGE_LENGTH` first (128 MiB,
`src/http_jsc/websocket_client.rs:57`, checked at `:813`). A compressed
message has the same bound (`MAX_DECOMPRESSED_SIZE`,
`websocket_client/WebSocketDeflate.rs:64`). So this cannot happen. |
| replay of queued Chrome commands over the pipe (`ChromeBackend.cpp`) |
rejects every pending promise of the transport |
| `keyFromString`, `passphraseFromBufferSource`, `keyFromPublicString` |
removed, no caller |

Two of these did not use `UTF8View` before. The `bun:sqlite` parameter
binding had its own copy of the same dispatch (borrow ASCII, else
`utf8()`). `Module._resolveLookupPaths` is the twin of
`require.resolve.paths` and called `utf8()` for the same builtin check.

**Not in this PR.** These call `String::utf8()` or `StringView::utf8()`
directly, not `UTF8View`. They abort for a string of 2^30 characters,
ASCII included.

| Parameter | Site | Owner |
| --- | --- | --- |
| `X509Certificate#checkIP(ip)` | `jsX509CertificateProtoFuncCheckIP` |
#42780 (throws `ERR_INVALID_ARG_VALUE`, as for the same string one
character shorter) |
| `hkdf(digest)` and the other digest, cipher and curve names |
`ncrypto.cpp` | #42780 |
| `hkdf(ikm)` as a string of 2^31 UTF-8 bytes | `CryptoHkdf.cpp`
`prepareKey` | #42187 |
| `process.execve` | `BunProcess.cpp` | #42308 |
| `Set-Cookie` header write | `CookieMap.cpp:20` | #42237 |
| `bun:sqlite` `new Database(filename)`, `serialize(name)`,
`loadExtension(path, entryPoint)`, `fileControl(name)`,
`setCustomSQLite(path)` | `JSSQLStatement.cpp:1208`, `1377`, `1428`,
`1430`, `1803`, `1955` | follow-up, with the 20 sites of `node:sqlite`
(`NodeSqlite.cpp`) |
| `Bun.Cookie` `expires` string | `Cookie.cpp:124`, `JSCookie.cpp:62` |
follow-up |
| `WebSocket` close reason, protocols | `WebSocket.cpp` | follow-up |
| Chrome backend pipe command | `ChromeBackend.h:211` | follow-up |

Most of them pass a `const char*` to a C API (`sqlite3_open_v2`,
`sqlite3_serialize`, `sqlite3_load_extension`, `X509_check_ip_asc`,
`EVP_get_digestbyname`). A WTF string has no NUL terminator
(`StringImpl::allocationSize` is `tailOffset + length * sizeof(T)`), so
that case always needs a copy, and the borrowed branch of `UTF8View`
cannot serve it. It stays on `tryGetUTF8()`. `src/jsc` and `src/runtime`
have about 100 `utf8()` call sites. Each needs a look: some pass a
pointer and a length and can move to `UTF8View`, which also saves the
copy of an ASCII string. That is a separate change.

**Related open PRs**
- #42187 edits `copyBufferOrString` in `CryptoHkdf.cpp` and keeps the
`UTF8View utf8(view);` line. The PR that merges second takes the
`tryCreate` form, a 3 line rebase.
- #42363 rewrites `validToDate` / `validFromDate` and drops their
`UTF8View`.
- #33312 adds 7 new `Bun::UTF8View(...)` constructor calls (clipboard).
After this PR they do not compile until they use `tryCreate`. That is
the purpose of the private constructor: a new user cannot skip the
failure case. An unchecked `tryCreate(s)->span()` still compiles.

**Test.** The length is what is under test, so the child holds a string
of 1 GiB, and a second one while the cookie header is joined (2.5 GB
peak). One child runs the 14 cases and takes 6 to 10 s in a debug ASAN
build. About 5 s of that is the last row, where the unoptimized build
scans 1 GiB for a non-ASCII byte. The test skips below 8 GiB of total
memory and carries a 30 s ceiling, like
`error-message-string-length-limit.test.ts`. A second test runs the same
APIs with `"café"` and `"café 😀"` to check that a string that converts
still gives the same result.

**The ASCII row.** `tryGetUTF8()` refuses every 8-bit string of 2^30
characters before it reads one (`StringImpl.h`
`tryGetUTF8ForCharacters`:
`isValidCapacityForVector<char8_t>(characters.size() * 2)`). So a
`UTF8View` that copied an ASCII string of that length reports `Out of
memory`. The row binds `"q".repeat(2 ** 30)` as a `bun:sqlite`
parameter. The bundled SQLite answers `string or blob too big` (its
limit is 1e9 bytes). The system SQLite that macOS loads takes up to 2
GiB and returns the length. The row accepts these two answers and no
other: each shows that SQLite got the string's buffer. The same
mechanism is why `Module._resolveLookupPaths("q".repeat(2 ** 30))`
aborts on `main`: it called `request.utf8()` to pass a pointer and a
length. It borrows now.

**Also ran (debug ASAN build):** `x509.test.ts`,
`x509-subclass.test.ts`, the vendored `test-crypto-x509.js`,
`test-crypto-hkdf.js`, `test-crypto-sign-verify.js`,
`test-crypto-key-objects.js`, `test-require-resolve.js`,
`hkdf-callback-null.test.ts`, `hkdf-zero-length.test.ts`,
`sqlite.test.js`, the four cookie suites,
`decodeURIComponentSIMD.test.ts`, `node-module-module.test.js`,
`resolve.test.ts`, `webview-chrome-pipe.test.ts`,
`webview-chrome-disconnect.test.ts`. The child script also ran with
`BUN_JSC_validateExceptionChecks=1`, with no report. In
`sqlite.test.js`, `#13082` times out on this machine's debug build
(about 6 s against the 5 s limit), with and without this change.

**Self-review: 6 concerns raised, 4 addressed.**
- Addressed: `checkHost` / `checkEmail` threw. They return `undefined`
now. The rule that they and `checkIP` in #42780 follow: a call reports
what it reports for the same string one character shorter. That string
is a name that matches no certificate.
- Addressed: the same 4 line throw block appeared 7 times. It is the
`tryCreate(globalObject, scope, view)` overload now.
- Addressed: the `bun:sqlite` parameter binding still aborted, through
its own copy of the dispatch.
- Addressed: the description named API surfaces as done. It names
parameters now, and the table above lists what is left, with an owner
for each row.
- Rejected: convert the six other `utf8()` calls of `JSSQLStatement.cpp`
here, through a new `Bun::tryUTF8`. They are direct `utf8()` callers
that need a NUL-terminated copy. They belong with the `node:sqlite`
sites in one change for that mechanism.
- Rejected: bound the conversion by `Bun::maxVectorSize` so that a test
can reach the failure with a small string. It would test a branch that
production never takes, and a crash fix needs the crashing input as its
fixture.

**Review.** The review asked that the two cases stay apart: a `const
char*` consumer (always a copy) and a pointer plus length consumer
(borrow an ASCII string). `UTF8View` is the second case only, before and
after this PR. Since the review: the comment on `UTF8View` says so, the
fields are `m_borrowed` and `m_converted`, and the ASCII row above
guards the borrow.

</details>

<!-- robobun:evidence:begin -->

---

**[human-review]** gate passed · iteration 0 · 16 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/bun/util/utf8-conversion-limit.test.ts"
bun test v1.4.3 (09bb546)

test/js/bun/util/utf8-conversion-limit.test.ts:
69 |       env: bunEnv,
70 |       stdout: "pipe",
71 |       stderr: "pipe",
72 |     });
73 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
74 |     expect({ stdout: stdout.trim().split("\n"), stderr, exitCode }).toEqual({
                                                                         ^
error: expect(received).toEqual(expected)

  {
-   "exitCode": 0,
-   "stderr": "",
+   "exitCode": 134,
+   "stderr": 
+ "ASSERTION FAILED: expectedString
+ vendor/WebKit/Source/WTF/wtf/text/StringView.cpp(121) : CString WTF::StringView::utf8(ConversionMode) const
+ no stacktrace available"
+ ,
    "stdout": [
-     "X509Certificate#checkHost: returned undefined",
-     "X509Certificate#checkEmail: returned undefined",
-     "new X509Certificate: RangeError: Out of memory",
-     "hkdfSync salt: RangeError: Out of memory",
-     "hkdfSync info: RangeEr
... (truncated)

release without fix: 1 FAILED
bun test v1.4.3-canary.1 (09bb546)

test/js/bun/util/utf8-conversion-limit.test.ts:
69 |       env: bunEnv,
70 |       stdout: "pipe",
71 |       stderr: "pipe",
72 |     });
73 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
74 |     expect({ stdout: stdout.trim().split("\n"), stderr, exitCode }).toEqual({
                                                                         ^
error: expect(received).toEqual(expected)

  {
-   "exitCode": 0,
-   "stderr": "",
+   "exitCode": 134,
+   "stderr": 
+ "============================================================
+ Bun Canary v1.4.3-canary.1 (09bb546) Linux x64
+ Linux Kernel v7.0.0 | glibc v2.41
+ CPU: sse42 popcnt avx avx2 avx512
+ Args: "/workspace/bun/build/release/bun" "-e" "\n  import { decodeURIComponentSIMD } from \"bun:internal-for-testing\";\n  import { Database } from \"bun:sqlite\";\n  import crypto from \"node:crypto\";\n  import Module"...
+ Features: Bun.stderr(2) bunfig jsc tsconfig 
+ Builtins: "bun:internal-for-testing" "bun:main" "bun:sqlite" "node:crypto" "node:module" 
+ 
+ Elapsed: 740ms | User: 198ms | Sys: 543ms
+ RSS: 1.12 
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/bun/util/utf8-conversion-limit.test.ts"
bun test v1.4.3 (09bb546)

test/js/bun/util/utf8-conversion-limit.test.ts:
(pass) a string whose UTF-8 form does not fit in a buffer is an error instead of an abort [3129.31ms]
(pass) a short Latin-1 string converts as before [70.38ms]
(pass) a short 16-bit string converts as before [10.22ms]

 3 pass
 0 fail
 3 expect() calls
Ran 3 tests across 1 file. [5.80s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     40af339
  features     lto, baseline

23 deps, 131 codegen, 1176 objects in 698ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1254] mkdir codegen
[2/1254] mkdir stamps
[3/1254] mkdir pch
[4/1254] mkdir obj
[5/1254] install /workspace/bun
bun install v1.4.3-canary.1 (09bb546)

Checked 22 installs across 61 packages (no changes) [11.00ms]
[6/1254] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (09bb546)

Checked 1 install across 2 packages (no changes) [4.00ms]
[7/1254] gen ErrorCode+*.h
[8/1254] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (09bb546)

Checked 111 installs across 104 packages (no changes) [6.00ms]
[9/1254] gen .bind.ts → GeneratedBindings.cpp
[10/1254] fetch picohttpparser
[picohttpparser] up to date
[11/1254] fetch zlib
[zlib] up to date
[12/1254] gen bindgenv2
[13/1254] gen node-fallbacks/react-refresh.js
Bundled 1 module in 5ms

  react-refresh.js  4.81 KB  (entry point)

[14/1254] fetch libjpeg
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/jsc/bindings/BunString.cpp                  |   8 ++
 src/jsc/bindings/BunString.h                    |  52 ++++-----
 src/jsc/bindings/CookieMap.cpp                  |   6 +-
 src/jsc/bindings/JSCommonJSModule.cpp           |   6 +-
 src/jsc/bindings/JSX509Certificate.cpp          |   9 +-
 src/jsc/bindings/JSX509CertificatePrototype.cpp |  26 +++--
 src/jsc/bindings/decodeURIComponentSIMD.cpp     |   5 +-
 src/jsc/bindings/node/crypto/CryptoHkdf.cpp     |   5 +-
 src/jsc/bindings/node/crypto/CryptoUtil.cpp     |  76 --------------
 src/jsc/bindings/node/crypto/CryptoUtil.h       |   2 -
 src/jsc/bindings/node/crypto/JSVerify.cpp       |  36 -------
 src/jsc/bindings/sqlite/JSSQLStatement.cpp      |  22 ++--
 src/jsc/bindings/webcore/WebSocket.cpp          |   5 +-
 src/jsc/modules/NodeModuleModule.cpp            |  10 +-
 src/runtime/webview/ChromeBackend.cpp           |   8 +-
 test/js/bun/util/utf8-conversion-limit.test.ts  | 134 ++++++++++++++++++++++++
 16 files changed, 233 insertions(+), 177 deletions(-)
```

</details>

**gate history** · 1 passed · 0 rejected · iteration 0

<details><summary>evidence per changed file</summary>

```
file                                             reads  edits  tests
src/jsc/bindings/BunString.cpp                       0      0     13
src/jsc/bindings/BunString.h                         3      2     13
src/jsc/bindings/CookieMap.cpp                       0      0     13
src/jsc/bindings/JSCommonJSModule.cpp                0      0     13
src/jsc/bindings/JSX509Certificate.cpp               0      0     13
src/jsc/bindings/JSX509CertificatePrototype.cpp      0      0     13
src/jsc/bindings/decodeURIComponentSIMD.cpp          0      0     13
src/jsc/bindings/node/crypto/CryptoHkdf.cpp          0      0     13
src/jsc/bindings/node/crypto/CryptoUtil.cpp          0      0     13
src/jsc/bindings/node/crypto/CryptoUtil.h            0      0     13
src/jsc/bindings/node/crypto/JSVerify.cpp            0      0     13
src/jsc/bindings/sqlite/JSSQLStatement.cpp           0      0     13
src/jsc/bindings/webcore/WebSocket.cpp               0      0     13
src/jsc/modules/NodeModuleModule.cpp                 0      0     13
src/runtime/webview/ChromeBackend.cpp                0      0     13
test/js/bun/util/utf8-conversion-limit.test.ts       1      1     13
```

</details>

<!-- robobun:evidence:end -->
@robobun

robobun commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: main already has this change. #47820 (commit 5e08ec1) builds validFromDate / validToDate from the ASN.1 time through the same X509View::getValidFromTime() / getValidToTime() design, and test/js/node/crypto/x509.test.ts on main covers the years 0 to 99.

I ran the test block of this PR against a debug build of main (cdfa19b). 13 of the 14 cases pass. The one case that fails is the UTCTime with a timezone offset (200101000000-0530 / 301231235959+0100), which is Invalid Date on main. #42415 covers that case with the same input. With its ncrypto.cpp change applied, all 14 cases pass.

@robobun robobun closed this Nov 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants