From df1449cfa8c56a6482b10295fb6889735b6e6a80 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:03:36 +0000 Subject: [PATCH 1/6] crypto: implement OpenSSL-compatible X509Certificate.checkHost/checkEmail 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. --- .../bindings/JSX509CertificatePrototype.cpp | 24 +- src/jsc/bindings/ncrypto.cpp | 270 +++++++++++++++++- src/jsc/bindings/ncrypto.h | 12 + test/js/node/crypto/x509.test.ts | 98 +++++++ 4 files changed, 382 insertions(+), 22 deletions(-) diff --git a/src/jsc/bindings/JSX509CertificatePrototype.cpp b/src/jsc/bindings/JSX509CertificatePrototype.cpp index e1b18c475c6e..2d1ff02e4343 100644 --- a/src/jsc/bindings/JSX509CertificatePrototype.cpp +++ b/src/jsc/bindings/JSX509CertificatePrototype.cpp @@ -165,11 +165,10 @@ static uint32_t getFlags(JSC::VM& vm, JSGlobalObject* globalObject, JSC::ThrowSc JSValue singleLabelSubdomains = object->get(globalObject, Identifier::fromString(vm, String("singleLabelSubdomains"_s))); RETURN_IF_EXCEPTION(scope, {}); + using CheckFlags = ncrypto::X509View::CheckFlags; uint32_t flags = 0; - bool any = false; if (!subject.isUndefined()) { - any = true; if (!subject.isString()) { Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, "subject must be a string"_s); return 0; @@ -180,9 +179,9 @@ static uint32_t getFlags(JSC::VM& vm, JSGlobalObject* globalObject, JSC::ThrowSc auto view = subjectString->view(globalObject); RETURN_IF_EXCEPTION(scope, {}); if (view == "always"_s) { - flags |= X509_CHECK_FLAG_ALWAYS_CHECK_SUBJECT; + flags |= CheckFlags::ALWAYS_CHECK_SUBJECT; } else if (view == "never"_s) { - flags |= X509_CHECK_FLAG_NEVER_CHECK_SUBJECT; + flags |= CheckFlags::NEVER_CHECK_SUBJECT; } else if (view == "default"_s) { // Matches OpenSSL's default, no flags. } else { @@ -192,51 +191,42 @@ static uint32_t getFlags(JSC::VM& vm, JSGlobalObject* globalObject, JSC::ThrowSc } if (!wildcards.isUndefined()) { - any = true; if (!wildcards.isBoolean()) { Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, "wildcards must be a boolean"_s); return 0; } if (!wildcards.asBoolean()) - flags |= X509_CHECK_FLAG_NO_WILDCARDS; + flags |= CheckFlags::NO_WILDCARDS; } if (!partialWildcards.isUndefined()) { - any = true; if (!partialWildcards.isBoolean()) { Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, "partialWildcards must be a boolean"_s); return 0; } if (!partialWildcards.asBoolean()) - flags |= X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS; + flags |= CheckFlags::NO_PARTIAL_WILDCARDS; } if (!multiLabelWildcards.isUndefined()) { - any = true; if (!multiLabelWildcards.isBoolean()) { Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, "multiLabelWildcards must be a boolean"_s); return 0; } if (multiLabelWildcards.asBoolean()) - flags |= X509_CHECK_FLAG_MULTI_LABEL_WILDCARDS; + flags |= CheckFlags::MULTI_LABEL_WILDCARDS; } if (!singleLabelSubdomains.isUndefined()) { - any = true; if (!singleLabelSubdomains.isBoolean()) { Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, "singleLabelSubdomains must be a boolean"_s); return 0; } if (singleLabelSubdomains.asBoolean()) - flags |= X509_CHECK_FLAG_SINGLE_LABEL_SUBDOMAINS; - } - - if (!any) { - Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, "options must have at least one property"_s); - return 0; + flags |= CheckFlags::SINGLE_LABEL_SUBDOMAINS; } return flags; diff --git a/src/jsc/bindings/ncrypto.cpp b/src/jsc/bindings/ncrypto.cpp index becafcceb04b..4a2b68582b31 100644 --- a/src/jsc/bindings/ncrypto.cpp +++ b/src/jsc/bindings/ncrypto.cpp @@ -1275,15 +1275,272 @@ bool X509View::checkPublicKey(const EVPKeyPointer& pkey) const return X509_verify(const_cast(cert_), pkey.get()) == 1; } +// BoringSSL's X509_check_host/X509_check_email drop several OpenSSL options +// that Node's X509Certificate.checkHost/checkEmail expose: ALWAYS_CHECK_SUBJECT, +// NO_PARTIAL_WILDCARDS, MULTI_LABEL_WILDCARDS and SINGLE_LABEL_SUBDOMAINS are +// all #defined to 0, and the subject-DN fallback / partial-wildcard matching +// is gone. To match Node, port OpenSSL crypto/x509/v3_utl.c do_x509_check and +// its helpers here and drive them with X509View::CheckFlags. +namespace { + +// Internal flag set when the caller-provided name begins with '.'. +constexpr int kDotSubdomainsFlag = 0x8000; + +using CheckFlags = X509View::CheckFlags; +using EqualFn = int (*)(const unsigned char*, size_t, const unsigned char*, size_t, unsigned int); + +void skip_prefix(const unsigned char** p, size_t* plen, size_t subject_len, unsigned int flags) +{ + if ((flags & kDotSubdomainsFlag) == 0) return; + const unsigned char* pattern = *p; + size_t pattern_len = *plen; + while (pattern_len > subject_len && *pattern) { + if ((flags & CheckFlags::SINGLE_LABEL_SUBDOMAINS) && *pattern == '.') break; + ++pattern; + --pattern_len; + } + if (pattern_len == subject_len) { + *p = pattern; + *plen = pattern_len; + } +} + +int equal_nocase(const unsigned char* pattern, size_t pattern_len, + const unsigned char* subject, size_t subject_len, unsigned int flags) +{ + skip_prefix(&pattern, &pattern_len, subject_len, flags); + if (pattern_len != subject_len) return 0; + while (pattern_len != 0) { + unsigned char l = *pattern; + unsigned char r = *subject; + if (l == 0) return 0; + if (l != r) { + if ('A' <= l && l <= 'Z') l = (l - 'A') + 'a'; + if ('A' <= r && r <= 'Z') r = (r - 'A') + 'a'; + if (l != r) return 0; + } + ++pattern; + ++subject; + --pattern_len; + } + return 1; +} + +int equal_case(const unsigned char* pattern, size_t pattern_len, + const unsigned char* subject, size_t subject_len, unsigned int flags) +{ + skip_prefix(&pattern, &pattern_len, subject_len, flags); + if (pattern_len != subject_len) return 0; + return memcmp(pattern, subject, pattern_len) == 0; +} + +int equal_email(const unsigned char* a, size_t a_len, + const unsigned char* b, size_t b_len, unsigned int) +{ + if (a_len != b_len) return 0; + size_t i = a_len; + while (i > 0) { + --i; + if (a[i] == '@' || b[i] == '@') { + if (!equal_nocase(a + i, a_len - i, b + i, a_len - i, 0)) return 0; + break; + } + } + if (i == 0) i = a_len; + return equal_case(a, i, b, i, 0); +} + +int wildcard_match(const unsigned char* prefix, size_t prefix_len, + const unsigned char* suffix, size_t suffix_len, + const unsigned char* subject, size_t subject_len, unsigned int flags) +{ + if (subject_len < prefix_len + suffix_len) return 0; + if (!equal_nocase(prefix, prefix_len, subject, prefix_len, flags)) return 0; + const unsigned char* wildcard_start = subject + prefix_len; + const unsigned char* wildcard_end = subject + (subject_len - suffix_len); + if (!equal_nocase(wildcard_end, suffix_len, suffix, suffix_len, flags)) return 0; + int allow_multi = 0; + int allow_idna = 0; + if (prefix_len == 0 && *suffix == '.') { + if (wildcard_start == wildcard_end) return 0; + allow_idna = 1; + if (flags & CheckFlags::MULTI_LABEL_WILDCARDS) allow_multi = 1; + } + if (!allow_idna && subject_len >= 4 + && OPENSSL_strncasecmp(reinterpret_cast(subject), "xn--", 4) == 0) + return 0; + if (wildcard_end == wildcard_start + 1 && *wildcard_start == '*') return 1; + for (const unsigned char* p = wildcard_start; p != wildcard_end; ++p) { + if (!(('0' <= *p && *p <= '9') || ('A' <= *p && *p <= 'Z') + || ('a' <= *p && *p <= 'z') || *p == '-' || (allow_multi && *p == '.'))) + return 0; + } + return 1; +} + +constexpr int kLabelStart = 1 << 0; +constexpr int kLabelHyphen = 1 << 2; +constexpr int kLabelIdna = 1 << 3; + +const unsigned char* valid_star(const unsigned char* p, size_t len, unsigned int flags) +{ + const unsigned char* star = nullptr; + int state = kLabelStart; + int dots = 0; + for (size_t i = 0; i < len; ++i) { + if (p[i] == '*') { + int atstart = (state & kLabelStart); + int atend = (i == len - 1 || p[i + 1] == '.'); + if (star != nullptr || (state & kLabelIdna) != 0 || dots) return nullptr; + if ((flags & CheckFlags::NO_PARTIAL_WILDCARDS) && (!atstart || !atend)) + return nullptr; + if (!atstart && !atend) return nullptr; + star = &p[i]; + state &= ~kLabelStart; + } else if (('a' <= p[i] && p[i] <= 'z') || ('A' <= p[i] && p[i] <= 'Z') + || ('0' <= p[i] && p[i] <= '9')) { + if ((state & kLabelStart) != 0 && len - i >= 4 + && OPENSSL_strncasecmp(reinterpret_cast(&p[i]), "xn--", 4) == 0) + state |= kLabelIdna; + state &= ~(kLabelHyphen | kLabelStart); + } else if (p[i] == '.') { + if ((state & (kLabelHyphen | kLabelStart)) != 0) return nullptr; + state = kLabelStart; + ++dots; + } else if (p[i] == '-') { + if ((state & kLabelStart) != 0) return nullptr; + state |= kLabelHyphen; + } else { + return nullptr; + } + } + if ((state & (kLabelStart | kLabelHyphen)) != 0 || dots < 2) return nullptr; + return star; +} + +int equal_wildcard(const unsigned char* pattern, size_t pattern_len, + const unsigned char* subject, size_t subject_len, unsigned int flags) +{ + const unsigned char* star = nullptr; + if (!(subject_len > 1 && subject[0] == '.')) + star = valid_star(pattern, pattern_len, flags); + if (star == nullptr) + return equal_nocase(pattern, pattern_len, subject, subject_len, flags); + return wildcard_match(pattern, star - pattern, star + 1, + (pattern + pattern_len) - star - 1, subject, subject_len, flags); +} + +int do_check_string(const ASN1_STRING* a, int cmp_type, EqualFn equal, + unsigned int flags, const char* b, size_t blen, char** peername) +{ + if (!a->data || !a->length) return 0; + int rv = 0; + if (cmp_type > 0) { + if (cmp_type != a->type) return 0; + if (cmp_type == V_ASN1_IA5STRING) + rv = equal(a->data, a->length, reinterpret_cast(b), blen, flags); + else if (a->length == static_cast(blen) && memcmp(a->data, b, blen) == 0) + rv = 1; + if (rv > 0 && peername != nullptr) { + *peername = OPENSSL_strndup(reinterpret_cast(a->data), a->length); + if (*peername == nullptr) return -1; + } + } else { + unsigned char* astr; + int astrlen = ASN1_STRING_to_UTF8(&astr, a); + if (astrlen < 0) return -1; + rv = equal(astr, astrlen, reinterpret_cast(b), blen, flags); + if (rv > 0 && peername != nullptr) { + *peername = OPENSSL_strndup(reinterpret_cast(astr), astrlen); + if (*peername == nullptr) { + OPENSSL_free(astr); + return -1; + } + } + OPENSSL_free(astr); + } + return rv; +} + +int do_x509_check(const X509* x, const char* chk, size_t chklen, + unsigned int flags, int check_type, char** peername) +{ + int cnid = NID_undef; + int alt_type; + int san_present = 0; + int rv = 0; + EqualFn equal; + + flags &= ~kDotSubdomainsFlag; + if (check_type == GEN_EMAIL) { + cnid = NID_pkcs9_emailAddress; + alt_type = V_ASN1_IA5STRING; + equal = equal_email; + } else if (check_type == GEN_DNS) { + cnid = NID_commonName; + if (chklen > 1 && chk[0] == '.') flags |= kDotSubdomainsFlag; + alt_type = V_ASN1_IA5STRING; + if (flags & CheckFlags::NO_WILDCARDS) + equal = equal_nocase; + else + equal = equal_wildcard; + } else { + alt_type = V_ASN1_OCTET_STRING; + equal = equal_case; + } + + GENERAL_NAMES* gens = static_cast( + X509_get_ext_d2i(x, NID_subject_alt_name, nullptr, nullptr)); + if (gens) { + for (OPENSSL_SIZE_T i = 0; i < sk_GENERAL_NAME_num(gens); i++) { + const GENERAL_NAME* gen = sk_GENERAL_NAME_value(gens, i); + const ASN1_STRING* cstr; + if (gen->type == GEN_EMAIL && check_type == GEN_EMAIL) + cstr = gen->d.rfc822Name; + else if (gen->type == GEN_DNS && check_type == GEN_DNS) + cstr = gen->d.dNSName; + else if (gen->type == GEN_IPADD && check_type == GEN_IPADD) + cstr = gen->d.iPAddress; + else + continue; + san_present = 1; + if ((rv = do_check_string(cstr, alt_type, equal, flags, chk, chklen, + peername)) + != 0) + break; + } + GENERAL_NAMES_free(gens); + if (rv != 0) return rv; + if (san_present && !(flags & CheckFlags::ALWAYS_CHECK_SUBJECT)) return 0; + } + + if (cnid == NID_undef || (flags & CheckFlags::NEVER_CHECK_SUBJECT)) return 0; + + const X509_NAME* name = X509_get_subject_name(x); + int j = -1; + while ((j = X509_NAME_get_index_by_NID(name, cnid, j)) >= 0) { + const X509_NAME_ENTRY* ne = X509_NAME_get_entry(name, j); + const ASN1_STRING* str = X509_NAME_ENTRY_get_data(ne); + if ((rv = do_check_string(str, -1, equal, flags, chk, chklen, peername)) != 0) + return rv; + } + return 0; +} + +} // namespace + X509View::CheckMatch X509View::checkHost(const std::span host, int flags, DataPointer* peerName) const { ClearErrorOnReturn clearErrorOnReturn; if (cert_ == nullptr) return CheckMatch::NO_MATCH; - char* peername; - switch (X509_check_host( - const_cast(cert_), host.data(), host.size(), flags, &peername)) { + if (host.data() == nullptr) return CheckMatch::INVALID_NAME; + if (memchr(host.data(), '\0', host.size()) != nullptr) + return CheckMatch::INVALID_NAME; + char* peername = nullptr; + switch (do_x509_check(cert_, host.data(), host.size(), flags, GEN_DNS, + &peername)) { case 0: return CheckMatch::NO_MATCH; case 1: { @@ -1305,8 +1562,11 @@ X509View::CheckMatch X509View::checkEmail(const std::span email, { ClearErrorOnReturn clearErrorOnReturn; if (cert_ == nullptr) return CheckMatch::NO_MATCH; - switch (X509_check_email( - const_cast(cert_), email.data(), email.size(), flags)) { + if (email.data() == nullptr) return CheckMatch::INVALID_NAME; + if (memchr(email.data(), '\0', email.size()) != nullptr) + return CheckMatch::INVALID_NAME; + switch (do_x509_check(cert_, email.data(), email.size(), flags, GEN_EMAIL, + nullptr)) { case 0: return CheckMatch::NO_MATCH; case 1: diff --git a/src/jsc/bindings/ncrypto.h b/src/jsc/bindings/ncrypto.h index 75d2a385aadc..fa63f897af66 100644 --- a/src/jsc/bindings/ncrypto.h +++ b/src/jsc/bindings/ncrypto.h @@ -1247,6 +1247,18 @@ class X509View final { INVALID_NAME, OPERATION_FAILED, }; + // OpenSSL's X509_CHECK_FLAG_* values. BoringSSL defines several of these + // macros as 0 (no-ops), so callers that need OpenSSL semantics must use + // these constants and the checkHost/checkEmail below, which reimplement + // OpenSSL's matching rather than delegating to BoringSSL. + struct CheckFlags { + static constexpr int ALWAYS_CHECK_SUBJECT = 0x1; + static constexpr int NO_WILDCARDS = 0x2; + static constexpr int NO_PARTIAL_WILDCARDS = 0x4; + static constexpr int MULTI_LABEL_WILDCARDS = 0x8; + static constexpr int SINGLE_LABEL_SUBDOMAINS = 0x10; + static constexpr int NEVER_CHECK_SUBJECT = 0x20; + }; CheckMatch checkHost(const std::span host, int flags, DataPointer* peerName = nullptr) const; diff --git a/test/js/node/crypto/x509.test.ts b/test/js/node/crypto/x509.test.ts index 215c76b57d7a..f24db271c24b 100644 --- a/test/js/node/crypto/x509.test.ts +++ b/test/js/node/crypto/x509.test.ts @@ -72,3 +72,101 @@ describe("X509Certificate.checkHost()", () => { expect(cnOnly.checkIP("127.0.0.1")).toBeUndefined(); }); }); + +// BoringSSL stubs X509_CHECK_FLAG_ALWAYS_CHECK_SUBJECT / NO_PARTIAL_WILDCARDS / +// MULTI_LABEL_WILDCARDS / SINGLE_LABEL_SUBDOMAINS to 0, so Bun reimplements +// OpenSSL's matching. Every expected value below is what Node.js (OpenSSL) +// returns for the same call. +describe("X509Certificate.checkHost() / checkEmail() options", () => { + // SAN DNS:*.w.x509.sysfuzz.test, DNS:*.xn--bcher-kva.x509.sysfuzz.test + // Subject CN=wild-cn-unused.example + const wild = new X509Certificate( + "-----BEGIN CERTIFICATE-----\nMIIB9TCCAZugAwIBAgIUI8OJajUyVOgpGPBbGl3btMhxlxAwCgYIKoZIzj0EAwIw\nKDESMBAGA1UECgwJeDUwOSBmdXp6MRIwEAYDVQQDDAl4NTA5LXJvb3QwIBcNMjYw\nNzA3MTQxMzU3WhgPMjA1NjA1MTAxNDEzNTdaMDUxEjAQBgNVBAoMCXg1MDkgZnV6\nejEfMB0GA1UEAwwWd2lsZC1jbi11bnVzZWQuZXhhbXBsZTBZMBMGByqGSM49AgEG\nCCqGSM49AwEHA0IABBlU+mcugKOB4r63yxEalCTcBv6sWAtt4yBPA4juhdcMS3xE\n/knQlI4nFIlzYkjlgfjRoNhD57Rf2ySMMEsoLRWjgZMwgZAwQwYDVR0RBDwwOoIV\nKi53Lng1MDkuc3lzZnV6ei50ZXN0giEqLnhuLS1iY2hlci1rdmEueDUwOS5zeXNm\ndXp6LnRlc3QwCQYDVR0TBAIwADAdBgNVHQ4EFgQUFKDoj6+Nnk/0iwi7A4m8Ry6d\nDyowHwYDVR0jBBgwFoAUm3cySAhYnJP1t9FnbozDAGFJPkgwCgYIKoZIzj0EAwID\nSAAwRQIgDOGDthc38j/YOh1TuznY4z48FGbFDeLn9oBWlR0CEugCIQDTN5HgZ6lj\nt8+fWEo5zpDp0etOTkYjoyH3STyOrhaaHA==\n-----END CERTIFICATE-----", + ); + // SAN DNS:a*.p.x509.sysfuzz.test, DNS:*b.p.x509.sysfuzz.test, + // DNS:x*y.p.x509.sysfuzz.test, DNS:*.*.d.x509.sysfuzz.test, + // DNS:x.*.q.x509.sysfuzz.test, DNS:*.x509.sysfuzz.test, DNS:* + // Subject CN=*.cn.x509.sysfuzz.test + const partial = new X509Certificate( + "-----BEGIN CERTIFICATE-----\nMIICUTCCAfegAwIBAgIUI8OJajUyVOgpGPBbGl3btMhxlxEwCgYIKoZIzj0EAwIw\nKDESMBAGA1UECgwJeDUwOSBmdXp6MRIwEAYDVQQDDAl4NTA5LXJvb3QwIBcNMjYw\nNzA3MTQxMzU3WhgPMjA1NjA1MTAxNDEzNTdaMDUxEjAQBgNVBAoMCXg1MDkgZnV6\nejEfMB0GA1UEAwwWKi5jbi54NTA5LnN5c2Z1enoudGVzdDBZMBMGByqGSM49AgEG\nCCqGSM49AwEHA0IABOnuFeF++N+Qy8NxJAUGnOAloNR/LX3f0I5ecGaoYJ42FMTg\n+8QjW4XzomIcIbR0XWJlhY7nLzOUCqA39keMtDSjge8wgewwgZ4GA1UdEQSBljCB\nk4IWYSoucC54NTA5LnN5c2Z1enoudGVzdIIWKmIucC54NTA5LnN5c2Z1enoudGVz\ndIIXeCp5LnAueDUwOS5zeXNmdXp6LnRlc3SCFyouKi5kLng1MDkuc3lzZnV6ei50\nZXN0ghd4LioucS54NTA5LnN5c2Z1enoudGVzdIITKi54NTA5LnN5c2Z1enoudGVz\ndIIBKjAJBgNVHRMEAjAAMB0GA1UdDgQWBBRbmxKfWC2X1A4DK5R1vggulknvozAf\nBgNVHSMEGDAWgBSbdzJICFick/W30WdujMMAYUk+SDAKBggqhkjOPQQDAgNIADBF\nAiEAvi2R0+115bGx19tn4WE//otIYAvwABsYCl2m3c3kbbgCIHSFczf0ONTAStTB\n19xWrPEssLALT86xmJZ3N7ePIrH3\n-----END CERTIFICATE-----", + ); + // SAN email:san.first@x509.sysfuzz.test, email:UPPER@CASE.x509.sysfuzz.test, + // DNS:email.x509.sysfuzz.test + // Subject CN=email.x509.sysfuzz.test, emailAddress=Subject.Mail@x509.sysfuzz.test + const email = new X509Certificate( + "-----BEGIN CERTIFICATE-----\nMIICUzCCAfqgAwIBAgIUI8OJajUyVOgpGPBbGl3btMhxlxIwCgYIKoZIzj0EAwIw\nKDESMBAGA1UECgwJeDUwOSBmdXp6MRIwEAYDVQQDDAl4NTA5LXJvb3QwIBcNMjYw\nNzA3MTQxMzU3WhgPMjA1NjA1MTAxNDEzNTdaMGUxEjAQBgNVBAoMCXg1MDkgZnV6\nejEgMB4GA1UEAwwXZW1haWwueDUwOS5zeXNmdXp6LnRlc3QxLTArBgkqhkiG9w0B\nCQEWHlN1YmplY3QuTWFpbEB4NTA5LnN5c2Z1enoudGVzdDBZMBMGByqGSM49AgEG\nCCqGSM49AwEHA0IABGkEy0ruQIsxpaobHzdCFlqb58Rho+OMDDGx9PEZBeFCVo+E\n3ctQ49W12DWNFcVdfHzojZ5ygLITNEJYlknUFAujgcIwgb8wXQYDVR0RBFYwVIEb\nc2FuLmZpcnN0QHg1MDkuc3lzZnV6ei50ZXN0gRxVUFBFUkBDQVNFLng1MDkuc3lz\nZnV6ei50ZXN0ghdlbWFpbC54NTA5LnN5c2Z1enoudGVzdDAJBgNVHRMEAjAAMBMG\nA1UdJQQMMAoGCCsGAQUFBwMEMB0GA1UdDgQWBBQaItMJT9+3JxiJken8yoPXhoM3\nSjAfBgNVHSMEGDAWgBSbdzJICFick/W30WdujMMAYUk+SDAKBggqhkjOPQQDAgNH\nADBEAiAONNhX7HU5PI2PAuJknyl/6dPGNZ1LPepWJcTxmuHUDwIgTZXdaT2f6dns\n65C/F6daxWrpj3rMBCGdsxj0EiH7emQ=\n-----END CERTIFICATE-----", + ); + + test("subject: 'always' checks the subject CN even when a DNS SAN is present", () => { + expect(wild.checkHost("wild-cn-unused.example", { subject: "always" })).toBe("wild-cn-unused.example"); + expect(wild.checkHost("wild-cn-unused.example", { subject: "default" })).toBeUndefined(); + expect(wild.checkHost("wild-cn-unused.example")).toBeUndefined(); + // Wildcard CN entries match too. + expect(partial.checkHost("foo.cn.x509.sysfuzz.test", { subject: "always" })).toBe("*.cn.x509.sysfuzz.test"); + expect(partial.checkHost("foo.cn.x509.sysfuzz.test")).toBeUndefined(); + }); + + test("partialWildcards defaults to true and matches 'a*.' / '*b.' SAN patterns", () => { + expect(partial.checkHost("abc.p.x509.sysfuzz.test")).toBe("a*.p.x509.sysfuzz.test"); + expect(partial.checkHost("a.p.x509.sysfuzz.test")).toBe("a*.p.x509.sysfuzz.test"); + expect(partial.checkHost("xb.p.x509.sysfuzz.test", { partialWildcards: true })).toBe("*b.p.x509.sysfuzz.test"); + expect(partial.checkHost("b.p.x509.sysfuzz.test")).toBe("*b.p.x509.sysfuzz.test"); + // 'x*y' (star neither at start nor end of the label) never matches. + expect(partial.checkHost("xZZy.p.x509.sysfuzz.test")).toBeUndefined(); + expect(partial.checkHost("xZZy.p.x509.sysfuzz.test", { partialWildcards: true })).toBeUndefined(); + }); + + test("partialWildcards: false restricts '*' to a full label", () => { + expect(partial.checkHost("abc.p.x509.sysfuzz.test", { partialWildcards: false })).toBeUndefined(); + expect(partial.checkHost("xb.p.x509.sysfuzz.test", { partialWildcards: false })).toBeUndefined(); + // Full-label wildcards still work. + expect(partial.checkHost("p.x509.sysfuzz.test", { partialWildcards: false })).toBe("*.x509.sysfuzz.test"); + }); + + test("multiLabelWildcards: true lets a full-label '*' span multiple labels", () => { + expect(wild.checkHost("a.b.w.x509.sysfuzz.test", { multiLabelWildcards: true })).toBe("*.w.x509.sysfuzz.test"); + expect(wild.checkHost("a.b.c.w.x509.sysfuzz.test", { multiLabelWildcards: true })).toBe("*.w.x509.sysfuzz.test"); + expect(wild.checkHost("a.b.w.x509.sysfuzz.test")).toBeUndefined(); + expect(wild.checkHost("a.b.w.x509.sysfuzz.test", { multiLabelWildcards: false })).toBeUndefined(); + // Only applies to full-label '*.' patterns: 'a*.' cannot span labels, so + // 'a.b.p...' falls through to '*.x509.sysfuzz.test'. + expect(partial.checkHost("a.b.p.x509.sysfuzz.test", { multiLabelWildcards: true })).toBe("*.x509.sysfuzz.test"); + }); + + test("singleLabelSubdomains limits how far '.suffix' inputs can reach", () => { + // Input starting with '.' matches any SAN that ends in that suffix. + expect(partial.checkHost(".x509.sysfuzz.test")).toBe("a*.p.x509.sysfuzz.test"); + expect(partial.checkHost(".sysfuzz.test")).toBe("a*.p.x509.sysfuzz.test"); + // With singleLabelSubdomains the SAN may only have one extra label. + expect(partial.checkHost(".sysfuzz.test", { singleLabelSubdomains: true })).toBeUndefined(); + expect(partial.checkHost("foo.x509.sysfuzz.test", { singleLabelSubdomains: true })).toBe("*.x509.sysfuzz.test"); + }); + + test("patterns with a wildcard outside the first label are never valid", () => { + expect(partial.checkHost("a.b.d.x509.sysfuzz.test")).toBeUndefined(); + expect(partial.checkHost("x.a.q.x509.sysfuzz.test")).toBeUndefined(); + expect(partial.checkHost("anything")).toBeUndefined(); + }); + + test("checkEmail subject: 'always' checks the subject emailAddress", () => { + expect(email.checkEmail("Subject.Mail@x509.sysfuzz.test", { subject: "always" })).toBe( + "Subject.Mail@x509.sysfuzz.test", + ); + expect(email.checkEmail("Subject.Mail@x509.sysfuzz.test")).toBeUndefined(); + expect(email.checkEmail("Subject.Mail@x509.sysfuzz.test", { subject: "never" })).toBeUndefined(); + // SAN email entries always match; local-part is case-sensitive, domain is not. + expect(email.checkEmail("san.first@x509.sysfuzz.test")).toBe("san.first@x509.sysfuzz.test"); + expect(email.checkEmail("UPPER@CASE.x509.sysfuzz.test")).toBe("UPPER@CASE.x509.sysfuzz.test"); + expect(email.checkEmail("upper@case.x509.sysfuzz.test")).toBeUndefined(); + }); + + test("an empty options object is accepted", () => { + expect(wild.checkHost("foo.w.x509.sysfuzz.test", {})).toBe("*.w.x509.sysfuzz.test"); + expect(email.checkEmail("san.first@x509.sysfuzz.test", {})).toBe("san.first@x509.sysfuzz.test"); + }); + + test("embedded NUL in the name is rejected", () => { + expect(() => wild.checkHost("agent\x001")).toThrow(expect.objectContaining({ code: "ERR_INVALID_ARG_VALUE" })); + expect(() => email.checkEmail("not\x00hing")).toThrow(expect.objectContaining({ code: "ERR_INVALID_ARG_VALUE" })); + }); +}); From e53caa57ff409898827c927a91e7b50179d11940 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:21:08 +0000 Subject: [PATCH 2/6] ci: retrigger From 071be669366cf56713ebd747b4d4061c8358d778 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:37:50 +0000 Subject: [PATCH 3/6] ncrypto: drop unreachable case -2 in checkHost/checkEmail 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. --- src/jsc/bindings/ncrypto.cpp | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/jsc/bindings/ncrypto.cpp b/src/jsc/bindings/ncrypto.cpp index 4a2b68582b31..1caa69fe0624 100644 --- a/src/jsc/bindings/ncrypto.cpp +++ b/src/jsc/bindings/ncrypto.cpp @@ -1550,8 +1550,6 @@ X509View::CheckMatch X509View::checkHost(const std::span host, } return CheckMatch::MATCH; } - case -2: - return CheckMatch::INVALID_NAME; default: return CheckMatch::OPERATION_FAILED; } @@ -1571,8 +1569,6 @@ X509View::CheckMatch X509View::checkEmail(const std::span email, return CheckMatch::NO_MATCH; case 1: return CheckMatch::MATCH; - case -2: - return CheckMatch::INVALID_NAME; default: return CheckMatch::OPERATION_FAILED; } From 50b03c582c571e9554368a13f6a2bdf8549647e1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:06:00 +0000 Subject: [PATCH 4/6] test: cover default subject fallback when SAN has no entries of the relevant 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). --- test/js/node/crypto/x509.test.ts | 48 ++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/test/js/node/crypto/x509.test.ts b/test/js/node/crypto/x509.test.ts index f24db271c24b..ad542f140d11 100644 --- a/test/js/node/crypto/x509.test.ts +++ b/test/js/node/crypto/x509.test.ts @@ -170,3 +170,51 @@ describe("X509Certificate.checkHost() / checkEmail() options", () => { expect(() => email.checkEmail("not\x00hing")).toThrow(expect.objectContaining({ code: "ERR_INVALID_ARG_VALUE" })); }); }); + +// OpenSSL's default subject-fallback rule checks the subject CN/emailAddress +// whenever the certificate has no SAN of the *relevant* type. BoringSSL's +// X509_check_host/X509_check_email skip the subject as soon as the SAN +// extension is present at all. Every expected value below is what Node.js +// (OpenSSL) returns for the same call. +describe("X509Certificate.checkHost() / checkEmail() default subject fallback", () => { + // Subject CN=host.a.test, emailAddress=subj@a.test + // subjectAltName: email:san@a.test (no DNS entry) + const emailOnlySan = new X509Certificate( + "-----BEGIN CERTIFICATE-----\nMIIBbDCCARKgAwIBAgIBBTAKBggqhkjOPQQDAjAyMRowGAYJKoZIhvcNAQkBFgtz\ndWJqQGEudGVzdDEUMBIGA1UEAwwLaG9zdC5hLnRlc3QwHhcNMjQwMTAxMDAwMDAw\nWhcNMzQwMTAxMDAwMDAwWjAyMRowGAYJKoZIhvcNAQkBFgtzdWJqQGEudGVzdDEU\nMBIGA1UEAwwLaG9zdC5hLnRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATA\n8oSTaHTNhZd7xBzP0wK4G63rTE3n+3VgLWtJ094xIurJGsb/Szp1zwlWvs1UqMkD\ns4ggDNQIOV4F/C6H7w+aoxkwFzAVBgNVHREEDjAMgQpzYW5AYS50ZXN0MAoGCCqG\nSM49BAMCA0gAMEUCIQDaDemParcnR+082ph2x+2WLqnX2pE/ekPTaH1ZNcs+9QIg\nNucFI8ZLT94vxoI+iChvM1epdnyPMj0GHxnj9eUgYX0=\n-----END CERTIFICATE-----", + ); + // Subject CN=host.b.test, emailAddress=hidden@b.test + // subjectAltName: DNS:host.b.test (no email entry) + const dnsOnlySan = new X509Certificate( + "-----BEGIN CERTIFICATE-----\nMIIBcTCCARegAwIBAgIBBTAKBggqhkjOPQQDAjA0MRwwGgYJKoZIhvcNAQkBFg1o\naWRkZW5AYi50ZXN0MRQwEgYDVQQDDAtob3N0LmIudGVzdDAeFw0yNDAxMDEwMDAw\nMDBaFw0zNDAxMDEwMDAwMDBaMDQxHDAaBgkqhkiG9w0BCQEWDWhpZGRlbkBiLnRl\nc3QxFDASBgNVBAMMC2hvc3QuYi50ZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcD\nQgAEwPKEk2h0zYWXe8Qcz9MCuBut60xN5/t1YC1rSdPeMSLqyRrG/0s6dc8JVr7N\nVKjJA7OIIAzUCDleBfwuh+8PmqMaMBgwFgYDVR0RBA8wDYILaG9zdC5iLnRlc3Qw\nCgYIKoZIzj0EAwIDSAAwRQIgLpQQaN9ewR4/libViPKpYMf4v1+3LAJzR91eoIdo\nXP8CIQCBAsQh2Co3UqUWhLzg6cMiB8UsMvJ1fXDUryyBQ6LmYA==\n-----END CERTIFICATE-----", + ); + // Subject CN=host.c.test, emailAddress=subj@c.test + // subjectAltName: present but an empty sequence + const emptySan = new X509Certificate( + "-----BEGIN CERTIFICATE-----\nMIIBYTCCAQagAwIBAgIBBTAKBggqhkjOPQQDAjAyMRowGAYJKoZIhvcNAQkBFgtz\ndWJqQGMudGVzdDEUMBIGA1UEAwwLaG9zdC5jLnRlc3QwHhcNMjQwMTAxMDAwMDAw\nWhcNMzQwMTAxMDAwMDAwWjAyMRowGAYJKoZIhvcNAQkBFgtzdWJqQGMudGVzdDEU\nMBIGA1UEAwwLaG9zdC5jLnRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATA\n8oSTaHTNhZd7xBzP0wK4G63rTE3n+3VgLWtJ094xIurJGsb/Szp1zwlWvs1UqMkD\ns4ggDNQIOV4F/C6H7w+aow0wCzAJBgNVHREEAjAAMAoGCCqGSM49BAMCA0kAMEYC\nIQDNDUdpjkPlDdHOC3GfMVsyqoW8EJQwpF12pGG7Jh3+ZQIhAKR1TAT1tz+qkaRL\nWBHSHNFMPBDhzbM67wHBVQYxoESB\n-----END CERTIFICATE-----", + ); + + test("checkHost falls back to the subject CN when the SAN has no DNS entries", () => { + expect(emailOnlySan.checkHost("host.a.test")).toBe("host.a.test"); + expect(emailOnlySan.checkHost("host.a.test", { subject: "default" })).toBe("host.a.test"); + expect(emailOnlySan.checkHost("host.a.test", { subject: "never" })).toBeUndefined(); + // The SAN email entry is still honoured by checkEmail, and because there is + // an email SAN the subject emailAddress is not consulted. + expect(emailOnlySan.checkEmail("san@a.test")).toBe("san@a.test"); + expect(emailOnlySan.checkEmail("subj@a.test")).toBeUndefined(); + }); + + test("checkEmail falls back to the subject emailAddress when the SAN has no email entries", () => { + expect(dnsOnlySan.checkEmail("hidden@b.test")).toBe("hidden@b.test"); + expect(dnsOnlySan.checkEmail("hidden@b.test", { subject: "default" })).toBe("hidden@b.test"); + expect(dnsOnlySan.checkEmail("hidden@b.test", { subject: "never" })).toBeUndefined(); + // The SAN DNS entry is still honoured by checkHost. + expect(dnsOnlySan.checkHost("host.b.test")).toBe("host.b.test"); + }); + + test("an empty subjectAltName sequence still falls back to the subject", () => { + expect(emptySan.checkHost("host.c.test")).toBe("host.c.test"); + expect(emptySan.checkEmail("subj@c.test")).toBe("subj@c.test"); + expect(emptySan.checkHost("host.c.test", { subject: "never" })).toBeUndefined(); + expect(emptySan.checkEmail("subj@c.test", { subject: "never" })).toBeUndefined(); + }); +}); From 3c3ee2b45a0ae887f9eab6c3346a1ab369f0aeb8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:08:20 +0000 Subject: [PATCH 5/6] ncrypto: tighten do_x509_check port comments --- src/jsc/bindings/ncrypto.cpp | 10 ++++------ src/jsc/bindings/ncrypto.h | 6 ++---- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/jsc/bindings/ncrypto.cpp b/src/jsc/bindings/ncrypto.cpp index ec00f6650aa5..b0d9e513d4db 100644 --- a/src/jsc/bindings/ncrypto.cpp +++ b/src/jsc/bindings/ncrypto.cpp @@ -1300,12 +1300,10 @@ bool X509View::checkPublicKey(const EVPKeyPointer& pkey) const return X509_verify(const_cast(cert_), pkey.get()) == 1; } -// BoringSSL's X509_check_host/X509_check_email drop several OpenSSL options -// that Node's X509Certificate.checkHost/checkEmail expose: ALWAYS_CHECK_SUBJECT, -// NO_PARTIAL_WILDCARDS, MULTI_LABEL_WILDCARDS and SINGLE_LABEL_SUBDOMAINS are -// all #defined to 0, and the subject-DN fallback / partial-wildcard matching -// is gone. To match Node, port OpenSSL crypto/x509/v3_utl.c do_x509_check and -// its helpers here and drive them with X509View::CheckFlags. +// Port of OpenSSL crypto/x509/v3_utl.c do_x509_check and helpers. BoringSSL +// #defines several X509_CHECK_FLAG_* to 0 and skips the subject-DN fallback +// when any SAN is present, so Node's checkHost/checkEmail semantics require +// the OpenSSL logic. namespace { // Internal flag set when the caller-provided name begins with '.'. diff --git a/src/jsc/bindings/ncrypto.h b/src/jsc/bindings/ncrypto.h index 43e66469360d..8672000c097a 100644 --- a/src/jsc/bindings/ncrypto.h +++ b/src/jsc/bindings/ncrypto.h @@ -1253,10 +1253,8 @@ class X509View final { INVALID_NAME, OPERATION_FAILED, }; - // OpenSSL's X509_CHECK_FLAG_* values. BoringSSL defines several of these - // macros as 0 (no-ops), so callers that need OpenSSL semantics must use - // these constants and the checkHost/checkEmail below, which reimplement - // OpenSSL's matching rather than delegating to BoringSSL. + // OpenSSL's X509_CHECK_FLAG_* values; BoringSSL defines several of these + // to 0. checkHost/checkEmail below reimplement OpenSSL's matching. struct CheckFlags { static constexpr int ALWAYS_CHECK_SUBJECT = 0x1; static constexpr int NO_WILDCARDS = 0x2; From 87e98e6b1c75ce8f7cbbee13c3b3fc02ee868888 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:10:32 +0000 Subject: [PATCH 6/6] ncrypto: reduce port comments to a single provenance line --- src/jsc/bindings/ncrypto.cpp | 6 +----- src/jsc/bindings/ncrypto.h | 3 +-- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/jsc/bindings/ncrypto.cpp b/src/jsc/bindings/ncrypto.cpp index b0d9e513d4db..c0293a82e424 100644 --- a/src/jsc/bindings/ncrypto.cpp +++ b/src/jsc/bindings/ncrypto.cpp @@ -1300,13 +1300,9 @@ bool X509View::checkPublicKey(const EVPKeyPointer& pkey) const return X509_verify(const_cast(cert_), pkey.get()) == 1; } -// Port of OpenSSL crypto/x509/v3_utl.c do_x509_check and helpers. BoringSSL -// #defines several X509_CHECK_FLAG_* to 0 and skips the subject-DN fallback -// when any SAN is present, so Node's checkHost/checkEmail semantics require -// the OpenSSL logic. +// Ported from OpenSSL crypto/x509/v3_utl.c; BoringSSL drops these CheckFlags. namespace { -// Internal flag set when the caller-provided name begins with '.'. constexpr int kDotSubdomainsFlag = 0x8000; using CheckFlags = X509View::CheckFlags; diff --git a/src/jsc/bindings/ncrypto.h b/src/jsc/bindings/ncrypto.h index 8672000c097a..b0d178541398 100644 --- a/src/jsc/bindings/ncrypto.h +++ b/src/jsc/bindings/ncrypto.h @@ -1253,8 +1253,7 @@ class X509View final { INVALID_NAME, OPERATION_FAILED, }; - // OpenSSL's X509_CHECK_FLAG_* values; BoringSSL defines several of these - // to 0. checkHost/checkEmail below reimplement OpenSSL's matching. + // OpenSSL's X509_CHECK_FLAG_* bit values (BoringSSL #defines several to 0). struct CheckFlags { static constexpr int ALWAYS_CHECK_SUBJECT = 0x1; static constexpr int NO_WILDCARDS = 0x2;