Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
220 changes: 220 additions & 0 deletions Services/RequestServer/AIA.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
/*
* Copyright (c) 2026-present, the Ladybird developers.
*
* SPDX-License-Identifier: BSD-2-Clause
*/

#include <RequestServer/AIA.h>
#include <RequestServer/CURL.h>

#include <AK/ByteString.h>
#include <AK/HashMap.h>
#include <AK/Time.h>
#include <AK/Vector.h>

#include <openssl/pem.h>
#include <openssl/pkcs7.h>
#include <openssl/ssl.h>
#include <openssl/x509.h>
#include <openssl/x509_vfy.h>
#include <openssl/x509v3.h>

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<ByteString> ca_issuers_urls(X509* certificate)
{
Vector<ByteString> urls;

auto* authority_info_access = static_cast<AUTHORITY_INFO_ACCESS*>(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<char const*>(ASN1_STRING_get0_data(uri)), static_cast<size_t>(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<X509*>& intermediate_cache()
{
static Vector<X509*> 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<ByteString, MonotonicTime>& failed_urls()
{
static HashMap<ByteString, MonotonicTime> 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<X509*> parse_certificates(ReadonlyBytes body)
{
Vector<X509*> certificates;

auto const* der = body.data();
if (auto* certificate = d2i_X509(nullptr, &der, static_cast<long>(body.size()))) {
certificates.append(certificate);
return certificates;
}

auto const* pkcs7_data = body.data();
if (auto* pkcs7 = d2i_PKCS7(nullptr, &pkcs7_data, static_cast<long>(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<int>(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<AIACollector*>(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;
}
Comment thread
sideshowbarker marked this conversation as resolved.

// 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<AIACollector*>(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_CTX*>(ssl_context);
auto* collector = static_cast<AIACollector*>(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);
}

}
44 changes: 44 additions & 0 deletions Services/RequestServer/AIA.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2026-present, the Ladybird developers.
*
* SPDX-License-Identifier: BSD-2-Clause
*/

#pragma once

#include <AK/ByteString.h>
#include <AK/HashTable.h>
#include <AK/RefCounted.h>
#include <AK/RefPtr.h>
#include <AK/Span.h>
#include <AK/Vector.h>
#include <openssl/types.h>

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<AIACollector> {
public:
Vector<ByteString> pending_urls; // caIssuers URLs collected during verification, awaiting fetch.
HashTable<ByteString> 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<ByteString> ca_issuers_urls(X509* certificate);
Vector<X509*> parse_certificates(ReadonlyBytes body);

}
1 change: 1 addition & 0 deletions Services/RequestServer/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
set(SOURCES
AIA.cpp
ConnectionFromClient.cpp
CURL.cpp
Request.cpp
Expand Down
91 changes: 91 additions & 0 deletions Services/RequestServer/ConnectionFromClient.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
#include <LibRequests/WebSocket.h>
#include <LibWebSocket/ConnectionInfo.h>
#include <LibWebSocket/Message.h>
#include <RequestServer/AIA.h>
#include <RequestServer/CURL.h>
#include <RequestServer/ConnectionFromClient.h>
#include <RequestServer/Request.h>
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -435,6 +442,11 @@ void ConnectionFromClient::check_active_requests()
continue;
}

if (reinterpret_cast<uintptr_t>(application_private) & aia_fetch_private_tag) {
complete_aia_fetch(msg->easy_handle, msg->data.result);
continue;
}

++completions_drained;
auto* request = static_cast<Request*>(application_private);
request->notify_fetch_complete({}, msg->data.result);
Expand All @@ -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<AIAFetch*>(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<Request>, 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<AIAFetch>();
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<void*>(reinterpret_cast<uintptr_t>(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<curl_off_t>(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));
Expand Down
Loading