diff --git a/src/jsc/bindings/JSX509CertificatePrototype.cpp b/src/jsc/bindings/JSX509CertificatePrototype.cpp index 25a143dc5d79..ef2326a87129 100644 --- a/src/jsc/bindings/JSX509CertificatePrototype.cpp +++ b/src/jsc/bindings/JSX509CertificatePrototype.cpp @@ -169,11 +169,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; @@ -184,9 +183,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 { @@ -196,51 +195,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 aeb428c7cdf8..c0293a82e424 100644 --- a/src/jsc/bindings/ncrypto.cpp +++ b/src/jsc/bindings/ncrypto.cpp @@ -1300,15 +1300,266 @@ bool X509View::checkPublicKey(const EVPKeyPointer& pkey) const return X509_verify(const_cast(cert_), pkey.get()) == 1; } +// Ported from OpenSSL crypto/x509/v3_utl.c; BoringSSL drops these CheckFlags. +namespace { + +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: { @@ -1318,8 +1569,6 @@ X509View::CheckMatch X509View::checkHost(const std::span host, } return CheckMatch::MATCH; } - case -2: - return CheckMatch::INVALID_NAME; default: return CheckMatch::OPERATION_FAILED; } @@ -1330,14 +1579,15 @@ 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: return CheckMatch::MATCH; - case -2: - return CheckMatch::INVALID_NAME; default: return CheckMatch::OPERATION_FAILED; } diff --git a/src/jsc/bindings/ncrypto.h b/src/jsc/bindings/ncrypto.h index e8f51a20391f..b0d178541398 100644 --- a/src/jsc/bindings/ncrypto.h +++ b/src/jsc/bindings/ncrypto.h @@ -1253,6 +1253,15 @@ class X509View final { INVALID_NAME, OPERATION_FAILED, }; + // 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; + 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..ad542f140d11 100644 --- a/test/js/node/crypto/x509.test.ts +++ b/test/js/node/crypto/x509.test.ts @@ -72,3 +72,149 @@ 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" })); + }); +}); + +// 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(); + }); +});