Skip to content

crypto: implement OpenSSL-compatible X509Certificate.checkHost/checkEmail options - #33676

Closed
robobun wants to merge 7 commits into
mainfrom
farm/c36e246a/x509-checkhost-openssl-flags
Closed

crypto: implement OpenSSL-compatible X509Certificate.checkHost/checkEmail options#33676
robobun wants to merge 7 commits into
mainfrom
farm/c36e246a/x509-checkhost-openssl-flags

Conversation

@robobun

@robobun robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

What

X509Certificate.checkHost() and checkEmail() accept five documented options. Four of them were type-validated and then silently ignored, and partialWildcards being the Node default meant partial-wildcard SAN entries like DNS:a*.example.com never matched at all. Passing {} as the options object also threw instead of using defaults.

Separately, the no-options default subject-fallback rule also diverged: Node falls back to the subject CN/emailAddress whenever the certificate has no SAN of the relevant type (so a cert with only email SANs still matches checkHost() by CN, and a cert with only DNS SANs still matches checkEmail() by subject emailAddress), but Bun only fell back when the SAN extension was absent altogether.

Repro

import { X509Certificate } from "node:crypto";
// SAN DNS:*.w.x509.sysfuzz.test  subject CN=wild-cn-unused.example
const W = new X509Certificate(WILD);
// SAN DNS:a*.p.x509.sysfuzz.test, DNS:*b.p..., ...
const P = new X509Certificate(PARTIAL);
// SAN email:san.first@...  subject emailAddress=Subject.Mail@x509.sysfuzz.test
const E = new X509Certificate(EMAIL);
// CN=host.a.test  subjectAltName: email:san@a.test (no DNS entry)
const A = new X509Certificate(EMAIL_ONLY_SAN);

W.checkHost("wild-cn-unused.example", { subject: "always" });
// node: "wild-cn-unused.example"   bun: undefined
P.checkHost("abc.p.x509.sysfuzz.test");
// node: "a*.p.x509.sysfuzz.test"   bun: undefined   (partialWildcards is the default)
W.checkHost("a.b.w.x509.sysfuzz.test", { multiLabelWildcards: true });
// node: "*.w.x509.sysfuzz.test"    bun: undefined
E.checkEmail("Subject.Mail@x509.sysfuzz.test", { subject: "always" });
// node: "Subject.Mail@..."         bun: undefined
A.checkHost("host.a.test");
// node: "host.a.test"              bun: undefined   (default fallback rule)
W.checkHost("foo.w.x509.sysfuzz.test", {});
// node: "*.w.x509.sysfuzz.test"    bun: TypeError "options must have at least one property"

Cause

getFlags() ORs in X509_CHECK_FLAG_ALWAYS_CHECK_SUBJECT / NO_PARTIAL_WILDCARDS / MULTI_LABEL_WILDCARDS / SINGLE_LABEL_SUBDOMAINS and passes them to BoringSSL's X509_check_host / X509_check_email. BoringSSL #defines all four of those to 0 and its do_x509_check never implements the OpenSSL behaviour behind them (no subject fallback when SANs of the right type exist, no partial or multi-label wildcards, no .suffix matching), so the whole options block compiled to flags |= 0.

BoringSSL's do_x509_check also returns immediately after walking the SAN list whenever the extension is present at all, whereas OpenSSL tracks san_present per type and only suppresses the subject fallback when at least one SAN of the requested type was seen.

Fix

Port OpenSSL crypto/x509/v3_utl.c do_x509_check and its helpers (valid_star, wildcard_match, equal_nocase/equal_case/equal_email, skip_prefix) into ncrypto.cpp and drive them with locally defined X509View::CheckFlags that carry OpenSSL's real bit values. X509View::checkHost / checkEmail now call the port instead of BoringSSL, and getFlags() ORs in the new constants. The bogus "options must have at least one property" check is removed to match Node.

checkIp is unchanged (no flags apply to IP matching). packages/bun-usockets/src/quic.c still calls BoringSSL's X509_check_host, which keeps QUIC certificate verification on BoringSSL's stricter rules.

Verification

