From 82dedcff2dd2c1ffb456134d5012471bb681b7c8 Mon Sep 17 00:00:00 2001 From: sideshowbarker Date: Sun, 12 Jul 2026 17:40:00 +0900 Subject: [PATCH 1/3] RequestServer: Implement AIA fetching, for sites with broken cert chains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This implements TLS AIA-fetching support on Linux, by doing this: 1. Install a cert-verification callback that, on a failure, records URLs from the AIA extension of any cert whose issuer is missing. 2. Make RequestServer fetch each missing intermediate on a fresh handle. 3. Parse each such intermediate, cache it, and then retry the request. Otherwise, without this change, some sites reachable in other browsers (which already do this) are unreachable in Ladybird — because we fail to load those whose responses have missing intermediate CA certs, even when a missing intermediate is pointed at by an AIA extension. The callback is OpenSSL-specific, so it’s only installed on Linux — where curl verifies with OpenSSL. On macOS, curl verifies with Apple’s SecTrust — which fetches missing intermediates itself. Note: On retry the fetched intermediates are only ever offered to chain- building as untrusted certs. The completed chain must still validate to a locally-trusted root; thus, this doesn’t introduce new/specious trust. Fixes https://github.com/LadybirdBrowser/ladybird/issues/10520 --- Services/RequestServer/AIA.cpp | 220 ++++++++++++++++++ Services/RequestServer/AIA.h | 44 ++++ Services/RequestServer/CMakeLists.txt | 1 + .../RequestServer/ConnectionFromClient.cpp | 91 ++++++++ Services/RequestServer/ConnectionFromClient.h | 12 + Services/RequestServer/Request.cpp | 56 ++++- Services/RequestServer/Request.h | 11 + 7 files changed, 434 insertions(+), 1 deletion(-) create mode 100644 Services/RequestServer/AIA.cpp create mode 100644 Services/RequestServer/AIA.h diff --git a/Services/RequestServer/AIA.cpp b/Services/RequestServer/AIA.cpp new file mode 100644 index 0000000000000..c3bf139bc2035 --- /dev/null +++ b/Services/RequestServer/AIA.cpp @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2026-present, the Ladybird developers. + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace RequestServer { + +// This max was chosen to align with the (telemetry-tuned) value Chromium uses. +static constexpr size_t max_ca_issuers_urls_per_certificate = 5; + +// Upper bound on the process-wide fetched-intermediate cache — so a long-lived process that visits many misconfigured +// sites can't grow it without limit. +static constexpr size_t max_cached_intermediates = 256; + +// How long a caIssuers URL whose fetch failed is remembered, before we're willing to try it again. +static constexpr auto failed_url_retry_interval = AK::Duration::from_seconds(300); + +// Upper bound on the failed-URL negative cache, so a page hitting many distinct dead caIssuers URLs can't grow it +// without limit. +static constexpr size_t max_failed_urls = 1024; + +// Collect the caIssuers URLs from a cert's AIA extension. Only http URLs are returned; fetching an intermediate over +// https would itself require cert verification — risking infinite recursion. +Vector ca_issuers_urls(X509* certificate) +{ + Vector urls; + + auto* authority_info_access = static_cast(X509_get_ext_d2i(certificate, NID_info_access, nullptr, nullptr)); + if (!authority_info_access) + return urls; + + for (int i = 0; i < sk_ACCESS_DESCRIPTION_num(authority_info_access); ++i) { + auto* access_description = sk_ACCESS_DESCRIPTION_value(authority_info_access, i); + if (OBJ_obj2nid(access_description->method) != NID_ad_ca_issuers) + continue; + if (access_description->location->type != GEN_URI) + continue; + + auto const* uri = access_description->location->d.uniformResourceIdentifier; + auto url = ByteString { reinterpret_cast(ASN1_STRING_get0_data(uri)), static_cast(ASN1_STRING_length(uri)) }; + if (url.starts_with("http://"sv, CaseSensitivity::CaseInsensitive) && urls.size() < max_ca_issuers_urls_per_certificate) + urls.append(move(url)); + } + + AUTHORITY_INFO_ACCESS_free(authority_info_access); + return urls; +} + +// Process-wide cache of intermediate certs fetched via AIA. Each entry owns one reference to the X509. Bounded by +// max_cached_intermediates with FIFO eviction. +static Vector& intermediate_cache() +{ + static Vector cache; + return cache; +} + +// Process-wide record of caIssuers URLs whose fetch failed, mapped to when they failed — so we don't repeatedly retry a +// dead URL. Entries older than failed_url_retry_interval are ignored (and dropped when next encountered) — so a +// transiently-unreachable AIA server is eventually retried. +static HashMap& failed_urls() +{ + static HashMap urls; + return urls; +} + +void mark_aia_url_failed(ByteString url) +{ + if (failed_urls().size() >= max_failed_urls) + failed_urls().clear(); + failed_urls().set(move(url), MonotonicTime::now_coarse()); +} + +// Parse a fetched AIA response body into one or more certs. Real-world CAs serve a bare DER-encoded cert, a PKCS#7/ +// CMS "certs-only" bundle (RFC 5280 section 4.2.2.1), or PEM. +Vector parse_certificates(ReadonlyBytes body) +{ + Vector certificates; + + auto const* der = body.data(); + if (auto* certificate = d2i_X509(nullptr, &der, static_cast(body.size()))) { + certificates.append(certificate); + return certificates; + } + + auto const* pkcs7_data = body.data(); + if (auto* pkcs7 = d2i_PKCS7(nullptr, &pkcs7_data, static_cast(body.size()))) { + STACK_OF(X509)* certs = nullptr; + if (PKCS7_type_is_signed(pkcs7)) { + if (pkcs7->d.sign) + certs = pkcs7->d.sign->cert; + } else if (PKCS7_type_is_signedAndEnveloped(pkcs7)) { + if (pkcs7->d.signed_and_enveloped) + certs = pkcs7->d.signed_and_enveloped->cert; + } + for (int i = 0; certs && i < sk_X509_num(certs); ++i) { + auto* certificate = sk_X509_value(certs, i); + X509_up_ref(certificate); + certificates.append(certificate); + } + PKCS7_free(pkcs7); + if (!certificates.is_empty()) + return certificates; + } + + if (auto* bio = BIO_new_mem_buf(body.data(), static_cast(body.size()))) { + while (auto* certificate = PEM_read_bio_X509(bio, nullptr, nullptr, nullptr)) + certificates.append(certificate); + BIO_free(bio); + } + + return certificates; +} + +bool add_fetched_aia_intermediate(ReadonlyBytes body) +{ + auto certificates = parse_certificates(body); + for (auto* certificate : certificates) { + if (intermediate_cache().size() >= max_cached_intermediates) + X509_free(intermediate_cache().take_first()); + intermediate_cache().append(certificate); + } + return !certificates.is_empty(); +} + +// Full certificate-verification callback, installed via SSL_CTX_set_cert_verify_callback. We offer the AIA-fetched +// intermediates to path building as UNTRUSTED certificates — never as trust anchors — so the completed chain must still +// terminate at a locally-trusted root. On a verification failure, we record the failing cert's caIssuers URLs (skipping +// any recently known to be dead) — so the request layer can fetch them and retry. +static int verify_callback(X509_STORE_CTX* context, void* collector_data) +{ + auto* collector = static_cast(collector_data); + + // Add the fetched intermediates alongside the certs the server sent, as untrusted path-building material. + auto* server_untrusted = X509_STORE_CTX_get0_untrusted(context); + auto* untrusted = server_untrusted ? sk_X509_dup(server_untrusted) : sk_X509_new_null(); + if (untrusted) { + for (auto* candidate : intermediate_cache()) + sk_X509_push(untrusted, candidate); + X509_STORE_CTX_set0_untrusted(context, untrusted); + } + + auto result = X509_verify_cert(context); + + if (untrusted) { + X509_STORE_CTX_set0_untrusted(context, server_untrusted); + sk_X509_free(untrusted); + } + + // AIA can only repair a chain missing an intermediate issuer. For any other verification failure (expired, + // revoked, hostname mismatch, ...) fetching caIssuers is useless and would burn several 10s retries before the + // real error surfaces, so we leave the collector's pending URLs untouched. + auto const verify_error = X509_STORE_CTX_get_error(context); + auto const issuer_is_missing = verify_error == X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT || verify_error == X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT_LOCALLY; + + if (result <= 0 && collector && issuer_is_missing) { + if (auto* current = X509_STORE_CTX_get_current_cert(context)) { + for (auto& url : ca_issuers_urls(current)) { + if (collector->attempted_urls.contains(url)) + continue; + if (auto failed_at = failed_urls().get(url); failed_at.has_value()) { + if (MonotonicTime::now_coarse() - *failed_at < failed_url_retry_interval) + continue; + failed_urls().remove(url); + } + if (!collector->pending_urls.contains_slow(url)) + collector->pending_urls.append(move(url)); + } + } + } + + return result; +} + +// The SSL_CTX (per-connection, possibly pooled beyond the originating request) holds a strong reference to the +// collector; this releases it when the SSL_CTX is destroyed. +static void collector_ex_data_free(void*, void* ptr, CRYPTO_EX_DATA*, int, long, void*) +{ + if (ptr) + static_cast(ptr)->unref(); +} + +static int collector_ex_data_index() +{ + static int index = SSL_CTX_get_ex_new_index(0, nullptr, nullptr, nullptr, collector_ex_data_free); + return index; +} + +static CURLcode ssl_context_callback(CURL*, void* ssl_context, void* collector_data) +{ + auto* context = static_cast(ssl_context); + auto* collector = static_cast(collector_data); + collector->ref(); + SSL_CTX_set_ex_data(context, collector_ex_data_index(), collector); + SSL_CTX_set_cert_verify_callback(context, verify_callback, collector); + return CURLE_OK; +} + +void install_aia_verification_hook(CURL* easy_handle, AIACollector& collector) +{ + curl_easy_setopt(easy_handle, CURLOPT_SSL_CTX_FUNCTION, ssl_context_callback); + curl_easy_setopt(easy_handle, CURLOPT_SSL_CTX_DATA, &collector); +} + +} diff --git a/Services/RequestServer/AIA.h b/Services/RequestServer/AIA.h new file mode 100644 index 0000000000000..dd52f02eafce5 --- /dev/null +++ b/Services/RequestServer/AIA.h @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026-present, the Ladybird developers. + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +typedef void CURL; + +namespace RequestServer { + +// Per-request state shared with (per-connection, possibly pooled and longer-lived) SSL_CTX during verification. Ref- +// counted, so a connection outliving its originating request can't leave the verify callback with a dangling pointer. +class AIACollector : public RefCounted { +public: + Vector pending_urls; // caIssuers URLs collected during verification, awaiting fetch. + HashTable attempted_urls; // URLs already fetched for this request, so they're not re-collected. +}; + +// During verification, any cert whose issuer we can't find locally or in the fetched-intermediate cache has its +// caIssuers URLs appended to collector.pending_urls (skipping ones already attempted or recently known dead). +void install_aia_verification_hook(CURL* easy_handle, AIACollector& collector); + +// Parse a fetched AIA response body and, if it yields a certificate, add it to the process-wide intermediate cache +// that's consulted during verification. Returns true if a certificate was added. +bool add_fetched_aia_intermediate(ReadonlyBytes body); + +// Record that fetching the given caIssuers URL failed — so it's not retried. +void mark_aia_url_failed(ByteString url); + +// The following two parsing primitives are exposed for unit testing. +Vector ca_issuers_urls(X509* certificate); +Vector parse_certificates(ReadonlyBytes body); + +} diff --git a/Services/RequestServer/CMakeLists.txt b/Services/RequestServer/CMakeLists.txt index 723b507bcc564..44b05cd3d4c23 100644 --- a/Services/RequestServer/CMakeLists.txt +++ b/Services/RequestServer/CMakeLists.txt @@ -1,4 +1,5 @@ set(SOURCES + AIA.cpp ConnectionFromClient.cpp CURL.cpp Request.cpp diff --git a/Services/RequestServer/ConnectionFromClient.cpp b/Services/RequestServer/ConnectionFromClient.cpp index 4eec73ec9debf..3643329f69018 100644 --- a/Services/RequestServer/ConnectionFromClient.cpp +++ b/Services/RequestServer/ConnectionFromClient.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -126,6 +127,12 @@ ConnectionFromClient::~ConnectionFromClient() m_pending_websockets.clear(); m_websockets.clear(); + for (auto& fetch : m_aia_fetches) { + curl_multi_remove_handle(m_curl_multi, fetch.key); + curl_easy_cleanup(fetch.key); + } + m_aia_fetches.clear(); + curl_multi_cleanup(m_curl_multi); m_curl_multi = nullptr; } @@ -435,6 +442,11 @@ void ConnectionFromClient::check_active_requests() continue; } + if (reinterpret_cast(application_private) & aia_fetch_private_tag) { + complete_aia_fetch(msg->easy_handle, msg->data.result); + continue; + } + ++completions_drained; auto* request = static_cast(application_private); request->notify_fetch_complete({}, msg->data.result); @@ -444,6 +456,85 @@ void ConnectionFromClient::check_active_requests() dbgln_if(REQUESTSERVER_WIRE_DEBUG, "RequestServer wire-batch: drained {} completions in one curl multi tick", completions_drained); } +static size_t aia_write_body(char* data, size_t size, size_t count, void* user_data) +{ + auto& fetch = *static_cast(user_data); + auto const length = size * count; + if (fetch.body.size() + length > 64u * 1024) + return 0; + if (fetch.body.try_append(data, length).is_error()) + return 0; + return length; +} + +void ConnectionFromClient::fetch_aia_intermediate(Badge, ByteString const& url, u64 for_request_id) +{ + for (auto& in_flight : m_aia_fetches) { + if (in_flight.value->url == url) { + in_flight.value->request_ids.append(for_request_id); + return; + } + } + + auto* easy_handle = curl_easy_init(); + if (!easy_handle) { + if (auto request = m_active_requests.get(for_request_id); request.has_value()) + (*request)->retry_after_aia({}); + return; + } + + auto fetch = make(); + fetch->url = url; + fetch->request_ids.append(for_request_id); + + auto set_option = [&](auto option, auto value) { + if (auto result = curl_easy_setopt(easy_handle, option, value); result != CURLE_OK) + dbgln("RequestServer: AIA fetch failed to set curl option: {}", curl_easy_strerror(result)); + }; + set_option(CURLOPT_PRIVATE, reinterpret_cast(reinterpret_cast(fetch.ptr()) | aia_fetch_private_tag)); + set_option(CURLOPT_URL, url.characters()); + set_option(CURLOPT_PROTOCOLS_STR, "http"); + set_option(CURLOPT_REDIR_PROTOCOLS_STR, "http"); + set_option(CURLOPT_FOLLOWLOCATION, 1L); + set_option(CURLOPT_MAXREDIRS, 5L); + set_option(CURLOPT_TIMEOUT, 10L); + set_option(CURLOPT_MAXFILESIZE_LARGE, static_cast(64L * 1024)); + set_option(CURLOPT_NOSIGNAL, 1L); + set_option(CURLOPT_WRITEFUNCTION, aia_write_body); + set_option(CURLOPT_WRITEDATA, fetch.ptr()); + + auto result = curl_multi_add_handle(m_curl_multi, easy_handle); + VERIFY(result == CURLM_OK); + + m_aia_fetches.set(easy_handle, move(fetch)); +} + +void ConnectionFromClient::complete_aia_fetch(void* easy_handle, int result_code) +{ + long response_code = 0; + curl_easy_getinfo(easy_handle, CURLINFO_RESPONSE_CODE, &response_code); + + auto fetch = m_aia_fetches.take(easy_handle); + curl_multi_remove_handle(m_curl_multi, easy_handle); + curl_easy_cleanup(easy_handle); + if (!fetch.has_value()) + return; + + bool added = false; + if (result_code == CURLE_OK && response_code == 200) + added = add_fetched_aia_intermediate((*fetch)->body.bytes()); + + if (!added) { + dbgln_if(REQUESTSERVER_DEBUG, "AIA: intermediate fetch from {} failed (curl={}, status={})", (*fetch)->url, result_code, response_code); + mark_aia_url_failed((*fetch)->url); + } + + for (auto request_id : (*fetch)->request_ids) { + if (auto request = m_active_requests.get(request_id); request.has_value()) + (*request)->retry_after_aia({}); + } +} + void ConnectionFromClient::fail_websocket(u64 websocket_id, Requests::WebSocket::Error error) { async_websocket_ready_state_changed(websocket_id, to_underlying(Requests::WebSocket::ReadyState::Closed)); diff --git a/Services/RequestServer/ConnectionFromClient.h b/Services/RequestServer/ConnectionFromClient.h index 123ee81a6d3eb..5db716830d9a0 100644 --- a/Services/RequestServer/ConnectionFromClient.h +++ b/Services/RequestServer/ConnectionFromClient.h @@ -7,9 +7,11 @@ #pragma once #include +#include #include #include #include +#include #include #include #include @@ -25,6 +27,12 @@ namespace RequestServer { +struct AIAFetch { + ByteString url; + ByteBuffer body; + Vector request_ids; +}; + class ConnectionFromClient final : public IPC::ConnectionFromClient { C_OBJECT(ConnectionFromClient); @@ -47,6 +55,7 @@ class ConnectionFromClient final void start_revalidation_request(Badge, ByteString method, URL::URL, NonnullRefPtr request_headers, ByteBuffer request_body, HTTP::Cookie::IncludeCredentials, Core::ProxyData proxy_data); void request_complete(Badge, Request const&); + void fetch_aia_intermediate(Badge, ByteString const& url, u64 for_request_id); private: ConnectionFromClient(NonnullOwnPtr, IsPrimaryConnection, IsPrivate, ConnectionMap&, Optional, ByteString alt_svc_cache_path); @@ -85,6 +94,7 @@ class ConnectionFromClient final static int on_socket_callback(void*, int sockfd, int what, void* user_data, void*); static int on_timeout_callback(void*, long timeout_ms, void* user_data); void check_active_requests(); + void complete_aia_fetch(void* easy_handle, int result_code); void fail_websocket(u64 websocket_id, Requests::WebSocket::Error); ErrorOr create_client_socket(IsPrivate); @@ -98,6 +108,7 @@ class ConnectionFromClient final HashMap> m_active_requests; HashMap> m_active_revalidation_requests; + HashMap> m_aia_fetches; HashTable m_pending_websockets; HashMap> m_websockets; @@ -115,5 +126,6 @@ class ConnectionFromClient final }; constexpr inline uintptr_t websocket_private_tag = 0x1; +constexpr inline uintptr_t aia_fetch_private_tag = 0x2; } diff --git a/Services/RequestServer/Request.cpp b/Services/RequestServer/Request.cpp index 952a3d0be83db..90403acb334f3 100644 --- a/Services/RequestServer/Request.cpp +++ b/Services/RequestServer/Request.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -524,6 +525,8 @@ void Request::notify_retrieved_http_cookie(Badge, StringVi transition_to_state(State::Fetch); } +static constexpr size_t max_aia_fetches_per_request = 5; + void Request::notify_fetch_complete(Badge, int result_code) { mark_lifecycle_event(this, &WireStats::complete_observed_at); @@ -533,6 +536,12 @@ void Request::notify_fetch_complete(Badge, int result_code log_chunk_stats(this); } + if (m_type == RequestType::Fetch && result_code == CURLE_PEER_FAILED_VERIFICATION && m_aia_collector && !m_aia_collector->pending_urls.is_empty() && m_aia_fetch_count < max_aia_fetches_per_request) { + m_curl_result_code = result_code; + transition_to_state(State::Complete); + return; + } + if (is_revalidation_request()) { if (acquire_status_code() == 304) { if (m_type == RequestType::BackgroundRevalidation && m_disk_cache->mode() == HTTP::DiskCache::Mode::Testing) @@ -555,6 +564,36 @@ void Request::notify_fetch_complete(Badge, int result_code transition_to_state(State::Complete); } +void Request::start_aia_fetch_and_retry() +{ + ++m_aia_fetch_count; + auto url = m_aia_collector->pending_urls.take_first(); + m_aia_collector->attempted_urls.set(url); + dbgln_if(REQUESTSERVER_DEBUG, "AIA: fetching missing intermediate from {} (attempt {})", url, m_aia_fetch_count); + m_client->fetch_aia_intermediate({}, url, m_request_id); + transition_to_state(State::WaitForAIA); +} + +void Request::retry_after_aia(Badge) +{ + if (m_state != State::WaitForAIA) + return; + + if (m_curl_easy_handle) { + if (m_curl_easy_handle_is_in_multi) { + auto result = curl_multi_remove_handle(m_curl_multi_handle, m_curl_easy_handle); + VERIFY(result == CURLM_OK); + } + curl_easy_cleanup(m_curl_easy_handle); + m_curl_easy_handle = nullptr; + m_curl_easy_handle_is_in_multi = false; + } + m_curl_result_code = {}; + if (m_aia_collector) + m_aia_collector->pending_urls.clear(); + transition_to_state(State::Fetch); +} + void Request::transition_to_state(State state) { dbgln_if(REQUESTSERVER_DEBUG, "Request::Transition[{}]: {} -> {} ({} {})", m_request_id, state_name(m_state), state_name(state), m_method, m_url); @@ -574,6 +613,9 @@ void Request::process() case State::WaitForCache: // Do nothing; we are waiting for the disk cache to notify us to proceed. break; + case State::WaitForAIA: + // Do nothing; we are waiting for the AIA intermediate-certificate fetch to notify us to proceed. + break; case State::FailedCacheOnly: handle_failed_cache_only_state(); break; @@ -912,7 +954,7 @@ void Request::handle_fetch_state() auto is_revalidation_request = this->is_revalidation_request(); - if (!is_revalidation_request) { + if (!is_revalidation_request && !m_informed_client_request_started) { if (inform_client_request_started().is_error()) return; } @@ -929,6 +971,12 @@ void Request::handle_fetch_state() if (auto const& path = default_certificate_path(); !path.is_empty()) set_option(CURLOPT_CAINFO, path.characters()); +#ifndef AK_OS_MACOS + if (!m_aia_collector) + m_aia_collector = make_ref_counted(); + install_aia_verification_hook(m_curl_easy_handle, *m_aia_collector); +#endif + set_option(CURLOPT_ACCEPT_ENCODING, ""); // Empty string lets curl define the accepted encodings. set_option(CURLOPT_URL, m_url.to_byte_string().characters()); set_option(CURLOPT_PORT, m_url.port_or_default()); @@ -1039,6 +1087,11 @@ void Request::handle_complete_state() if (m_curl_result_code == CURLE_RECV_ERROR && m_bytes_transferred_to_client != 0 && !m_response_headers->contains("Content-Length"sv)) m_curl_result_code = CURLE_OK; + if (m_curl_result_code == CURLE_PEER_FAILED_VERIFICATION && m_aia_collector && !m_aia_collector->pending_urls.is_empty() && m_aia_fetch_count < max_aia_fetches_per_request) { + start_aia_fetch_and_retry(); + return; + } + if (m_curl_result_code != CURLE_OK) { m_network_error = curl_code_to_network_error(*m_curl_result_code); @@ -1246,6 +1299,7 @@ ErrorOr Request::inform_client_request_started() m_client_request_pipe = request_pipe.release_value(); TRY(send_request_pipe_to_client()); + m_informed_client_request_started = true; return {}; } diff --git a/Services/RequestServer/Request.h b/Services/RequestServer/Request.h index 8ecad328a70ce..180b6271379e6 100644 --- a/Services/RequestServer/Request.h +++ b/Services/RequestServer/Request.h @@ -31,6 +31,8 @@ struct curl_slist; namespace RequestServer { +class AIACollector; + class Request final : public HTTP::CacheRequest { public: static NonnullOwnPtr fetch( @@ -85,6 +87,7 @@ class Request final : public HTTP::CacheRequest { virtual void notify_request_unblocked(Badge) override; void notify_retrieved_http_cookie(Badge, StringView cookie); void notify_fetch_complete(Badge, int result_code); + void retry_after_aia(Badge); private: struct TransferredBodyFile { @@ -109,6 +112,7 @@ class Request final : public HTTP::CacheRequest { RetrieveCookie, // Retrieve cookies from the UI process. Connect, // Issue a network request to connect to the URL. Fetch, // Issue a network request to fetch the URL. + WaitForAIA, // Wait for an AIA intermediate-certificate fetch to complete, then retry the fetch. Complete, // Finalize the request with the client. Error, // Any error occured during the request's lifetime. }; @@ -122,6 +126,8 @@ class Request final : public HTTP::CacheRequest { return "ReadCache"sv; case State::WaitForCache: return "WaitForCache"sv; + case State::WaitForAIA: + return "WaitForAIA"sv; case State::FailedCacheOnly: return "FailedCacheOnly"sv; case State::ServeSubstitution: @@ -185,6 +191,7 @@ class Request final : public HTTP::CacheRequest { ErrorOr detach_curl_handle_from_multi(); ErrorOr inform_client_request_started(); + void start_aia_fetch_and_retry(); ErrorOr send_request_pipe_to_client(); ErrorOr send_transferred_body_file_to_client(); void transfer_headers_to_client_if_needed(); @@ -211,6 +218,10 @@ class Request final : public HTTP::CacheRequest { void* m_curl_easy_handle { nullptr }; bool m_curl_easy_handle_is_in_multi { false }; Vector m_curl_string_lists; + + RefPtr m_aia_collector; + size_t m_aia_fetch_count { 0 }; + bool m_informed_client_request_started { false }; Optional m_curl_result_code; NonnullRefPtr m_resolver; From 133e8930e6d9d31718555e422751cc9c1367db86 Mon Sep 17 00:00:00 2001 From: sideshowbarker Date: Sun, 12 Jul 2026 17:40:02 +0900 Subject: [PATCH 2/3] Tests/RequestServer: Add unit tests for AIA certificate parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the two AIA-parsing primitives directly: extracting http caIssuers URLs from a cert’s AIA extension (https URLs skipped, capped per-cert), and parsing a fetched response body as a DER cert, a PKCS#7 certs-only bundle, or PEM. The test compiles AIA.cpp into its own binary, and links OpenSSL directly — rather than the whole RequestServer service. --- Tests/CMakeLists.txt | 1 + Tests/RequestServer/CMakeLists.txt | 3 + Tests/RequestServer/TestAIA.cpp | 123 +++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 Tests/RequestServer/CMakeLists.txt create mode 100644 Tests/RequestServer/TestAIA.cpp diff --git a/Tests/CMakeLists.txt b/Tests/CMakeLists.txt index 1dea8552b5578..387812511c4d1 100644 --- a/Tests/CMakeLists.txt +++ b/Tests/CMakeLists.txt @@ -18,6 +18,7 @@ add_subdirectory(LibUnicode) add_subdirectory(LibURL) add_subdirectory(LibWasm) add_subdirectory(LibXML) +add_subdirectory(RequestServer) add_subdirectory(Meta) if (ENABLE_GUI_TARGETS) diff --git a/Tests/RequestServer/CMakeLists.txt b/Tests/RequestServer/CMakeLists.txt new file mode 100644 index 0000000000000..c70dd326779b6 --- /dev/null +++ b/Tests/RequestServer/CMakeLists.txt @@ -0,0 +1,3 @@ +ladybird_test(TestAIA.cpp RequestServer LIBS OpenSSL::Crypto OpenSSL::SSL CURL::libcurl) +target_sources(TestAIA PRIVATE ${LADYBIRD_SOURCE_DIR}/Services/RequestServer/AIA.cpp) +target_include_directories(TestAIA PRIVATE ${LADYBIRD_SOURCE_DIR}/Services) diff --git a/Tests/RequestServer/TestAIA.cpp b/Tests/RequestServer/TestAIA.cpp new file mode 100644 index 0000000000000..c0e1f378c6a2f --- /dev/null +++ b/Tests/RequestServer/TestAIA.cpp @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026-present, the Ladybird developers. + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include + +#include + +// Generated certificate fixtures (EC/prime256v1, fixed validity 2020-2050): +// - LEAF: subject CN "leaf.aia.test"; AIA extension with one http caIssuers URL, one https caIssuers URL, one http OCSP URL. +// - CA: self-signed "AIA Test Root CA"; no AIA extension. +// - MANY: subject CN "many.aia.test"; AIA extension carrying six http caIssuers URLs. +// - PKCS7_TWO_CERTS: a PKCS#7 "certs-only" bundle containing [CA, leaf]. +static constexpr auto leaf_der_base64 = "MIIBnTCCAUSgAwIBAgIBAjAKBggqhkjOPQQDAjAbMRkwFwYDVQQDDBBBSUEgVGVzdCBSb290IENBMCAXDTIwMDEwMTAwMDAwMFoYDzIwNTAwMTAxMDAwMDAwWjAYMRYwFAYDVQQDDA1sZWFmLmFpYS50ZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEYsSP5b9NEG+zYqFyllVmL2kHe/eNXc0XUsRDFdTEBEFxn7I2cT7f8h8yjcrf63UgeEToVHWW5ySz1tzdbzCk9aN6MHgwdgYIKwYBBQUHAQEEajBoMCIGCCsGAQUFBzAChhZodHRwOi8vYWlhLnRlc3QvY2EuY3J0MCMGCCsGAQUFBzAChhdodHRwczovL2FpYS50ZXN0L2NhLmNydDAdBggrBgEFBQcwAYYRaHR0cDovL29jc3AudGVzdC8wCgYIKoZIzj0EAwIDRwAwRAIgR3a5MGrx7BBesYV8arbwfeBIprTELDzniCerSZuyHjgCIHn480IuXwqGJBJcBwqwvFcyRLEnfVI1HqmkCDElMA3C"sv; +static constexpr auto ca_der_base64 = "MIIBODCB4KADAgECAgEBMAoGCCqGSM49BAMCMBsxGTAXBgNVBAMMEEFJQSBUZXN0IFJvb3QgQ0EwIBcNMjAwMTAxMDAwMDAwWhgPMjA1MDAxMDEwMDAwMDBaMBsxGTAXBgNVBAMMEEFJQSBUZXN0IFJvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQvFaFCRjw2v74zuhfRNy6CQSj16eQo3DvL5UjoJo6Ic+jlA/En7yPQWBTdksQFW/3mxWqeoSShdsEzEVEkC84toxMwETAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIFcoD2A6nwl2nz8sgjQUl1h/vEmP2ou36HfWDjHNrUDBAiBuf54FrlwV+CbAPcABgP5ktbYmt7Ho0zaKWSuDSI/EeA=="sv; +static constexpr auto many_der_base64 = "MIICGTCCAb+gAwIBAgIBAjAKBggqhkjOPQQDAjAbMRkwFwYDVQQDDBBBSUEgVGVzdCBSb290IENBMCAXDTIwMDEwMTAwMDAwMFoYDzIwNTAwMTAxMDAwMDAwWjAYMRYwFAYDVQQDDA1tYW55LmFpYS50ZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExI4Mkk2uxbNU9fEXyoU9VSo172CiHbWDDbRkOhesAmAJHuBTleAkmTLAEWugT6bILtQTKCq5GldbYwMrHMJ2PaOB9DCB8TCB7gYIKwYBBQUHAQEEgeEwgd4wIwYIKwYBBQUHMAKGF2h0dHA6Ly9haWEudGVzdC9jYTAuY3J0MCMGCCsGAQUFBzAChhdodHRwOi8vYWlhLnRlc3QvY2ExLmNydDAjBggrBgEFBQcwAoYXaHR0cDovL2FpYS50ZXN0L2NhMi5jcnQwIwYIKwYBBQUHMAKGF2h0dHA6Ly9haWEudGVzdC9jYTMuY3J0MCMGCCsGAQUFBzAChhdodHRwOi8vYWlhLnRlc3QvY2E0LmNydDAjBggrBgEFBQcwAoYXaHR0cDovL2FpYS50ZXN0L2NhNS5jcnQwCgYIKoZIzj0EAwIDSAAwRQIgGwvZA7qgH9wcYmBZGRbWDbdsKQ7vbzyt27lQkobpVOgCIQCZZz5VBu6Ycv7e7i44STvL7tpiu2CeTqq5WQk6ZSvH+g=="sv; +static constexpr auto pkcs7_two_certs_base64 = "MIIDCAYJKoZIhvcNAQcCoIIC+TCCAvUCAQExADALBgkqhkiG9w0BBwGgggLdMIIBODCB4KADAgECAgEBMAoGCCqGSM49BAMCMBsxGTAXBgNVBAMMEEFJQSBUZXN0IFJvb3QgQ0EwIBcNMjAwMTAxMDAwMDAwWhgPMjA1MDAxMDEwMDAwMDBaMBsxGTAXBgNVBAMMEEFJQSBUZXN0IFJvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQvFaFCRjw2v74zuhfRNy6CQSj16eQo3DvL5UjoJo6Ic+jlA/En7yPQWBTdksQFW/3mxWqeoSShdsEzEVEkC84toxMwETAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIFcoD2A6nwl2nz8sgjQUl1h/vEmP2ou36HfWDjHNrUDBAiBuf54FrlwV+CbAPcABgP5ktbYmt7Ho0zaKWSuDSI/EeDCCAZ0wggFEoAMCAQICAQIwCgYIKoZIzj0EAwIwGzEZMBcGA1UEAwwQQUlBIFRlc3QgUm9vdCBDQTAgFw0yMDAxMDEwMDAwMDBaGA8yMDUwMDEwMTAwMDAwMFowGDEWMBQGA1UEAwwNbGVhZi5haWEudGVzdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABGLEj+W/TRBvs2KhcpZVZi9pB3v3jV3NF1LEQxXUxARBcZ+yNnE+3/IfMo3K3+t1IHhE6FR1lucks9bc3W8wpPWjejB4MHYGCCsGAQUFBwEBBGowaDAiBggrBgEFBQcwAoYWaHR0cDovL2FpYS50ZXN0L2NhLmNydDAjBggrBgEFBQcwAoYXaHR0cHM6Ly9haWEudGVzdC9jYS5jcnQwHQYIKwYBBQUHMAGGEWh0dHA6Ly9vY3NwLnRlc3QvMAoGCCqGSM49BAMCA0cAMEQCIEd2uTBq8ewQXrGFfGq28H3gSKa0xCw854gnq0mbsh44AiB5+PNCLl8KhiQSXAcKsLxXMkSxJ31SNR6ppAgxJTANwjEA"sv; +static constexpr auto ca_pem = R"PEM(-----BEGIN CERTIFICATE----- +MIIBODCB4KADAgECAgEBMAoGCCqGSM49BAMCMBsxGTAXBgNVBAMMEEFJQSBUZXN0 +IFJvb3QgQ0EwIBcNMjAwMTAxMDAwMDAwWhgPMjA1MDAxMDEwMDAwMDBaMBsxGTAX +BgNVBAMMEEFJQSBUZXN0IFJvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNC +AAQvFaFCRjw2v74zuhfRNy6CQSj16eQo3DvL5UjoJo6Ic+jlA/En7yPQWBTdksQF +W/3mxWqeoSShdsEzEVEkC84toxMwETAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49 +BAMCA0cAMEQCIFcoD2A6nwl2nz8sgjQUl1h/vEmP2ou36HfWDjHNrUDBAiBuf54F +rlwV+CbAPcABgP5ktbYmt7Ho0zaKWSuDSI/EeA== +-----END CERTIFICATE-----)PEM"sv; + +static X509* parse_der(StringView base64) +{ + auto der = MUST(decode_base64(base64)); + auto const* data = der.data(); + return d2i_X509(nullptr, &data, static_cast(der.size())); +} + +static ByteString subject_common_name(X509* certificate) +{ + char buffer[256] = {}; + X509_NAME_get_text_by_NID(X509_get_subject_name(certificate), NID_commonName, buffer, sizeof(buffer)); + return ByteString { buffer }; +} + +TEST_CASE(ca_issuers_urls_returns_only_http_ca_issuers) +{ + auto* leaf = parse_der(leaf_der_base64); + EXPECT_NE(leaf, nullptr); + + // Of the three access descriptions, only the http caIssuers URL is returned: The https caIssuers URL is skipped + // (fetching over TLS would itself need verification), and the OCSP URL isn't a caIssuers entry. + auto urls = RequestServer::ca_issuers_urls(leaf); + EXPECT_EQ(urls.size(), 1u); + EXPECT_EQ(urls[0], "http://aia.test/ca.crt"sv); + + X509_free(leaf); +} + +TEST_CASE(ca_issuers_urls_is_capped) +{ + auto* many = parse_der(many_der_base64); + EXPECT_NE(many, nullptr); + + // The certificate carries six http caIssuers URLs; extraction is capped at five. + EXPECT_EQ(RequestServer::ca_issuers_urls(many).size(), 5u); + + X509_free(many); +} + +TEST_CASE(ca_issuers_urls_empty_without_extension) +{ + auto* ca = parse_der(ca_der_base64); + EXPECT_NE(ca, nullptr); + + EXPECT(RequestServer::ca_issuers_urls(ca).is_empty()); + + X509_free(ca); +} + +TEST_CASE(parse_certificates_reads_der) +{ + auto der = MUST(decode_base64(leaf_der_base64)); + auto certificates = RequestServer::parse_certificates(der.bytes()); + EXPECT_EQ(certificates.size(), 1u); + EXPECT_EQ(subject_common_name(certificates[0]), "leaf.aia.test"sv); + for (auto* certificate : certificates) + X509_free(certificate); +} + +TEST_CASE(parse_certificates_reads_pkcs7_certs_only_bundle) +{ + // A PKCS#7 "certs-only" response can carry more than one certificate; all are returned. + auto der = MUST(decode_base64(pkcs7_two_certs_base64)); + auto certificates = RequestServer::parse_certificates(der.bytes()); + EXPECT_EQ(certificates.size(), 2u); + for (auto* certificate : certificates) + X509_free(certificate); +} + +TEST_CASE(parse_certificates_reads_pem) +{ + auto certificates = RequestServer::parse_certificates(ca_pem.bytes()); + EXPECT_EQ(certificates.size(), 1u); + EXPECT_EQ(subject_common_name(certificates[0]), "AIA Test Root CA"sv); + for (auto* certificate : certificates) + X509_free(certificate); +} + +TEST_CASE(parse_certificates_rejects_garbage) +{ + EXPECT(RequestServer::parse_certificates("this is not a certificate"sv.bytes()).is_empty()); +} + +TEST_CASE(parse_certificates_handles_degenerate_pkcs7_without_crashing) +{ + // A PKCS#7 SignedData whose (OPTIONAL) content is absent decodes with a NULL content pointer; parsing must not + // dereference it. Body: SEQUENCE { OBJECT IDENTIFIER signedData } with no content. + static constexpr Array degenerate_pkcs7 { 0x30, 0x0B, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x07, 0x02 }; + EXPECT(RequestServer::parse_certificates(degenerate_pkcs7).is_empty()); +} From 8e9cba02b23f795dfc6b118b6284bd491f336a95 Mon Sep 17 00:00:00 2001 From: sideshowbarker Date: Sun, 12 Jul 2026 17:45:34 +0900 Subject: [PATCH 3/3] Tests/LibWeb: Add an end-to-end AIA certificate-fetching test Drive the full AIA-fetch pipeline against real TLS. The http-test-server fixture now generates a root/intermediate/leaf certificate chain, and serves HTTPS hosts that present a broken chain (leaf only); test-web trusts the generated root via its request-server certificate option. --- .github/actions/setup/action.yml | 2 +- Tests/LibWeb/Fixtures/http-test-server.py | 253 +++++++++++++++++- .../Text/expected/aia-cert-fetching.txt | 5 + .../LibWeb/Text/input/aia-cert-fetching.html | 48 ++++ Tests/LibWeb/test-web/Application.cpp | 6 + Tests/LibWeb/test-web/Fixture.cpp | 5 +- Tests/LibWeb/test-web/main.cpp | 13 + 7 files changed, 328 insertions(+), 4 deletions(-) create mode 100644 Tests/LibWeb/Text/expected/aia-cert-fetching.txt create mode 100644 Tests/LibWeb/Text/input/aia-cert-fetching.html diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 8300404b78d4c..c093cee075a60 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -64,7 +64,7 @@ runs: python3 -m pip install --upgrade pip if ${{ inputs.type == 'build' }} ; then - pip3 install pyyaml requests six + pip3 install pyyaml requests six cryptography else pip3 install pyright ruff fi diff --git a/Tests/LibWeb/Fixtures/http-test-server.py b/Tests/LibWeb/Fixtures/http-test-server.py index fd16fff4b8b64..d2bce6869d820 100755 --- a/Tests/LibWeb/Fixtures/http-test-server.py +++ b/Tests/LibWeb/Fixtures/http-test-server.py @@ -10,6 +10,7 @@ import os import socket import socketserver +import ssl import sys import threading import time @@ -155,6 +156,8 @@ class TestHTTPServer(socketserver.ThreadingTCPServer): class TestHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): static_directory: str wpt_directory: str + aia_artifacts: dict + aia_config: Optional[dict] def __init__(self, *arguments, **kwargs): super().__init__(*arguments, directory=self.static_directory, **kwargs) @@ -252,6 +255,32 @@ def _serve_wpt_file_request(self): def _serve_static_request(self): self._serve_wpt_file_request() + def _serve_aia_artifact(self, request_path): + # Serves the intermediate certificate that the broken-chain HTTPS hosts point at via their AIA caIssuers URL. + # Fetched by RequestServer, not by the page. + entry = getattr(TestHTTPRequestHandler, "aia_artifacts", {}).get(request_path) + if entry is None: + # for example, /aia/dead: a deliberately missing artifact, to exercise the failure path. + self.send_error(404, "Not Found") + return + content_type, body = entry + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _serve_aia_config(self): + # Lets the test page discover the per-scenario HTTPS ports (each an OS-assigned port). + config = getattr(TestHTTPRequestHandler, "aia_config", None) + body = json.dumps(config or {}).encode("utf-8") + self.send_response(200 if config else 503) + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + def do_GET(self): if self.headers.get("Upgrade", "").lower() == "websocket": self._serve_websocket_echo() @@ -267,6 +296,10 @@ def do_GET(self): self._serve_recorded_request_headers() elif request_path.endswith(".py"): self._serve_wpt_python_script() + elif request_path.startswith("/aia/"): + self._serve_aia_artifact(request_path) + elif request_path == "/aia-test/config": + self._serve_aia_config() else: self._serve_static_request() @@ -636,7 +669,211 @@ def _send_websocket_close(self): pass -def start_server(port, static_directory): +class AIALeafHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", "2") + self.end_headers() + self.wfile.write(b"ok") + + def log_message(self, format: str, *args: Any) -> None: + pass + + +class AIATLSServer(socketserver.ThreadingTCPServer): + daemon_threads = True + allow_reuse_address = True + + def __init__(self, server_address, handler, ssl_context): + super().__init__(server_address, handler) + self._ssl_context = ssl_context + + def get_request(self): + sock, addr = super().get_request() + try: + return self._ssl_context.wrap_socket(sock, server_side=True), addr + except OSError: + sock.close() + raise + + +def _setup_aia_test_servers(http_port, ca_cert_output): + try: + from cryptography import x509 # type: ignore[import-not-found] + from cryptography.hazmat.primitives import hashes # type: ignore[import-not-found] + from cryptography.hazmat.primitives.asymmetric import ec # type: ignore[import-not-found] + from cryptography.hazmat.primitives.serialization import Encoding # type: ignore[import-not-found] + from cryptography.hazmat.primitives.serialization import NoEncryption # type: ignore[import-not-found] + from cryptography.hazmat.primitives.serialization import PrivateFormat # type: ignore[import-not-found] + from cryptography.hazmat.primitives.serialization import pkcs7 # type: ignore[import-not-found] + from cryptography.x509.oid import AuthorityInformationAccessOID # type: ignore[import-not-found] + from cryptography.x509.oid import ExtendedKeyUsageOID # type: ignore[import-not-found] + from cryptography.x509.oid import NameOID # type: ignore[import-not-found] + except ImportError as error: + logging.warning("AIA test setup skipped: python 'cryptography' unavailable (%s)", error) + return + + import datetime + import tempfile + + def new_key(): + return ec.generate_private_key(ec.SECP256R1()) + + def dn(common_name): + return x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)]) + + not_before, not_after = datetime.datetime(2020, 1, 1), datetime.datetime(2050, 1, 1) + + root_key = new_key() + root = ( + x509.CertificateBuilder() + .subject_name(dn("Ladybird AIA Test Root")) + .issuer_name(dn("Ladybird AIA Test Root")) + .public_key(root_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(not_before) + .not_valid_after(not_after) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .sign(root_key, hashes.SHA256()) + ) + + aia_base = f"http://127.0.0.1:{http_port}/aia" + artifacts = {} + scenarios = {} + + def add_scenario(name, host, artifact_name, encoding, shared=None): + # By default, each scenario gets its own intermediate — so fetching one never populates the cache for another + # (which would mask whether that scenario's own fetch and parse ran). The de-dupe scenarios instead share one + # intermediate — to check it's fetched only once. + if shared is not None: + intermediate, intermediate_key = shared + else: + intermediate_key = new_key() + intermediate = ( + x509.CertificateBuilder() + .subject_name(dn(f"Ladybird AIA Test Intermediate ({name})")) + .issuer_name(root.subject) + .public_key(intermediate_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(not_before) + .not_valid_after(not_after) + .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) + .sign(root_key, hashes.SHA256()) + ) + if encoding == "der": + artifacts[f"/aia/{artifact_name}"] = ("application/pkix-cert", intermediate.public_bytes(Encoding.DER)) + aia_url = f"{aia_base}/{artifact_name}" + elif encoding == "pkcs7": + artifacts[f"/aia/{artifact_name}"] = ( + "application/pkcs7-mime", + pkcs7.serialize_certificates([intermediate], Encoding.DER), + ) + aia_url = f"{aia_base}/{artifact_name}" + else: + aia_url = f"{aia_base}/dead" # 404: the intermediate is never published + + leaf_key = new_key() + leaf = ( + x509.CertificateBuilder() + .subject_name(dn(host)) + .issuer_name(intermediate.subject) + .public_key(leaf_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(not_before) + .not_valid_after(not_after) + .add_extension(x509.SubjectAlternativeName([x509.DNSName("localhost")]), critical=False) + .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False) + .add_extension( + x509.AuthorityInformationAccess( + [ + x509.AccessDescription( + AuthorityInformationAccessOID.CA_ISSUERS, x509.UniformResourceIdentifier(aia_url) + ) + ] + ), + critical=False, + ) + .sign(intermediate_key, hashes.SHA256()) + ) + scenarios[name] = (leaf, leaf_key) + return intermediate, intermediate_key + + add_scenario("good_der", "good-der.localhost", "intermediate-der.der", "der") + add_scenario("good_pkcs7", "good-pkcs7.localhost", "intermediate-pkcs7.p7c", "pkcs7") + add_scenario("dead", "dead.localhost", None, "dead") + shared_intermediate = add_scenario("dedup_a", "dedup-a.localhost", "intermediate-dedup.der", "der") + add_scenario("dedup_b", "dedup-b.localhost", "intermediate-dedup.der", "der", shared_intermediate) + + # Negative trust control: The caIssuers URL serves a self-signed cert that's *not* anchored in the trusted test + # root. A fetched intermediate must never complete a chain to an untrusted anchor — so this host must fail to load. + untrusted_key = new_key() + untrusted = ( + x509.CertificateBuilder() + .subject_name(dn("Ladybird AIA Test UNTRUSTED root")) + .issuer_name(dn("Ladybird AIA Test UNTRUSTED root")) + .public_key(untrusted_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(not_before) + .not_valid_after(not_after) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .sign(untrusted_key, hashes.SHA256()) + ) + artifacts["/aia/untrusted.der"] = ("application/pkix-cert", untrusted.public_bytes(Encoding.DER)) + untrusted_leaf_key = new_key() + untrusted_leaf = ( + x509.CertificateBuilder() + .subject_name(dn("untrusted-leaf.localhost")) + .issuer_name(untrusted.subject) + .public_key(untrusted_leaf_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(not_before) + .not_valid_after(not_after) + .add_extension(x509.SubjectAlternativeName([x509.DNSName("localhost")]), critical=False) + .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False) + .add_extension( + x509.AuthorityInformationAccess( + [ + x509.AccessDescription( + AuthorityInformationAccessOID.CA_ISSUERS, + x509.UniformResourceIdentifier(f"{aia_base}/untrusted.der"), + ) + ] + ), + critical=False, + ) + .sign(untrusted_key, hashes.SHA256()) + ) + scenarios["untrusted"] = (untrusted_leaf, untrusted_leaf_key) + + TestHTTPRequestHandler.aia_artifacts = artifacts + + # Write the root CA so RequestServer (via --certificate/CAINFO) trusts the completed chains. + with open(ca_cert_output, "wb") as ca_file: + ca_file.write(root.public_bytes(Encoding.PEM)) + + scratch = tempfile.mkdtemp(prefix="ladybird-aia-") + config = {} + for name, (leaf, leaf_key) in scenarios.items(): + # The cert file holds *only* the leaf — so, each HTTPS host presents a broken chain (the intermediate is omitted + # and must be fetched via AIA). + cert_path = os.path.join(scratch, f"{name}.crt") + key_path = os.path.join(scratch, f"{name}.key") + with open(cert_path, "wb") as f: + f.write(leaf.public_bytes(Encoding.PEM)) + with open(key_path, "wb") as f: + f.write(leaf_key.private_bytes(Encoding.PEM, PrivateFormat.TraditionalOpenSSL, NoEncryption())) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(certfile=cert_path, keyfile=key_path) + server = AIATLSServer(("127.0.0.1", 0), AIALeafHandler, context) + config[name] = server.socket.getsockname()[1] + threading.Thread(target=server.serve_forever, daemon=True).start() + + TestHTTPRequestHandler.aia_config = config + logging.info("AIA test servers ready: %s (root CA at %s)", config, ca_cert_output) + + +def start_server(port, static_directory, ca_cert_output=None): TestHTTPRequestHandler.static_directory = os.path.abspath(static_directory) TestHTTPRequestHandler.wpt_directory = os.path.join( TestHTTPRequestHandler.static_directory, "Text", "input", "wpt-import" @@ -653,6 +890,12 @@ def start_server(port, static_directory): url_base="/static/", ) + if ca_cert_output: + try: + _setup_aia_test_servers(httpd.socket.getsockname()[1], ca_cert_output) + except Exception as error: + logging.exception("AIA test setup failed: %s", error) + print(httpd.socket.getsockname()[1]) sys.stdout.flush() @@ -681,6 +924,12 @@ def start_server(port, static_directory): default=0, help="Port to run the server on", ) + parser.add_argument( + "--ca-cert-output", + type=str, + default=None, + help="Path to write the AIA test root CA (PEM) to; enables the AIA broken-chain HTTPS servers", + ) args = parser.parse_args() - start_server(port=args.port, static_directory=args.directory) + start_server(port=args.port, static_directory=args.directory, ca_cert_output=args.ca_cert_output) diff --git a/Tests/LibWeb/Text/expected/aia-cert-fetching.txt b/Tests/LibWeb/Text/expected/aia-cert-fetching.txt new file mode 100644 index 0000000000000..324084a2534d8 --- /dev/null +++ b/Tests/LibWeb/Text/expected/aia-cert-fetching.txt @@ -0,0 +1,5 @@ +DER intermediate via AIA: loaded +PKCS#7 intermediate via AIA: loaded +dead AIA URL: failed +untrusted intermediate (must not validate): failed +both hosts sharing an intermediate: loaded, loaded diff --git a/Tests/LibWeb/Text/input/aia-cert-fetching.html b/Tests/LibWeb/Text/input/aia-cert-fetching.html new file mode 100644 index 0000000000000..119a3b29dcd27 --- /dev/null +++ b/Tests/LibWeb/Text/input/aia-cert-fetching.html @@ -0,0 +1,48 @@ + + + diff --git a/Tests/LibWeb/test-web/Application.cpp b/Tests/LibWeb/test-web/Application.cpp index fdf456150ac63..9268fe5cc94b7 100644 --- a/Tests/LibWeb/test-web/Application.cpp +++ b/Tests/LibWeb/test-web/Application.cpp @@ -7,8 +7,10 @@ #include "Application.h" #include "Fixture.h" +#include #include #include +#include #include namespace TestWeb { @@ -73,6 +75,10 @@ void Application::create_platform_options(WebView::BrowserOptions& browser_optio request_server_options.http_disk_cache_mode = WebView::HTTPDiskCacheMode::Testing; + // Trust the AIA integration test's root CA (written by the http-test-server fixture) — so its broken-chain HTTPS + // hosts validate once their intermediate is fetched. Must match the path passed in HttpEchoServerFixture::setup. + request_server_options.certificates.append(LexicalPath::join(Core::StandardPaths::tempfile_directory(), "ladybird-aia-test-ca.pem"sv).string()); + web_content_options.is_test_mode = WebView::IsTestMode::Yes; // Allow window.open() to succeed for tests. diff --git a/Tests/LibWeb/test-web/Fixture.cpp b/Tests/LibWeb/test-web/Fixture.cpp index b39dc188aede2..a0e0a1cb41923 100644 --- a/Tests/LibWeb/test-web/Fixture.cpp +++ b/Tests/LibWeb/test-web/Fixture.cpp @@ -64,7 +64,10 @@ void HttpEchoServerFixture::teardown_impl() ErrorOr HttpEchoServerFixture::setup(WebView::WebContentOptions& web_content_options) { auto const script_path = LexicalPath::join(s_fixtures_path, m_script_path); - auto const arguments = Vector { script_path.string(), "--directory", Application::the().test_root_path }; + // Path where http-test-server.py writes the AIA test root CA. Must match the certificate trusted by + // TestWeb::Application::create_platform_options. + auto const ca_cert_path = LexicalPath::join(Core::StandardPaths::tempfile_directory(), "ladybird-aia-test-ca.pem"sv).string(); + auto const arguments = Vector { script_path.string(), "--directory", Application::the().test_root_path, "--ca-cert-output", ca_cert_path }; // FIXME: Pick a more reasonable log path that is more observable auto const log_path = LexicalPath::join(Core::StandardPaths::tempfile_directory(), "http-test-server.log"sv).string(); diff --git a/Tests/LibWeb/test-web/main.cpp b/Tests/LibWeb/test-web/main.cpp index 155b200b75ebc..266b5a2b46418 100644 --- a/Tests/LibWeb/test-web/main.cpp +++ b/Tests/LibWeb/test-web/main.cpp @@ -177,6 +177,18 @@ static ErrorOr skip_ui_process_session_history_tests_unless_enabled(Applic return {}; } +static ErrorOr skip_aia_tests_on_apple(Application const& app) +{ +#ifdef AK_OS_MACOS + auto path = LexicalPath::join(app.test_root_path, "Text/input/aia-cert-fetching.html"sv).string(); + if (FileSystem::exists(path)) + s_skipped_tests.append(TRY(real_path_for_test_input(path))); +#else + (void)app; +#endif + return {}; +} + static void log_active_test_views(StringView reason) { outln(); @@ -1025,6 +1037,7 @@ static ErrorOr run_tests(Core::AnonymousBuffer const& theme, Web::DevicePix TRY(load_test_config(app.test_root_path)); TRY(skip_async_scrolling_tests_unless_enabled(app)); TRY(skip_ui_process_session_history_tests_unless_enabled(app)); + TRY(skip_aia_tests_on_apple(app)); Vector tests;