diff --git a/patches/boringssl/name-constraints-common-name.patch b/patches/boringssl/name-constraints-common-name.patch new file mode 100644 index 000000000000..cbeaa8232b89 --- /dev/null +++ b/patches/boringssl/name-constraints-common-name.patch @@ -0,0 +1,212 @@ +--- a/crypto/x509/internal.h ++++ b/crypto/x509/internal.h +@@ -500,6 +500,15 @@ int x509v3_conf_name_matches(const char *name, const char *cmp); + OPENSSL_EXPORT int x509v3_looks_like_dns_name(const unsigned char *in, + size_t len); + ++// x509_check_common_name_constraints returns `X509_V_OK` if `x` has a dNSName ++// subjectAltName, or if every subject commonName of `x` that looks like a host ++// name or wildcard pattern (see `x509v3_looks_like_dns_name`) satisfies the DNS ++// name constraints in `nc`. Otherwise it returns an `X509_V_ERR_*` value ++// describing the violation. This is the counterpart of OpenSSL's ++// `NAME_CONSTRAINTS_check_CN`. ++int x509_check_common_name_constraints(const X509 *x, ++ const NAME_CONSTRAINTS *nc); ++ + // x509v3_cache_extensions fills in a number of fields relating to X.509 + // extensions in `x`. It returns one on success and zero if some extensions were + // invalid. +--- a/crypto/x509/v3_ncons.cc ++++ b/crypto/x509/v3_ncons.cc +@@ -796,3 +796,98 @@ int NAME_CONSTRAINTS_check(const X509 *x, const NAME_CONSTRAINTS *nc) { + + return X509_V_OK; + } ++ ++// Besides whole-label wildcards ("*.example.com"), common-name host matching in ++// the style of Node.js's `tls.checkServerIdentity` honours a single partial ++// wildcard in the leftmost label ("w*.example.com"), which ++// `x509v3_looks_like_dns_name` does not recognise. Widen such a label to "*" so ++// the name is recognised and `nc_dns` constrains it like the whole-label ++// wildcard that covers it. ++static void widen_partial_wildcard_label(unsigned char **inout, ++ size_t *inout_len) { ++ unsigned char *in = *inout; ++ size_t len = *inout_len; ++ size_t label_len = 0, stars = 0; ++ for (; label_len < len && in[label_len] != '.'; label_len++) { ++ unsigned char c = in[label_len]; ++ if (c == '*') { ++ stars++; ++ } else if (!OPENSSL_isalnum(c) && c != '-' && c != '_') { ++ return; ++ } ++ } ++ if (stars != 1 || label_len < 2) { ++ return; ++ } ++ in += label_len - 1; ++ in[0] = '*'; ++ *inout = in; ++ *inout_len = len - (label_len - 1); ++} ++ ++// OpenSSL's `cn2dnsid` only treats a commonName with at least two labels as a ++// DNS identifier. ++static bool has_inner_dot(const unsigned char *in, size_t len) { ++ if (len > 0 && in[len - 1] == '.') { ++ len--; ++ } ++ return OPENSSL_memchr(in, '.', len) != nullptr; ++} ++ ++int bssl::x509_check_common_name_constraints(const X509 *x, ++ const NAME_CONSTRAINTS *nc) { ++ const auto *x_impl = FromOpaque(x); ++ for (const GENERAL_NAME *gen : x_impl->altname) { ++ if (gen->type == GEN_DNS) { ++ return X509_V_OK; ++ } ++ } ++ ++ const X509_NAME *name = X509_get_subject_name(x); ++ for (int i = -1;;) { ++ i = X509_NAME_get_index_by_NID(name, NID_commonName, i); ++ if (i == -1) { ++ return X509_V_OK; ++ } ++ ++ const ASN1_STRING *common_name = ++ X509_NAME_ENTRY_get_data(X509_NAME_get_entry(name, i)); ++ unsigned char *idval; ++ int idlen = ASN1_STRING_to_UTF8(&idval, common_name); ++ if (idlen < 0) { ++ return X509_V_ERR_OUT_OF_MEM; ++ } ++ UniquePtr free_idval(idval); ++ unsigned char *dns_id = idval; ++ size_t dns_id_len = (size_t)idlen; ++ while (dns_id_len > 0 && dns_id[dns_id_len - 1] == '\0') { ++ dns_id_len--; ++ } ++ if (OPENSSL_memchr(dns_id, '\0', dns_id_len) != nullptr) { ++ return X509_V_ERR_UNSUPPORTED_NAME_SYNTAX; ++ } ++ // On a certificate without a SAN extension this must cover every name ++ // `X509_check_host` may match against the common name, i.e. ++ // `x509v3_looks_like_dns_name`. Next to a SAN extension only Node.js-style ++ // matching consults the common name, and there, as in OpenSSL, a ++ // single-label name is not taken to be a DNS identifier. ++ widen_partial_wildcard_label(&dns_id, &dns_id_len); ++ if (!x509v3_looks_like_dns_name(dns_id, dns_id_len) || ++ (x_impl->altname != nullptr && !has_inner_dot(dns_id, dns_id_len))) { ++ continue; ++ } ++ ++ ASN1_IA5STRING dns_name; ++ dns_name.type = V_ASN1_IA5STRING; ++ dns_name.data = dns_id; ++ dns_name.length = (int)dns_id_len; ++ dns_name.flags = 0; ++ GENERAL_NAME gntmp; ++ gntmp.type = GEN_DNS; ++ gntmp.d.dNSName = &dns_name; ++ int r = nc_match(&gntmp, nc, /*case_insensitive_exclude_localpart=*/false); ++ if (r != X509_V_OK) { ++ return r; ++ } ++ } ++} +--- a/crypto/x509/x509_vfy.cc ++++ b/crypto/x509/x509_vfy.cc +@@ -518,35 +518,8 @@ static int check_chain_extensions(X509_STORE_CTX *ctx) { + return 1; + } + +-static int reject_dns_name_in_common_name(X509 *x509) { +- const X509_NAME *name = X509_get_subject_name(x509); +- int i = -1; +- for (;;) { +- i = X509_NAME_get_index_by_NID(name, NID_commonName, i); +- if (i == -1) { +- return X509_V_OK; +- } +- +- const X509_NAME_ENTRY *entry = X509_NAME_get_entry(name, i); +- const ASN1_STRING *common_name = X509_NAME_ENTRY_get_data(entry); +- unsigned char *idval; +- int idlen = ASN1_STRING_to_UTF8(&idval, common_name); +- if (idlen < 0) { +- return X509_V_ERR_OUT_OF_MEM; +- } +- // Only process attributes that look like host names. Note it is +- // important that this check be mirrored in `X509_check_host`. +- int looks_like_dns = x509v3_looks_like_dns_name(idval, (size_t)idlen); +- OPENSSL_free(idval); +- if (looks_like_dns) { +- return X509_V_ERR_NAME_CONSTRAINTS_WITHOUT_SANS; +- } +- } +-} +- + static int check_name_constraints(X509_STORE_CTX *ctx) { + int i, j, rv; +- int has_name_constraints = 0; + // Check name constraints for all certificates + for (i = (int)sk_X509_num(ctx->chain) - 1; i >= 0; i--) { + X509Impl *x = FromOpaque(sk_X509_value(ctx->chain, i)); +@@ -561,8 +534,16 @@ static int check_name_constraints(X509_STORE_CTX *ctx) { + for (j = (int)sk_X509_num(ctx->chain) - 1; j > i; j--) { + NAME_CONSTRAINTS *nc = FromOpaque(sk_X509_value(ctx->chain, j))->nc; + if (nc) { +- has_name_constraints = 1; + rv = NAME_CONSTRAINTS_check(x, nc); ++ // Bun matches host names against the subject common name whenever the ++ // leaf has no dNSName SAN (Node.js semantics), not only when it has no ++ // SAN extension as `X509_check_host` does. So, like OpenSSL's ++ // `NAME_CONSTRAINTS_check_CN`, hold host-name-like common names to ++ // the issuer's constraints in exactly that case, instead of upstream's ++ // outright rejection of DNS-like common names on SAN-less leaves. ++ if (rv == X509_V_OK && i == 0) { ++ rv = x509_check_common_name_constraints(x, nc); ++ } + switch (rv) { + case X509_V_OK: + continue; +@@ -582,36 +563,6 @@ static int check_name_constraints(X509_STORE_CTX *ctx) { + } + } + +- // Name constraints do not match against the common name, but +- // `X509_check_host` still implements the legacy behavior where, on +- // certificates lacking a SAN list, DNS-like names in the common name are +- // checked instead. +- // +- // While we could apply the name constraints to the common name, name +- // constraints are rare enough that can hold such certificates to a higher +- // standard. Note this does not make "DNS-like" heuristic failures any +- // worse. A decorative common-name misidentified as a DNS name would fail +- // the name constraint anyway. +- X509Impl *leaf = FromOpaque(sk_X509_value(ctx->chain, 0)); +- if (has_name_constraints && leaf->altname == nullptr) { +- rv = reject_dns_name_in_common_name(leaf); +- switch (rv) { +- case X509_V_OK: +- break; +- case X509_V_ERR_OUT_OF_MEM: +- ctx->error = rv; +- return 0; +- default: +- ctx->error = rv; +- ctx->error_depth = 0; +- ctx->current_cert = leaf; +- if (!call_verify_cb(0, ctx)) { +- return 0; +- } +- break; +- } +- } +- + return 1; + } + diff --git a/scripts/build/deps/boringssl.ts b/scripts/build/deps/boringssl.ts index 395739e710bc..0408077ba683 100644 --- a/scripts/build/deps/boringssl.ts +++ b/scripts/build/deps/boringssl.ts @@ -36,10 +36,18 @@ export const boringssl: Dependency = { commit: BORINGSSL_COMMIT, }), - // Upstream mem.cc gates OPENSSL_memory_* weak-symbol overrides on __ELF__; - // on Mach-O/COFF the hooks compile to static nullptr and OPENSSL_malloc goes - // straight to libc. Declare them as plain externs so lib.rs binds everywhere. - patches: ["patches/boringssl/require-memory-hooks.patch"], + // require-memory-hooks: upstream mem.cc gates OPENSSL_memory_* weak-symbol + // overrides on __ELF__; on Mach-O/COFF the hooks compile to static nullptr + // and OPENSSL_malloc goes straight to libc. Declare them as plain externs so + // lib.rs binds everywhere. + // + // name-constraints-common-name: Bun matches host names against the subject + // CN whenever a certificate has no DNS subjectAltName (Node.js semantics), so + // chain verification applies issuer nameConstraints to DNS-like CNs in that + // same case, as OpenSSL's NAME_CONSTRAINTS_check_CN does, instead of + // upstream's rule of rejecting DNS-like CNs only on leaves with no + // subjectAltName extension at all. + patches: ["patches/boringssl/require-memory-hooks.patch", "patches/boringssl/name-constraints-common-name.patch"], build: cfg => { // win-x64 uses NASM-syntax .asm; everything else (including win-aarch64) diff --git a/test/js/node/tls/fixtures/name-constraints-ca-cert.pem b/test/js/node/tls/fixtures/name-constraints-ca-cert.pem new file mode 100644 index 000000000000..138041df8f74 --- /dev/null +++ b/test/js/node/tls/fixtures/name-constraints-ca-cert.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBuDCCAV+gAwIBAgIUVKHMxICllpAN21+HWQPtyyRurIAwCgYIKoZIzj0EAwIw +KTEnMCUGA1UEAwweQnVuIFRlc3QgTmFtZSBDb25zdHJhaW50cyBSb290MCAXDTI2 +MDgxMTIxMDgzNloYDzIxMjYwNzE4MjEwODM2WjApMScwJQYDVQQDDB5CdW4gVGVz +dCBOYW1lIENvbnN0cmFpbnRzIFJvb3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNC +AAQcnam91voxL/8oO6QTPF52U+CJgGd/RMj9Ue/Q/goGIvNx4MeakecEgK/CLh5u +zRQpzI+bQtjkHQ9oTulY0xipo2MwYTAdBgNVHQ4EFgQUmmJNDhqQodWArSf+X9gD +Zu7l1BAwHwYDVR0jBBgwFoAUmmJNDhqQodWArSf+X9gDZu7l1BAwDwYDVR0TAQH/ +BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwIDRwAwRAIgZql0mdHb +taW/kT8j+bIwm7Dwoslz1Q7jlQRhYkBQis0CIEYPgwCAUvEo/T417f6e/lbtZwK8 +ZIf2bC0tX11zUBGK +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/name-constraints-inside-cert.pem b/test/js/node/tls/fixtures/name-constraints-inside-cert.pem new file mode 100644 index 000000000000..ee4e81cce349 --- /dev/null +++ b/test/js/node/tls/fixtures/name-constraints-inside-cert.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB6DCCAY2gAwIBAgIUE+PjIf+ta0faC686AZ8pQjqAQaMwCgYIKoZIzj0EAwIw +MTEvMC0GA1UEAwwmQnVuIFRlc3QgTmFtZSBDb25zdHJhaW50cyBJbnRlcm1lZGlh +dGUwIBcNMjYwODEyMDQxNTM3WhgPMjEyNjA3MTkwNDE1MzdaMBwxGjAYBgNVBAMM +EWhvc3QuYWxsb3dlZC50ZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAERtB7 +devtyXGFRn4GJTl3ZS2+24tEXr/rPcHW9K6KeZU48rHp5uWhJLn4dn8or5scB9kp +b0smMX9IHy2IRkWw9KOBlTCBkjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIH +gDATBgNVHSUEDDAKBggrBgEFBQcDATAdBgNVHREEFjAUgRJhZG1pbkBhbGxvd2Vk +LnRlc3QwHQYDVR0OBBYEFO/uSMn4KKqx6iVdjtcpIGi/lOslMB8GA1UdIwQYMBaA +FLoeTxokk43jgb2AHyBOzOaAbZDJMAoGCCqGSM49BAMCA0kAMEYCIQDACRFh4mZ1 +cv0phA6Xi+qMYJxBpI+iBzyWrkZuTTWjTwIhAJ/ZvIgM/isMOGLdmMtGqzJgIYPM +pLyt+Thcy6W078z0 +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/name-constraints-intermediate-cert.pem b/test/js/node/tls/fixtures/name-constraints-intermediate-cert.pem new file mode 100644 index 000000000000..307ddc5f311e --- /dev/null +++ b/test/js/node/tls/fixtures/name-constraints-intermediate-cert.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB5DCCAYqgAwIBAgIUWXyy8H2kPNJKVeWgzwC3lTsvOckwCgYIKoZIzj0EAwIw +KTEnMCUGA1UEAwweQnVuIFRlc3QgTmFtZSBDb25zdHJhaW50cyBSb290MCAXDTI2 +MDgxMTIxMDgzNloYDzIxMjYwNzE4MjEwODM2WjAxMS8wLQYDVQQDDCZCdW4gVGVz +dCBOYW1lIENvbnN0cmFpbnRzIEludGVybWVkaWF0ZTBZMBMGByqGSM49AgEGCCqG +SM49AwEHA0IABOLG+3rVfYKgXiiF+Y2CJtxK3G6LUoUyz2FGBfimd68IDHcHoDEL +873O9azkd0Xy31EzWb6MQeALUaxd9FZ2bqajgYUwgYIwDwYDVR0TAQH/BAUwAwEB +/zAOBgNVHQ8BAf8EBAMCAQYwHwYDVR0eAQH/BBUwE6ARMA+CDS5hbGxvd2VkLnRl +c3QwHQYDVR0OBBYEFLoeTxokk43jgb2AHyBOzOaAbZDJMB8GA1UdIwQYMBaAFJpi +TQ4akKHVgK0n/l/YA2bu5dQQMAoGCCqGSM49BAMCA0gAMEUCIB5FbK553jZP6fjs +50uLBqYWaFDO6ar/Imo062RHQNd5AiEAxG2GTsuLNAn++u0sVbq7xBQNPXy7gO97 +6m6xGuJhtSY= +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/name-constraints-leaf-key.pem b/test/js/node/tls/fixtures/name-constraints-leaf-key.pem new file mode 100644 index 000000000000..319e838ea19b --- /dev/null +++ b/test/js/node/tls/fixtures/name-constraints-leaf-key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIJER7WT8GvwjdUt0eAtX6CgEJwyMFBJa8KEY0m3VVMfwoAoGCCqGSM49 +AwEHoUQDQgAERtB7devtyXGFRn4GJTl3ZS2+24tEXr/rPcHW9K6KeZU48rHp5uWh +JLn4dn8or5scB9kpb0smMX9IHy2IRkWw9A== +-----END EC PRIVATE KEY----- diff --git a/test/js/node/tls/fixtures/name-constraints-nosan-cert.pem b/test/js/node/tls/fixtures/name-constraints-nosan-cert.pem new file mode 100644 index 000000000000..12bbd89f58b2 --- /dev/null +++ b/test/js/node/tls/fixtures/name-constraints-nosan-cert.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIByDCCAW6gAwIBAgIUE+PjIf+ta0faC686AZ8pQjqAQacwCgYIKoZIzj0EAwIw +MTEvMC0GA1UEAwwmQnVuIFRlc3QgTmFtZSBDb25zdHJhaW50cyBJbnRlcm1lZGlh +dGUwIBcNMjYwODEyMDQxNTM3WhgPMjEyNjA3MTkwNDE1MzdaMB4xHDAaBgNVBAMM +E2xlZ2FjeS5hbGxvd2VkLnRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARG +0Ht16+3JcYVGfgYlOXdlLb7bi0Rev+s9wdb0rop5lTjysenm5aEkufh2fyivmxwH +2SlvSyYxf0gfLYhGRbD0o3UwczAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIH +gDATBgNVHSUEDDAKBggrBgEFBQcDATAdBgNVHQ4EFgQU7+5IyfgoqrHqJV2O1ykg +aL+U6yUwHwYDVR0jBBgwFoAUuh5PGiSTjeOBvYAfIE7M5oBtkMkwCgYIKoZIzj0E +AwIDSAAwRQIhAL64zqFHmLmUWlA5IItDMgmAc8iYpdAJurdebhNPAszNAiBoMnvS +h6rPV8kzI6vtuSO2uCt7W1CBYs+bgBrxgW4E9Q== +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/name-constraints-outside-cert.pem b/test/js/node/tls/fixtures/name-constraints-outside-cert.pem new file mode 100644 index 000000000000..c4fce46241dd --- /dev/null +++ b/test/js/node/tls/fixtures/name-constraints-outside-cert.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB6jCCAZCgAwIBAgIUE+PjIf+ta0faC686AZ8pQjqAQaQwCgYIKoZIzj0EAwIw +MTEvMC0GA1UEAwwmQnVuIFRlc3QgTmFtZSBDb25zdHJhaW50cyBJbnRlcm1lZGlh +dGUwIBcNMjYwODEyMDQxNTM3WhgPMjEyNjA3MTkwNDE1MzdaMB8xHTAbBgNVBAMM +FG91dHNpZGUuZXhhbXBsZS50ZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE +RtB7devtyXGFRn4GJTl3ZS2+24tEXr/rPcHW9K6KeZU48rHp5uWhJLn4dn8or5sc +B9kpb0smMX9IHy2IRkWw9KOBlTCBkjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQE +AwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATAdBgNVHREEFjAUgRJhZG1pbkBhbGxv +d2VkLnRlc3QwHQYDVR0OBBYEFO/uSMn4KKqx6iVdjtcpIGi/lOslMB8GA1UdIwQY +MBaAFLoeTxokk43jgb2AHyBOzOaAbZDJMAoGCCqGSM49BAMCA0gAMEUCIQDzFaAT +evcCGiW9suwp7R2qIXUu9+RdFRR4st0oFZ5KQQIgJaXSfFICB+Gf79wy0I3SkQtZ +p62qe+UcK6JxY4/dlUw= +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/name-constraints-partial-wildcard-cert.pem b/test/js/node/tls/fixtures/name-constraints-partial-wildcard-cert.pem new file mode 100644 index 000000000000..077135bb5694 --- /dev/null +++ b/test/js/node/tls/fixtures/name-constraints-partial-wildcard-cert.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB5DCCAYugAwIBAgIUE+PjIf+ta0faC686AZ8pQjqAQaYwCgYIKoZIzj0EAwIw +MTEvMC0GA1UEAwwmQnVuIFRlc3QgTmFtZSBDb25zdHJhaW50cyBJbnRlcm1lZGlh +dGUwIBcNMjYwODEyMDQxNTM3WhgPMjEyNjA3MTkwNDE1MzdaMBoxGDAWBgNVBAMM +D3cqLmV4YW1wbGUudGVzdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABEbQe3Xr +7clxhUZ+BiU5d2UtvtuLRF6/6z3B1vSuinmVOPKx6ebloSS5+HZ/KK+bHAfZKW9L +JjF/SB8tiEZFsPSjgZUwgZIwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4Aw +EwYDVR0lBAwwCgYIKwYBBQUHAwEwHQYDVR0RBBYwFIESYWRtaW5AYWxsb3dlZC50 +ZXN0MB0GA1UdDgQWBBTv7kjJ+CiqseolXY7XKSBov5TrJTAfBgNVHSMEGDAWgBS6 +Hk8aJJON44G9gB8gTszmgG2QyTAKBggqhkjOPQQDAgNHADBEAiBpRF6/NBtcLcNu +EBHKPN/oBDl1Gbue5H1O1XfjvaVjYQIgG8I/Dx/LLLqnnAxtWXDUcfYegwV77/xB +7prW6QJO6Nk= +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/name-constraints-wildcard-cert.pem b/test/js/node/tls/fixtures/name-constraints-wildcard-cert.pem new file mode 100644 index 000000000000..b2d03218b10b --- /dev/null +++ b/test/js/node/tls/fixtures/name-constraints-wildcard-cert.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB4zCCAYqgAwIBAgIUE+PjIf+ta0faC686AZ8pQjqAQaUwCgYIKoZIzj0EAwIw +MTEvMC0GA1UEAwwmQnVuIFRlc3QgTmFtZSBDb25zdHJhaW50cyBJbnRlcm1lZGlh +dGUwIBcNMjYwODEyMDQxNTM3WhgPMjEyNjA3MTkwNDE1MzdaMBkxFzAVBgNVBAMM +DiouZXhhbXBsZS50ZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAERtB7devt +yXGFRn4GJTl3ZS2+24tEXr/rPcHW9K6KeZU48rHp5uWhJLn4dn8or5scB9kpb0sm +MX9IHy2IRkWw9KOBlTCBkjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDAT +BgNVHSUEDDAKBggrBgEFBQcDATAdBgNVHREEFjAUgRJhZG1pbkBhbGxvd2VkLnRl +c3QwHQYDVR0OBBYEFO/uSMn4KKqx6iVdjtcpIGi/lOslMB8GA1UdIwQYMBaAFLoe +Txokk43jgb2AHyBOzOaAbZDJMAoGCCqGSM49BAMCA0cAMEQCIAYI2RXlxulI7dcD +FVPgKOCqy7JBa5bVCrNha4tBILBLAiBHyv+VUKP4rX92Jl/fZehCeBS9VqJY0ght +lsmpAcHT0w== +-----END CERTIFICATE----- diff --git a/test/js/node/tls/node-tls-connect-hostname-verification.test.ts b/test/js/node/tls/node-tls-connect-hostname-verification.test.ts index 40b698ebd263..6d79dedb36ee 100644 --- a/test/js/node/tls/node-tls-connect-hostname-verification.test.ts +++ b/test/js/node/tls/node-tls-connect-hostname-verification.test.ts @@ -14,8 +14,11 @@ const serverKey = fs.readFileSync(path.join(fixturesDir, "agent1-key.pem")); const serverCert = fs.readFileSync(path.join(fixturesDir, "agent1-cert.pem")); const ca = fs.readFileSync(path.join(fixturesDir, "ca1-cert.pem")); -async function withServer(fn: (port: number) => Promise): Promise { - const server = tls.createServer({ key: serverKey, cert: serverCert }, c => c.end()); +async function withServer( + fn: (port: number) => Promise, + options: tls.TlsOptions = { key: serverKey, cert: serverCert }, +): Promise { + const server = tls.createServer(options, c => c.end()); server.listen(0); await once(server, "listening"); try { @@ -274,3 +277,81 @@ describe("Bun.connect TLS hostname verification", () => { } }); }); + +// name-constraints-ca-cert.pem -> name-constraints-intermediate-cert.pem (nameConstraints +// permitted;DNS:.allowed.test) -> name-constraints--cert.pem, all leaves sharing name-constraints-leaf-key.pem: +// inside: CN=host.allowed.test, subjectAltName=email:admin@allowed.test +// outside: CN=outside.example.test, subjectAltName=email:admin@allowed.test +// wildcard: CN=*.example.test, subjectAltName=email:admin@allowed.test +// partial-wildcard: CN=w*.example.test, subjectAltName=email:admin@allowed.test +// nosan: CN=legacy.allowed.test, no subjectAltName extension +const nameConstraintsCa = fs.readFileSync(path.join(fixturesDir, "name-constraints-ca-cert.pem")); +const nameConstraintsIntermediate = fs.readFileSync( + path.join(fixturesDir, "name-constraints-intermediate-cert.pem"), + "utf8", +); +const nameConstraintsLeafKey = fs.readFileSync(path.join(fixturesDir, "name-constraints-leaf-key.pem")); + +function connectWithNameConstraints(leaf: string, servername: string, rejectUnauthorized = true) { + type Result = { error: NodeJS.ErrnoException | null; authorized: boolean; authorizationError: unknown }; + return withServer( + port => { + const { promise, resolve } = Promise.withResolvers(); + const socket = tls.connect( + { host: "127.0.0.1", port, servername, ca: nameConstraintsCa, rejectUnauthorized }, + () => { + resolve({ error: null, authorized: socket.authorized, authorizationError: socket.authorizationError }); + socket.destroy(); + }, + ); + socket.on("error", err => { + resolve({ error: err as NodeJS.ErrnoException, authorized: false, authorizationError: undefined }); + socket.destroy(); + }); + return promise; + }, + { + key: nameConstraintsLeafKey, + cert: + fs.readFileSync(path.join(fixturesDir, `name-constraints-${leaf}-cert.pem`), "utf8") + + nameConstraintsIntermediate, + }, + ); +} + +describe("tls.connect applies CA name constraints to the subject CN of a certificate without a DNS subjectAltName", () => { + test("a CN outside the permitted subtree fails certificate verification", async () => { + const result = await connectWithNameConstraints("outside", "outside.example.test"); + assert.ok(result.error, "expected the connection to be rejected"); + assert.match(result.error.message, /permitted subtree violation/); + }); + + test("a CN outside the permitted subtree is reported through authorizationError with rejectUnauthorized: false", async () => { + const result = await connectWithNameConstraints("outside", "outside.example.test", false); + assert.strictEqual(result.error, null); + assert.strictEqual(result.authorized, false); + assert.strictEqual(result.authorizationError, "UNSPECIFIED"); + }); + + // Node.js (OpenSSL cn2dnsid) does not treat a wildcard CN as a DNS name here and accepts these + // chains; Bun holds every CN pattern its host name matching honours to the issuer's constraints. + for (const leaf of ["wildcard", "partial-wildcard"]) { + test(`a ${leaf} CN outside the permitted subtree fails certificate verification`, async () => { + const result = await connectWithNameConstraints(leaf, "www.example.test"); + assert.ok(result.error, "expected the connection to be rejected"); + assert.match(result.error.message, /permitted subtree violation/); + }); + } + + test("a CN inside the permitted subtree is accepted", async () => { + const result = await connectWithNameConstraints("inside", "host.allowed.test"); + assert.strictEqual(result.error, null); + assert.strictEqual(result.authorized, true); + }); + + test("a CN inside the permitted subtree is accepted when the certificate has no subjectAltName at all", async () => { + const result = await connectWithNameConstraints("nosan", "legacy.allowed.test"); + assert.strictEqual(result.error, null); + assert.strictEqual(result.authorized, true); + }); +}); diff --git a/test/js/web/fetch/fetch.tls.test.ts b/test/js/web/fetch/fetch.tls.test.ts index 6617f3bd69c4..e3dff3fa065f 100644 --- a/test/js/web/fetch/fetch.tls.test.ts +++ b/test/js/web/fetch/fetch.tls.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import { bunEnv, bunExe, isASAN, tmpdirSync } from "harness"; +import { readFileSync } from "node:fs"; import { join } from "node:path"; import tls from "node:tls"; @@ -390,6 +391,33 @@ describe.concurrent("fetch-tls", () => { }); }); + it("applies CA name constraints to the subject CN of a certificate without a DNS subjectAltName", async () => { + // Fixture layout is described in test/js/node/tls/node-tls-connect-hostname-verification.test.ts. + const fixture = (name: string) => + readFileSync( + join(import.meta.dir, "..", "..", "node", "tls", "fixtures", `name-constraints-${name}.pem`), + "utf8", + ); + const ca = fixture("ca-cert"); + const intermediate = fixture("intermediate-cert"); + const key = fixture("leaf-key"); + // The Host header also selects the name used for certificate verification. + const run = (leaf: string, host: string) => + createServer({ key, cert: fixture(`${leaf}-cert`) + intermediate }, port => + fetch(`https://127.0.0.1:${port}/`, { headers: { Host: host }, tls: { ca }, keepalive: false }).then(res => + res.text(), + ), + ); + + await run("inside", "host.allowed.test"); + await run("nosan", "legacy.allowed.test"); + await expect(run("outside", "outside.example.test")).rejects.toMatchObject({ code: "PERMITTED_VIOLATION" }); + await expect(run("wildcard", "www.example.test")).rejects.toMatchObject({ code: "PERMITTED_VIOLATION" }); + await expect(run("partial-wildcard", "www.example.test")).rejects.toMatchObject({ + code: "PERMITTED_VIOLATION", + }); + }); + it("fetch with checkServerIdentity failing should throw", async () => { await createServer(CERT_LOCALHOST_IP, async port => { try {