test/js/node/crypto/x509.test.ts gains two describe blocks: one covering every option against three purpose-built certificates (full-label wildcard + CN, partial-wildcard SANs, email SAN + subject emailAddress), and one covering the default subject-fallback rule against certs whose SANs are entirely of a different type or an empty sequence. Every expected value was taken from Node.js v26.3.0; the debug build's output is byte-identical to Node for the full repro matrix. 9 of the 12 new tests fail on main, all 26 pass with this change. test-crypto-x509.js, test-tls-getcertificate-x509.js and x509-subclass.test.ts still pass, and a 60k-iteration loop under ASAN shows bounded RSS.

This follows up on #33299, which noted these options as BoringSSL limitations.


[review] gate passed · iteration 6 · 4 files touched

fails on main (without fix)
ASAN without fix: 9 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/node/crypto/x509.test.ts"
bun test v1.4.0 (87e98e6b1)

test/js/node/crypto/x509.test.ts:
(pass) X509Certificate.checkHost() > "sub.wildcard.example.com" returns the subjectAltName entry that matched [2.08ms]
(pass) X509Certificate.checkHost() > "SUB.WILDCARD.EXAMPLE.COM" returns the subjectAltName entry that matched [0.51ms]
(pass) X509Certificate.checkHost() > "exact.example.com" returns the subjectAltName entry that matched [0.29ms]
(pass) X509Certificate.checkHost() > "EXACT.EXAMPLE.COM" returns the subjectAltName entry that matched [0.29ms]
(pass) X509Certificate.checkHost() > "a.b.wildcard.example.com" does not match [1.26ms]
(pass) X509Certificate.checkHost() > "wildcard.example.com" does not match [0.68ms]
(pass) X509Certificate.checkHost() > "wildcard-san.example.com" does not match [0.23ms]
(pass) X509Certificate.checkHost() > "nomatch.example.org" does not match [0.22ms]
(pass) X509Certificate.checkHost() > wildcards: false only disables the wildcard entry [2.42ms]
(pass) X509Certificate.checkHost() > "agent1" falls 
... (truncated)

release without fix: 15 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/node/crypto/x509.test.ts:
37 |     ["sub.wildcard.example.com", "*.wildcard.example.com"],
38 |     ["SUB.WILDCARD.EXAMPLE.COM", "*.wildcard.example.com"],
39 |     ["exact.example.com", "exact.example.com"],
40 |     ["EXACT.EXAMPLE.COM", "exact.example.com"],
41 |   ])("%p returns the subjectAltName entry that matched", (host, matched) => {
42 |     expect(cert.checkHost(host)).toBe(matched);
                                      ^
error: expect(received).toBe(expected)

Expected: "*.wildcard.example.com"
Received: "sub.wildcard.example.com"

      at <anonymous> (/workspace/bun/test/js/node/crypto/x509.test.ts:42:34)
(fail) X509Certificate.checkHost() > "sub.wildcard.example.com" returns the subjectAltName entry that matched [0.31ms]
37 |     ["sub.wildcard.example.com", "*.wildcard.example.com"],
38 |     ["SUB.WILDCARD.EXAMPLE.COM", "*.wildcard.example.com"],
39 |     ["exact.example.com", "exact.example.com"],
40 |     ["EXACT.EXAMPLE.COM", "exact.example.com"],
41 |   ])("%p returns the subjectAltName entry that matched", (host, matched) => {
42 |     expect(cert.checkHost(host)).toBe(matched);
                   
... (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/mechgate.xml" "test/js/node/crypto/x509.test.ts"
bun test v1.4.0 (87e98e6b1)

test/js/node/crypto/x509.test.ts:
(pass) X509Certificate.checkHost() > "sub.wildcard.example.com" returns the subjectAltName entry that matched [2.02ms]
(pass) X509Certificate.checkHost() > "SUB.WILDCARD.EXAMPLE.COM" returns the subjectAltName entry that matched [0.51ms]
(pass) X509Certificate.checkHost() > "exact.example.com" returns the subjectAltName entry that matched [0.27ms]
(pass) X509Certificate.checkHost() > "EXACT.EXAMPLE.COM" returns the subjectAltName entry that matched [0.25ms]
(pass) X509Certificate.checkHost() > "a.b.wildcard.example.com" does not match [1.10ms]
(pass) X509Certificate.checkHost() > "wildcard.example.com" does not match [0.63ms]
(pass) X509Certificate.checkHost() > "wildcard-san.example.com" does not match [0.23ms]
(pass) X509Certificate.checkHost() > "nomatch.example.org" does not match [0.22ms]
(pass) X509Certificate.checkHost() > wildcards: false only disables the wildcard entry [2.12ms]
(pass) X509Certificate.checkHost() > "agent1" falls 
... (truncated)

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     87e98e6b1c
  features     baseline

22 deps, 108 codegen, 1171 objects in 808ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1234] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b77)

Checked 124 installs across 170 packages (no changes) [8.00ms]
[2/1234] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [4.00ms]
[3/1234] gen ErrorCode+*.h
[4/1234] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b77)

