-
-
Notifications
You must be signed in to change notification settings - Fork 3.1k
Implement AIA fetching on Linux, for sites with broken cert chains #10661
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
sideshowbarker
wants to merge
3
commits into
LadybirdBrowser:master
Choose a base branch
from
sideshowbarker:aia-cert-fetching
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
82dedcf
RequestServer: Implement AIA fetching, for sites with broken cert chains
sideshowbarker 133e893
Tests/RequestServer: Add unit tests for AIA certificate parsing
sideshowbarker 8e9cba0
Tests/LibWeb: Add an end-to-end AIA certificate-fetching test
sideshowbarker File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
|
|
||
| // 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); | ||
| } | ||
|
|
||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
|
|
||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| set(SOURCES | ||
| AIA.cpp | ||
| ConnectionFromClient.cpp | ||
| CURL.cpp | ||
| Request.cpp | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.