From 8b38e9fb23df2987b283e2f8d3a6aac63b291131 Mon Sep 17 00:00:00 2001 From: Shlomo <78599753+ShlomoCode@users.noreply.github.com> Date: Sun, 26 Jan 2025 23:49:28 +0000 Subject: [PATCH] feat: load full certificate bundles from `NODE_EXTRA_CA_CERTS` --- .../bun-usockets/src/crypto/root_certs.cpp | 96 ++++++++++++------- test/harness.ts | 5 + test/js/web/fetch/fetch.tls.test.ts | 80 ++++++++++++++-- 3 files changed, 135 insertions(+), 46 deletions(-) diff --git a/packages/bun-usockets/src/crypto/root_certs.cpp b/packages/bun-usockets/src/crypto/root_certs.cpp index 0e8c8541d9a8a3..0fab9ee8ec095a 100644 --- a/packages/bun-usockets/src/crypto/root_certs.cpp +++ b/packages/bun-usockets/src/crypto/root_certs.cpp @@ -27,57 +27,81 @@ us_ssl_ctx_get_X509_without_callback_from(struct us_cert_string_t content) { in = BIO_new_mem_buf(content.str, content.len); if (in == NULL) { OPENSSL_PUT_ERROR(SSL, ERR_R_BUF_LIB); - goto end; - } + } else { + x = PEM_read_bio_X509(in, NULL, us_no_password_callback, NULL); + if (x == NULL) { + OPENSSL_PUT_ERROR(SSL, ERR_R_PEM_LIB); + } - x = PEM_read_bio_X509(in, NULL, us_no_password_callback, NULL); - if (x == NULL) { - OPENSSL_PUT_ERROR(SSL, ERR_R_PEM_LIB); - goto end; + // NOTE: PEM_read_bio_X509 allocates, so input BIO must be freed. + BIO_free(in); } - - // NOTE: PEM_read_bio_X509 allocates, so input BIO must be freed. - BIO_free(in); return x; -end: - X509_free(x); - BIO_free(in); - return NULL; } -static X509 * -us_ssl_ctx_get_X509_without_callback_from_file(const char *filename) { +static STACK_OF(X509) *us_ssl_ctx_load_all_certs_from_file(const char *filename) { + BIO *in = NULL; + STACK_OF(X509) *certs = NULL; X509 *x = NULL; - BIO *in; + unsigned long last_err; ERR_clear_error(); // clear error stack for SSL_CTX_use_certificate() - in = BIO_new(BIO_s_file()); + in = BIO_new_file(filename, "r"); if (in == NULL) { - OPENSSL_PUT_ERROR(SSL, ERR_R_BUF_LIB); + OPENSSL_PUT_ERROR(SSL, ERR_R_SYS_LIB); goto end; } - if (BIO_read_filename(in, filename) <= 0) { - OPENSSL_PUT_ERROR(SSL, ERR_R_SYS_LIB); + certs = sk_X509_new_null(); + if (certs == NULL) { + OPENSSL_PUT_ERROR(SSL, ERR_R_MALLOC_FAILURE); goto end; } - x = PEM_read_bio_X509(in, NULL, us_no_password_callback, NULL); - if (x == NULL) { + while ((x = PEM_read_bio_X509(in, NULL, us_no_password_callback, NULL))) { + if (!sk_X509_push(certs, x)) { + OPENSSL_PUT_ERROR(SSL, ERR_R_MALLOC_FAILURE); + X509_free(x); + goto end; + } + } + + last_err = ERR_peek_last_error(); + // Ignore error if its EOF/no start line found. + if (ERR_GET_LIB(last_err) == ERR_LIB_PEM && ERR_GET_REASON(last_err) == PEM_R_NO_START_LINE) { + ERR_clear_error(); + } else { + goto end; + } + + if (sk_X509_num(certs) == 0) { OPENSSL_PUT_ERROR(SSL, ERR_R_PEM_LIB); goto end; } - return x; + + BIO_free(in); + return certs; + end: - X509_free(x); BIO_free(in); + if (certs) { + sk_X509_pop_free(certs, X509_free); + } + + char buf[256]; + ERR_error_string_n(ERR_peek_last_error(), buf, sizeof(buf)); + fprintf(stderr, "Warning: Error loading certs from file: %s\n", buf); + return NULL; } -static void us_internal_init_root_certs(X509 *root_cert_instances[sizeof(root_certs) / sizeof(root_certs[0])], X509 *&root_extra_cert_instances) { +static void us_internal_init_root_certs( + X509 *root_cert_instances[root_certs_size], + STACK_OF(X509) *&root_extra_cert_instances) { static std::atomic_flag root_cert_instances_lock = ATOMIC_FLAG_INIT; static std::atomic_bool root_cert_instances_initialized = 0; + if (std::atomic_load(&root_cert_instances_initialized) == 1) return; @@ -92,13 +116,9 @@ static void us_internal_init_root_certs(X509 *root_cert_instances[sizeof(root_ce } // get extra cert option from environment variable - const char *extra_cert = getenv("NODE_EXTRA_CA_CERTS"); - if (extra_cert) { - size_t length = strlen(extra_cert); - if (length > 0) { - root_extra_cert_instances = - us_ssl_ctx_get_X509_without_callback_from_file(extra_cert); - } + const char *extra_certs = getenv("NODE_EXTRA_CA_CERTS"); + if (extra_certs && extra_certs[0]) { + root_extra_cert_instances = us_ssl_ctx_load_all_certs_from_file(extra_certs); } } @@ -122,9 +142,8 @@ extern "C" X509_STORE *us_get_default_ca_store() { return NULL; } - static X509 *root_cert_instances[sizeof(root_certs) / sizeof(root_certs[0])] = { - NULL}; - static X509 *root_extra_cert_instances = NULL; + static X509 *root_cert_instances[root_certs_size] = {NULL}; + static STACK_OF(X509) *root_extra_cert_instances = NULL; us_internal_init_root_certs(root_cert_instances, root_extra_cert_instances); @@ -138,8 +157,11 @@ extern "C" X509_STORE *us_get_default_ca_store() { } if (root_extra_cert_instances) { - X509_up_ref(root_extra_cert_instances); - X509_STORE_add_cert(store, root_extra_cert_instances); + for (int i = 0; i < sk_X509_num(root_extra_cert_instances); i++) { + X509 *cert = sk_X509_value(root_extra_cert_instances, i); + X509_up_ref(cert); + X509_STORE_add_cert(store, cert); + } } return store; diff --git a/test/harness.ts b/test/harness.ts index 5619b1095caa9e..2c48f2b72e6b13 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -1188,6 +1188,11 @@ export const tls = Object.freeze({ key: "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC+7odzr3yIYewR\nNRGIubF5hzT7Bym2dDab4yhaKf5drL+rcA0J15BM8QJ9iSmL1ovg7x35Q2MBKw3r\nl/Yyy3aJS8whZTUze522El72iZbdNbS+oH6GxB2gcZB6hmUehPjHIUH4icwPdwVU\neR6fB7vkfDddLXe0Tb4qsO1EK8H0mr5PiQSXfj39Yc1QHY7/gZ/xeSrt/6yn0oH9\nHbjF2XLSL2j6cQPKEayartHN0SwzwLi0eWSzcziVPSQV7c6Lg9UuIHbKlgOFzDpc\np1p1lRqv2yrT25im/dS6oy9XX+p7EfZxqeqpXX2fr5WKxgnzxI3sW93PG8FUIDHt\nnUsoHX3RAgMBAAECggEAAckMqkn+ER3c7YMsKRLc5bUE9ELe+ftUwfA6G+oXVorn\nE+uWCXGdNqI+TOZkQpurQBWn9IzTwv19QY+H740cxo0ozZVSPE4v4czIilv9XlVw\n3YCNa2uMxeqp76WMbz1xEhaFEgn6ASTVf3hxYJYKM0ljhPX8Vb8wWwlLONxr4w4X\nOnQAB5QE7i7LVRsQIpWKnGsALePeQjzhzUZDhz0UnTyGU6GfC+V+hN3RkC34A8oK\njR3/Wsjahev0Rpb+9Pbu3SgTrZTtQ+srlRrEsDG0wVqxkIk9ueSMOHlEtQ7zYZsk\nlX59Bb8LHNGQD5o+H1EDaC6OCsgzUAAJtDRZsPiZEQKBgQDs+YtVsc9RDMoC0x2y\nlVnP6IUDXt+2UXndZfJI3YS+wsfxiEkgK7G3AhjgB+C+DKEJzptVxP+212hHnXgr\n1gfW/x4g7OWBu4IxFmZ2J/Ojor+prhHJdCvD0VqnMzauzqLTe92aexiexXQGm+WW\nwRl3YZLmkft3rzs3ZPhc1G2X9QKBgQDOQq3rrxcvxSYaDZAb+6B/H7ZE4natMCiz\nLx/cWT8n+/CrJI2v3kDfdPl9yyXIOGrsqFgR3uhiUJnz+oeZFFHfYpslb8KvimHx\nKI+qcVDcprmYyXj2Lrf3fvj4pKorc+8TgOBDUpXIFhFDyM+0DmHLfq+7UqvjU9Hs\nkjER7baQ7QKBgQDTh508jU/FxWi9RL4Jnw9gaunwrEt9bxUc79dp+3J25V+c1k6Q\nDPDBr3mM4PtYKeXF30sBMKwiBf3rj0CpwI+W9ntqYIwtVbdNIfWsGtV8h9YWHG98\nJ9q5HLOS9EAnogPuS27walj7wL1k+NvjydJ1of+DGWQi3aQ6OkMIegap0QKBgBlR\nzCHLa5A8plG6an9U4z3Xubs5BZJ6//QHC+Uzu3IAFmob4Zy+Lr5/kITlpCyw6EdG\n3xDKiUJQXKW7kluzR92hMCRnVMHRvfYpoYEtydxcRxo/WS73SzQBjTSQmicdYzLE\ntkLtZ1+ZfeMRSpXy0gR198KKAnm0d2eQBqAJy0h9AoGBAM80zkd+LehBKq87Zoh7\ndtREVWslRD1C5HvFcAxYxBybcKzVpL89jIRGKB8SoZkF7edzhqvVzAMP0FFsEgCh\naClYGtO+uo+B91+5v2CCqowRJUGfbFOtCuSPR7+B3LDK8pkjK2SQ0mFPUfRA5z0z\nNVWtC0EYNBTRkqhYtqr3ZpUc\n-----END PRIVATE KEY-----\n", }); +export const invalidTls = Object.freeze({ + cert: "-----BEGIN CERTIFICATE-----\nBQAwaTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1TYW4gRnJh\nBQAwaTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1TYW4gRnJh\nbmNpc2NvMQ0wCwYDVQQKDARPdmVuMREwDwYDVQQLDAhUZWFtIEJ1bjETMBEGA1UE\nAwwKc2VydmVyLWJ1bjAeFw0yNTAyMDQwNDUyNTdaFw0yNzAyMDQwNDUyNTdaMGkx\nCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNj\nbzENMAsGA1UECgwET3ZlbjERMA8GA1UECwwIVGVhbSBCdW4xEzARBgNVBAMMCnNl\ncnZlci1idW4wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC1rZqCnASs\nHPzPjs/mls+z3qTl6OsCNI+kTsA23/+ZkvtBe7EI+9LfV1Sy4MF66ZovR0UgeJUB\nlL7ExadXkfZJS0N6LEAIyEMQI0cpILv3i6sJCcRwHV7X7N55lkUdsJtQ3fSKsyn9\nPDWJGVdwtRjdod3XyevYcx5NLGZOF/4KJmR4eNkX8ycG8zvW/srPHHE95/+k/5Wo\n/RrS+OLl+bgVznxmXtnFMdbYvJ1RLyipCED2P569NWXAgCzYESX2tqLr20R8ca8Q\niTcXXijY1Wq+pVR5NhIckt+zyZlUQ5IT3DvAQn4aW30wA514k1AKDKQjtxdRzVmV\nGDOTOzAlpmeZAgMBAAGjTzBNMCwGA1UdEQQlMCOCCWxvY2FsaG9zdIcEfwAAAYcQ\nAAAAAAAAAAAAAAAAAAAAATAdBgNVHQ4EFgQUkgeZUw9BZc/9mxAym4BjaVYhHoow\nDQYJKoZIhvcNAQELBQADggEBAJGQomt68rO1wuhHaG355kGaIsTsoUJgs7VAKNI2\n0/vtMKODX2Zo2BHhiI1wSH751IySqWbGYCvXl6QrsV5tD/jdIYKvyXLFmV0KgQSY\nkZ91sde4jIiiqL5h03GJSUydCl4SE1A1H8Ht41NYoyVaWVPzv5lprt654vpRPbPq\nYBQTWSFcYkmGnza/tRALUftM5U3yKOTQ8sKH/eKGC9KU0DI5pZ2XAxrIyvrJZMm1\n0WwWTrO0KlXN8N9v8tVCVm7g6mYug4HEADQ4kymyfwM6mPY1EmsGy36KOqCRUtUR\n+jmAZr9m+l+27GxR9zjxoLWHkARuWZM/hL//u90cNfNDRgQ=\n-----END CERTIFICATE-----\n", + key: "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC1rZqCnASsHPzP\njs/mls+z3qTl6OsCNI+kTsA23/+ZkvtBe7EI+9LfV1Sy4MF66ZovR0UgeJUBlL7E\nxadXkfZJS0N6LEAIyEMQI0cpILv3i6sJCcRwHV7X7N55lkUdsJtQ3fSKsyn9PDWJ\nGVdwtRjdod3XyevYcx5NLGZOF/4KJmR4eNkX8ycG8zvW/srPHHE95/+k/5Wo/RrS\n+OLl+bgVznxmXtnFMdbYvJ1RLyipCED2P569NWXAgCzYESX2tqLr20R8ca8QiTcX\nXijY1Wq+pVR5NhIckt+zyZlUQ5IT3DvAQn4aW30wA514k1AKDKQjtxdRzVmVGDOT\nOzAlpmeZAgMBAAECggEANW6F2zTckOwDlF2pmmUvV/S6pZ1/hIoF1uqMUHdHmpim\nSaeBtSUu6x2pnORKMwaCILaCx55/IFRpWMDSywf0GbFHeqaJ/Ks9QgFGG/vzHEZY\n+pMDUX/p1XJmKfc+g5Fd1IY6thIkXsR28EfiNhUk54YEE0NhGCsfNc5BlmUrAzuw\nSevCkbChsZzLoasskt5hgWOb1wT757xDrOOss3LXvwaFkMXANQHiaGWxSpmyXTVf\nmtIX4wpN2K5BQxRBV6xmRaBBp7fWJlbqvV67wwh2cxIAyvQ68VVVHTbfv44TUw62\nyCKle6hSLi/OnMr1FJv16Ez+K3lUIkYE0nTYIvQkYwKBgQD34Nwy0U8WcHcOSbU1\nMIREfvPNYfl/hH94wmLyKZWhpqOfqgrUg+19+n9TXw4DGdO7lwAzz+ud7TImbGDI\n+1cb9/FxTK5cRwICTLC+Pse6pVkKUvPdil/TfHZBJP1jeIMGMDVi0fcGv97LxrHV\nJGQwA5x1nHGHl0JrENRqm3M2NwKBgQC7oXkWb0s2C8ANI14gz+q/2EmTSJxxrzXR\nz5EQk87PmPfkY4I1T8vKFcaiJynyReLwpYTip2WYGqc7qAO9oLwmA+d/NMOBI2sg\noEn154Q9zvr3jqIgu9/AapEgEDlA+v18veoIz3bae6wu57lpGvGtCoQLBS6q2UZg\n3zFI3BJorwKBgDz4WjFFuqZSU3Z4OtIydNZEQ8Oo7a2n8ZLKfXwDLoLsciK7uJ49\nNRVfoCHpp5CrsaDaq3oTEmluBn/c+JF3AR4oBoNP0TNxY9Uc9/xThN0r/pLDhKhh\neOCUJKIxbwIgilnjUb5U1uYaG7sTzHoY0Wvd94YWTPaFBhk/sn/mbJhRAoGAA+/E\nWZsmKdEfS2dFj0ytcS75hDSOy7fQWkGPmphvS127Pbh0v+eXr/q6+yX1NFcRBtmC\nKzs133YXsiG5Sl439Fg6oCmcPHZgxgN26cjctmtESrNcZXFrpV7XAqQ0f0+Ex/w4\nD81Cghz8JNPJyRG+plHFKXIHY6BBYMDuCMhNPpMCgYEA1BVG5scNtmBE3SaRns2G\npKgWiwmzPDTqwf3R0rgkHQroZUIz616jLgKXIVMBPaq/771uq+hzJZro9sNcNL8p\n9PkLRr4V4KtUSqkjvitU68vMM1qxtO9NVwCI5u3wicVC5mMqcH8FN+sO/5/jIPBl\nO/qEOVDlCuYtURcnh/Oz1cE=\n-----END PRIVATE KEY-----\n", +}); + export function disableAggressiveGCScope() { const gc = Bun.unsafe.gcAggressionLevel(0); return { diff --git a/test/js/web/fetch/fetch.tls.test.ts b/test/js/web/fetch/fetch.tls.test.ts index 2151c6f6992fbd..5cc509129173d1 100644 --- a/test/js/web/fetch/fetch.tls.test.ts +++ b/test/js/web/fetch/fetch.tls.test.ts @@ -9,10 +9,10 @@ type TLSOptions = { passphrase?: string; }; -import { tls as cert1, expiredTls as cert2 } from "harness"; +import { tls as validTls, expiredTls, invalidTls } from "harness"; -const CERT_LOCALHOST_IP = { ...cert1 }; -const CERT_EXPIRED = { ...cert2 }; +const CERT_LOCALHOST_IP = { ...validTls }; +const CERT_EXPIRED = { ...expiredTls }; // Note: Do not use bun.sh as the example domain // Cloudflare sometimes blocks automated requests to it. @@ -228,7 +228,7 @@ it("fetch should respect rejectUnauthorized env", async () => { it("fetch timeout works on tls", async () => { using server = Bun.serve({ - tls: cert1, + tls: validTls, hostname: "localhost", port: 0, rejectUnauthorized: false, @@ -248,7 +248,7 @@ it("fetch timeout works on tls", async () => { try { await fetch(server.url, { signal: AbortSignal.timeout(TIMEOUT), - tls: { ca: cert1.cert }, + tls: { ca: validTls.cert }, }).then(res => res.text()); expect.unreachable(); } catch (e) { @@ -292,13 +292,13 @@ for (const timeout of [0, 1, 10, 20, 100, 300]) { it("fetch should use NODE_EXTRA_CA_CERTS", async () => { using server = Bun.serve({ port: 0, - tls: cert1, + tls: validTls, fetch() { return new Response("OK"); }, }); const cert_path = join(tmpdirSync(), "cert.pem"); - await Bun.write(cert_path, cert1.cert); + await Bun.write(cert_path, validTls.cert); const proc = Bun.spawn({ env: { @@ -315,15 +315,77 @@ it("fetch should use NODE_EXTRA_CA_CERTS", async () => { expect(await proc.exited).toBe(0); }); +it("fetch should load certificate even if it's not the first in bundle", async () => { + using server = Bun.serve({ + port: 0, + tls: validTls, + fetch() { + return new Response("OK"); + }, + }); + + const bundlePath = join(tmpdirSync(), "bundle.pem"); + const bundleContent = `${expiredTls.cert}\n${validTls.cert}`; + await Bun.write(bundlePath, bundleContent); + + const proc = Bun.spawn({ + env: { + ...bunEnv, + SERVER: server.url, + NODE_EXTRA_CA_CERTS: bundlePath, + }, + stderr: "inherit", + stdout: "inherit", + stdin: "inherit", + cmd: [bunExe(), join(import.meta.dir, "fetch.tls.extra-cert.fixture.js")], + }); + + expect(await proc.exited).toBe(0); +}); + it("fetch should ignore invalid NODE_EXTRA_CA_CERTS", async () => { using server = Bun.serve({ port: 0, - tls: cert1, + tls: validTls, fetch() { return new Response("OK"); }, }); - for (const invalid of ["invalid.pem", "", " "]) { + + for (const invalid of ["not-exist.pem", "", " "]) { + const proc = Bun.spawn({ + env: { + ...bunEnv, + SERVER: server.url, + NODE_EXTRA_CA_CERTS: invalid, + }, + stderr: "pipe", + stdout: "inherit", + stdin: "inherit", + cmd: [bunExe(), join(import.meta.dir, "fetch.tls.extra-cert.fixture.js")], + }); + + expect(await proc.exited).toBe(1); + expect(await Bun.readableStreamToText(proc.stderr)).toContain("DEPTH_ZERO_SELF_SIGNED_CERT"); + } +}); + +it("fetch should ignore NODE_EXTRA_CA_CERTS if it's contains invalid cert", async () => { + using server = Bun.serve({ + port: 0, + tls: validTls, + fetch() { + return new Response("OK"); + }, + }); + + const mixedValidAndInvalidCertsBundlePath = join(tmpdirSync(), "mixed-valid-and-invalid-certs-bundle.pem"); + await Bun.write(mixedValidAndInvalidCertsBundlePath, `${invalidTls.cert}\n${validTls.cert}`); + + const mixedInvalidAndValidCertsBundlePath = join(tmpdirSync(), "mixed-invalid-and-valid-certs-bundle.pem"); + await Bun.write(mixedInvalidAndValidCertsBundlePath, `${validTls.cert}\n${invalidTls.cert}`); + + for (const invalid of [mixedValidAndInvalidCertsBundlePath, mixedInvalidAndValidCertsBundlePath]) { const proc = Bun.spawn({ env: { ...bunEnv,