Checked 129 installs across 147 packages (no changes) [11.00ms]
[5/1234] gen bindgenv2
[6/1234] fetch picohttpparser
[picohttpparser] up to date
[7/1234] fetch zlib
[zlib] up to date
[8/1234] gen .bind.ts → GeneratedBindings.cpp
[9/1234] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[10/1234] fetch tinycc
[tinycc] up to date
[11/1234] subst deps/zlib/zlib.h
[12/1234] subst deps/zlib/zconf.h
[13/1234] fetch nodejs (prebuilt)
[nodejs] up to date
[14/123
... (truncated)
diff hotspot
src/jsc/bindings/JSX509CertificatePrototype.cpp |  24 +--
 src/jsc/bindings/ncrypto.cpp                    | 268 +++++++++++++++++++++++-
 src/jsc/bindings/ncrypto.h                      |   9 +
 test/js/node/crypto/x509.test.ts                | 146 +++++++++++++
 4 files changed, 421 insertions(+), 26 deletions(-)

gate history · 1 passed · 0 rejected · iteration 6

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

…mail options

BoringSSL defines X509_CHECK_FLAG_ALWAYS_CHECK_SUBJECT,
X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS, X509_CHECK_FLAG_MULTI_LABEL_WILDCARDS
and X509_CHECK_FLAG_SINGLE_LABEL_SUBDOMAINS as 0, so the checkHost/checkEmail
option parser compiled to 'flags |= 0' and the options were silently ignored.
Port OpenSSL's do_x509_check and helpers into ncrypto so the documented
options (subject:'always', partialWildcards, multiLabelWildcards,
singleLabelSubdomains) behave like Node, and stop rejecting an empty
options object.
@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:40 AM PT - Jul 26th, 2026

@robobun, your commit 87e98e6 has 1 failures in Build #82498 (All Failures):

  • 📦 Binary size — 12 over 0.50 MB
  • targetthis build canary: main #79916
    sizeΔ
    bun-darwin-aarch6458.13 MB57.58 MB+564.9 KB
    bun-darwin-x6463.48 MB62.95 MB+544.5 KB
    bun-linux-aarch6470.98 MB70.42 MB+576.0 KB
    bun-linux-x6472.48 MB71.95 MB+544.0 KB
    bun-linux-aarch64-musl64.88 MB64.32 MB+576.0 KB
    bun-linux-x64-musl66.98 MB66.45 MB+544.0 KB
    bun-linux-aarch64-android78.47 MB77.97 MB+512.0 KB
    bun-linux-x64-android80.62 MB80.10 MB+529.2 KB
    bun-freebsd-x6483.07 MB82.56 MB+528.0 KB
    bun-freebsd-aarch6484.84 MB84.31 MB+544.0 KB
    bun-windows-x6480.26 MB79.70 MB+573.5 KB
    bun-windows-aarch6470.86 MB70.34 MB+535.5 KB

    Add [skip size check] to the commit message if this increase is intentional.


🧪   To try this PR locally:

bunx bun-pr 33676

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

bun-33676 --bun

@github-actions github-actions Bot added the claude label Jul 7, 2026
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Replaces OpenSSL/BoringSSL-based hostname and email certificate matching in X509View with a local do_x509_check and a new CheckFlags struct. Updates JS binding option parsing to use the new flags and removes the empty-options error. Adds tests for checkHost/checkEmail option behaviors.

Changes

X509 certificate check flags and matching

Layer / File(s) Summary
CheckFlags constants definition
src/jsc/bindings/ncrypto.h
Adds nested X509View::CheckFlags struct with static constexpr bitmask constants mirroring OpenSSL X509_CHECK_FLAG_* semantics, with comments on BoringSSL differences.
Local do_x509_check matching implementation
src/jsc/bindings/ncrypto.cpp
Adds a local do_x509_check implementation with wildcard/case-insensitive comparison helpers and rewrites checkHost/checkEmail to validate input, invoke do_x509_check, and map results to CheckMatch, populating peerName on host match.
JS binding option parsing update
src/jsc/bindings/JSX509CertificatePrototype.cpp
Updates getFlags to build flags via X509View::CheckFlags for subject, wildcards, partialWildcards, multiLabelWildcards, and singleLabelSubdomains; removes prior any tracking so empty options now return 0 flags instead of throwing.
checkHost/checkEmail option tests
test/js/node/crypto/x509.test.ts
Adds tests for subject override, wildcard rule toggles, invalid wildcard rejection, empty options acceptance, and embedded-NUL rejection.

Possibly related PRs

  • oven-sh/bun#33299: Related through X509View::checkHost/checkEmail producing a matched peerName that the JS binding threads and returns from checkHost().
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly states the main change: OpenSSL-compatible X509Certificate option handling.
Description check ✅ Passed The description covers the PR purpose, repro, fix, and verification, though the first heading doesn't exactly match the template.

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

@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.

I didn't find any bugs, but this ports ~250 lines of OpenSSL's certificate name-matching logic and replaces BoringSSL's X509_check_host/email — security-sensitive enough that a human should sign off.

Extended reasoning...

Overview

This PR ports OpenSSL's do_x509_check and its helpers (valid_star, wildcard_match, equal_nocase/equal_case/equal_email, skip_prefix) from crypto/x509/v3_utl.c into ncrypto.cpp, replacing the previous delegation to BoringSSL's X509_check_host/X509_check_email. It defines local X509View::CheckFlags constants with OpenSSL's real bit values (BoringSSL #defines several of these to 0), wires getFlags() to use them, and removes the incorrect "options must have at least one property" throw. checkIp and the QUIC path in bun-usockets remain on BoringSSL.

Security risks

This is certificate hostname/email matching — the code that decides whether a given name is covered by an X.509 certificate. A defect here could either reject valid names (breakage) or, worse, accept names that shouldn't match (e.g., an over-permissive wildcard, mishandled IDNA label, or a bounds slip in skip_prefix/wildcard_match). The port also introduces manual buffer handling (ASN1_STRING_to_UTF8 + OPENSSL_free, OPENSSL_strndup, raw pointer arithmetic in valid_star/wildcard_match). The change deliberately loosens matching relative to BoringSSL's stricter behaviour (partial wildcards, multi-label wildcards, subject fallback with SANs present), which is correct for Node compat but is exactly the direction that warrants careful review.

Level of scrutiny

High. ~250 lines of new C++ reimplementing security-relevant string/wildcard matching, with pointer arithmetic on ASN.1-derived data. Even though it's a line-for-line port of well-audited OpenSSL code and the PR verified byte-identical output against Node v26.3.0 across a purpose-built cert matrix, the CLAUDE.md guidance is explicit: never auto-approve changes to crypto / certificate-verification paths.

Other factors

  • The port looks faithful to OpenSSL v3_utl.c; memory ownership (free of astr on the OOM path in do_check_string, GENERAL_NAMES_free, null-initialised peername) appears correct.
  • Embedded-NUL rejection is preserved by an explicit memchr guard now that BoringSSL's wrapper (which did that check) is bypassed.
  • Test coverage is thorough (9 new tests, 3 purpose-built certs, every option exercised, negative cases, NUL rejection) and the description reports ASAN-clean under a 60k-iteration loop.
  • No prior reviews or outstanding comments on the PR.

Comment thread src/jsc/bindings/ncrypto.cpp
do_x509_check only returns -1/0/1; the null and embedded-NUL inputs that
BoringSSL's X509_check_host/email reported as -2 are now rejected before
the switch.

@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.

No further issues from me — the dead case -2: cleanup landed. This is a ~260-line port of OpenSSL's X509 host/email matching (wildcard parsing, subject-DN fallback) into ncrypto.cpp, so it warrants a human pass over the pointer arithmetic and flag semantics before merge.

Extended reasoning...

Overview

This PR ports OpenSSL's do_x509_check and its helpers (valid_star, wildcard_match, equal_nocase/equal_case/equal_email, skip_prefix, do_check_string) — roughly 260 lines of new C++ — into src/jsc/bindings/ncrypto.cpp, replacing the BoringSSL X509_check_host/X509_check_email calls that silently ignored four of the five documented checkHost/checkEmail options. It adds an X509View::CheckFlags struct in ncrypto.h with OpenSSL's real bit values, wires getFlags() in JSX509CertificatePrototype.cpp to use them, removes the bogus empty-options error, and adds a thorough test matrix in test/js/node/crypto/x509.test.ts cross-checked against Node.js.

Security risks

The new code is X.509 hostname/email matching — squarely security-adjacent. It intentionally loosens matching relative to BoringSSL when the caller opts in (partial wildcards by default, subject: 'always' fallback, multi-label wildcards, .suffix inputs). Per the PR description this only backs the user-facing X509Certificate.checkHost/checkEmail Node-compat API; the actual TLS/QUIC verification path (packages/bun-usockets/src/quic.c) still uses BoringSSL's stricter X509_check_host. That scoping is important and looks correct, but the ported string-matching code itself has non-trivial pointer arithmetic over certificate-derived ASN.1 strings and deserves a careful human read for bounds correctness and fidelity to upstream v3_utl.c.

Level of scrutiny

High. This is hand-ported crypto library code with manual buffer walking, an OPENSSL_strndup/OPENSSL_free ownership pair, and flag-driven branching that changes what certificates a name will match. Even though it's a faithful-looking port with strong test coverage and an ASAN loop, ported security code in this repo's conventions calls for a maintainer to diff it against the OpenSSL reference.

Other factors

My earlier inline comment (unreachable case -2: arms) was addressed in 071be66 and the thread is resolved. The bug-hunting pass found nothing on the current revision. Test coverage is excellent — nine new tests exercising every flag against purpose-built certs, with expected values taken from Node — and the PR description documents ASAN verification. None of that changes the fact that this is outside the "simple/mechanical" bar for auto-approval.

@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

All test lanes green on build 82498 (26/26 in x509.test.ts, plus the node parallel suite). The only red is package-binary-size (+~550 KB vs canary #79916).

That delta is not from this diff. The baseline is main ae4b17de6d (#79916) and this branch was rebased onto main 44f6469e0d, 12 commits later, which includes node:quic on lsquic (#32602), the full node:repl (#31827), node:inspector Profiler + DevTools server (#31823) and the tls close_notify/upgrade rewrite (#34598). Those account for the ~550 KB. The pre-rebase build of this diff (69955, 071be669) passed binary-size; the ~250-line do_x509_check port here does not move the needle on its own.

Review threads: all five resolved (one dead-code finding addressed in 071be66, four comment-cop threads addressed in 3c3ee2b / 87e98e6). Ready for a maintainer.

robobun added 2 commits July 26, 2026 12:59
…elevant type

OpenSSL's X509_check_host/X509_check_email default rule falls back to the
subject CN/emailAddress when the certificate has no SAN of the relevant
type, not only when the SAN extension is absent. BoringSSL's implementation
skips the subject as soon as any SAN extension is present. The ported
do_x509_check already handles this via san_present; add explicit coverage
for certs whose SANs are all of a different type (or an empty sequence).
Comment thread src/jsc/bindings/ncrypto.cpp Outdated
Comment thread src/jsc/bindings/ncrypto.h Outdated
Comment thread src/jsc/bindings/ncrypto.cpp Outdated
Comment thread src/jsc/bindings/ncrypto.h Outdated

@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.

I didn't find any bugs, but this is a ~250-line hand-port of OpenSSL's do_x509_check / valid_star / wildcard_match into a security-sensitive path (X509 hostname/email matching), and it strictly widens what matches relative to the previous BoringSSL-backed behaviour. That warrants a human diff against crypto/x509/v3_utl.c before landing.

What was reviewed:

  • Traced do_x509_check return values → {-1, 0, 1}; -1 correctly maps to OPERATION_FAILED and the earlier dead case -2: labels are gone.
  • Checked memory ownership: peername (OPENSSL_strndup) is adopted by DataPointer, GENERAL_NAMES_free(gens) runs on every exit, astr is freed on both success and the strndup-failure path.
  • Confirmed the new CheckFlags bit values match OpenSSL's X509_CHECK_FLAG_* and don't collide with kDotSubdomainsFlag = 0x8000; checkIp still routes to BoringSSL and QUIC's X509_check_host is untouched.
Extended reasoning...

Overview

The PR replaces BoringSSL's X509_check_host / X509_check_email with a local port of OpenSSL's do_x509_check (and its helpers skip_prefix, equal_nocase/equal_case/equal_email, wildcard_match, valid_star, do_check_string) inside src/jsc/bindings/ncrypto.cpp. A new X509View::CheckFlags struct in ncrypto.h carries OpenSSL's real bit values, and getFlags() in JSX509CertificatePrototype.cpp is rewired to use them (the bogus empty-options TypeError is also removed). ~150 lines of new tests exercise every documented option against six purpose-built certificates.

Security risks

This is X509 certificate name matching — the change makes checkHost/checkEmail more permissive than before: partial-label wildcards (a*.example.com), multi-label wildcards, .suffix subdomain matching, and subject-CN fallback in the presence of unrelated SAN types now succeed where they previously returned undefined. That is the documented Node.js/OpenSSL behaviour and the PR is careful to leave TLS/QUIC verification on BoringSSL's stricter path, so this only affects users who call X509Certificate#checkHost/checkEmail directly. Still, any transcription error in the ~250-line port could accept a hostname OpenSSL would reject, so this is squarely security-sensitive.

Level of scrutiny

High. Per the repo guidance, hand-rolled security-sensitive parsing needs to "replicate the FULL verification path" of the reference implementation, and ported code should be diffed line-for-line against upstream. The port looks faithful (I spot-checked valid_star's star/label/IDNA state machine, wildcard_match's IDNA guard and hostname-char loop, and do_x509_check's san_present fallback rule against OpenSSL's v3_utl.c), and memory handling is clean (GENERAL_NAMES_free, OPENSSL_free(astr), OPENSSL_strndupDataPointer). But a maintainer should independently confirm the port matches upstream and is comfortable with the compat-vs-strictness tradeoff.

Other factors

Test coverage is thorough — every option is exercised in both directions with expected values taken from Node v26.3.0, plus the per-type SAN fallback rule and empty-SAN edge case. My earlier inline note about unreachable case -2: was addressed; the comment-cop nits were resolved by trimming to single-line provenance comments. No outstanding threads. Deferring because this is complex, security-adjacent native code that a human should sign off on.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Closing this since #36252 (tls: share one native certificate-name matcher across fetch and X509Certificate#checkHost) merged and covers the same ground. Thank you @robobun for the PR — if there's a piece of this that #36252 didn't pick up, please say so and we'll take another look.

(This comment was written by Claude, on behalf of the Bun team.)

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Ran this PR's test matrix against current main (a7838c5, with #36252 merged): all checkHost cases pass, so #36252 fully covers the host side.

Three checkEmail cases still diverge from Node, because X509View::checkEmail in ncrypto.cpp still calls BoringSSL's X509_check_email directly:

// SAN email:san.first@..., email:UPPER@...  subject emailAddress=Subject.Mail@x509.sysfuzz.test
E.checkEmail("Subject.Mail@x509.sysfuzz.test", { subject: "always" });
// node: "Subject.Mail@x509.sysfuzz.test"   bun main: undefined

// SAN DNS:host.b.test only (no email SAN)  subject emailAddress=hidden@b.test
dnsOnlySan.checkEmail("hidden@b.test");
// node: "hidden@b.test"   bun main: undefined

// SAN present but empty sequence  subject emailAddress=subj@c.test
emptySan.checkEmail("subj@c.test");
// node: "subj@c.test"   bun main: undefined

The first is the subject: "always" case from the original report; the other two are OpenSSL's default rule of falling back to the subject emailAddress when the SAN extension has no entries of the GEN_EMAIL type (BoringSSL skips the subject as soon as any SAN extension exists). Happy to open a follow-up that routes checkEmail through the same shared matcher if that's the direction you want.

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