Skip to content

Commit f2deea5

Browse files
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.
1 parent 0a6d49b commit f2deea5

7 files changed

Lines changed: 327 additions & 4 deletions

File tree

.github/actions/setup/action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ runs:
6464
python3 -m pip install --upgrade pip
6565
6666
if ${{ inputs.type == 'build' }} ; then
67-
pip3 install pyyaml requests six
67+
pip3 install pyyaml requests six cryptography
6868
else
6969
pip3 install pyright ruff
7070
fi

Tests/LibWeb/Fixtures/http-test-server.py

Lines changed: 251 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import os
1111
import socket
1212
import socketserver
13+
import ssl
1314
import sys
1415
import threading
1516
import time
@@ -155,6 +156,8 @@ class TestHTTPServer(socketserver.ThreadingTCPServer):
155156
class TestHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
156157
static_directory: str
157158
wpt_directory: str
159+
aia_artifacts: dict
160+
aia_config: Optional[dict]
158161

159162
def __init__(self, *arguments, **kwargs):
160163
super().__init__(*arguments, directory=self.static_directory, **kwargs)
@@ -252,6 +255,32 @@ def _serve_wpt_file_request(self):
252255
def _serve_static_request(self):
253256
self._serve_wpt_file_request()
254257

258+
def _serve_aia_artifact(self, request_path):
259+
# Serves the intermediate certificate that the broken-chain HTTPS hosts point at via their AIA caIssuers URL.
260+
# Fetched by RequestServer, not by the page.
261+
entry = getattr(TestHTTPRequestHandler, "aia_artifacts", {}).get(request_path)
262+
if entry is None:
263+
# for example, /aia/dead: a deliberately missing artifact, to exercise the failure path.
264+
self.send_error(404, "Not Found")
265+
return
266+
content_type, body = entry
267+
self.send_response(200)
268+
self.send_header("Content-Type", content_type)
269+
self.send_header("Content-Length", str(len(body)))
270+
self.end_headers()
271+
self.wfile.write(body)
272+
273+
def _serve_aia_config(self):
274+
# Lets the test page discover the per-scenario HTTPS ports (each an OS-assigned port).
275+
config = getattr(TestHTTPRequestHandler, "aia_config", None)
276+
body = json.dumps(config or {}).encode("utf-8")
277+
self.send_response(200 if config else 503)
278+
self.send_header("Content-Type", "application/json")
279+
self.send_header("Access-Control-Allow-Origin", "*")
280+
self.send_header("Content-Length", str(len(body)))
281+
self.end_headers()
282+
self.wfile.write(body)
283+
255284
def do_GET(self):
256285
if self.headers.get("Upgrade", "").lower() == "websocket":
257286
self._serve_websocket_echo()
@@ -267,6 +296,10 @@ def do_GET(self):
267296
self._serve_recorded_request_headers()
268297
elif request_path.endswith(".py"):
269298
self._serve_wpt_python_script()
299+
elif request_path.startswith("/aia/"):
300+
self._serve_aia_artifact(request_path)
301+
elif request_path == "/aia-test/config":
302+
self._serve_aia_config()
270303
else:
271304
self._serve_static_request()
272305

@@ -636,7 +669,211 @@ def _send_websocket_close(self):
636669
pass
637670

638671

639-
def start_server(port, static_directory):
672+
class AIALeafHandler(http.server.BaseHTTPRequestHandler):
673+
def do_GET(self):
674+
self.send_response(200)
675+
self.send_header("Content-Type", "text/plain")
676+
self.send_header("Content-Length", "2")
677+
self.end_headers()
678+
self.wfile.write(b"ok")
679+
680+
def log_message(self, format: str, *args: Any) -> None:
681+
pass
682+
683+
684+
class AIATLSServer(socketserver.ThreadingTCPServer):
685+
daemon_threads = True
686+
allow_reuse_address = True
687+
688+
def __init__(self, server_address, handler, ssl_context):
689+
super().__init__(server_address, handler)
690+
self._ssl_context = ssl_context
691+
692+
def get_request(self):
693+
sock, addr = super().get_request()
694+
try:
695+
return self._ssl_context.wrap_socket(sock, server_side=True), addr
696+
except OSError:
697+
sock.close()
698+
raise
699+
700+
701+
def _setup_aia_test_servers(http_port, ca_cert_output):
702+
try:
703+
from cryptography import x509 # type: ignore[import-not-found]
704+
from cryptography.hazmat.primitives import hashes # type: ignore[import-not-found]
705+
from cryptography.hazmat.primitives.asymmetric import ec # type: ignore[import-not-found]
706+
from cryptography.hazmat.primitives.serialization import Encoding # type: ignore[import-not-found]
707+
from cryptography.hazmat.primitives.serialization import NoEncryption # type: ignore[import-not-found]
708+
from cryptography.hazmat.primitives.serialization import PrivateFormat # type: ignore[import-not-found]
709+
from cryptography.hazmat.primitives.serialization import pkcs7 # type: ignore[import-not-found]
710+
from cryptography.x509.oid import AuthorityInformationAccessOID # type: ignore[import-not-found]
711+
from cryptography.x509.oid import ExtendedKeyUsageOID # type: ignore[import-not-found]
712+
from cryptography.x509.oid import NameOID # type: ignore[import-not-found]
713+
except ImportError as error:
714+
logging.warning("AIA test setup skipped: python 'cryptography' unavailable (%s)", error)
715+
return
716+
717+
import datetime
718+
import tempfile
719+
720+
def new_key():
721+
return ec.generate_private_key(ec.SECP256R1())
722+
723+
def dn(common_name):
724+
return x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)])
725+
726+
not_before, not_after = datetime.datetime(2020, 1, 1), datetime.datetime(2050, 1, 1)
727+
728+
root_key = new_key()
729+
root = (
730+
x509.CertificateBuilder()
731+
.subject_name(dn("Ladybird AIA Test Root"))
732+
.issuer_name(dn("Ladybird AIA Test Root"))
733+
.public_key(root_key.public_key())
734+
.serial_number(x509.random_serial_number())
735+
.not_valid_before(not_before)
736+
.not_valid_after(not_after)
737+
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
738+
.sign(root_key, hashes.SHA256())
739+
)
740+
741+
aia_base = f"http://127.0.0.1:{http_port}/aia"
742+
artifacts = {}
743+
scenarios = {}
744+
745+
def add_scenario(name, host, artifact_name, encoding, shared=None):
746+
# By default, each scenario gets its own intermediate — so fetching one never populates the cache for another
747+
# (which would mask whether that scenario's own fetch and parse ran). The de-dupe scenarios instead share one
748+
# intermediate — to check it's fetched only once.
749+
if shared is not None:
750+
intermediate, intermediate_key = shared
751+
else:
752+
intermediate_key = new_key()
753+
intermediate = (
754+
x509.CertificateBuilder()
755+
.subject_name(dn(f"Ladybird AIA Test Intermediate ({name})"))
756+
.issuer_name(root.subject)
757+
.public_key(intermediate_key.public_key())
758+
.serial_number(x509.random_serial_number())
759+
.not_valid_before(not_before)
760+
.not_valid_after(not_after)
761+
.add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
762+
.sign(root_key, hashes.SHA256())
763+
)
764+
if encoding == "der":
765+
artifacts[f"/aia/{artifact_name}"] = ("application/pkix-cert", intermediate.public_bytes(Encoding.DER))
766+
aia_url = f"{aia_base}/{artifact_name}"
767+
elif encoding == "pkcs7":
768+
artifacts[f"/aia/{artifact_name}"] = (
769+
"application/pkcs7-mime",
770+
pkcs7.serialize_certificates([intermediate], Encoding.DER),
771+
)
772+
aia_url = f"{aia_base}/{artifact_name}"
773+
else:
774+
aia_url = f"{aia_base}/dead" # 404: the intermediate is never published
775+
776+
leaf_key = new_key()
777+
leaf = (
778+
x509.CertificateBuilder()
779+
.subject_name(dn(host))
780+
.issuer_name(intermediate.subject)
781+
.public_key(leaf_key.public_key())
782+
.serial_number(x509.random_serial_number())
783+
.not_valid_before(not_before)
784+
.not_valid_after(not_after)
785+
.add_extension(x509.SubjectAlternativeName([x509.DNSName("localhost")]), critical=False)
786+
.add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False)
787+
.add_extension(
788+
x509.AuthorityInformationAccess(
789+
[
790+
x509.AccessDescription(
791+
AuthorityInformationAccessOID.CA_ISSUERS, x509.UniformResourceIdentifier(aia_url)
792+
)
793+
]
794+
),
795+
critical=False,
796+
)
797+
.sign(intermediate_key, hashes.SHA256())
798+
)
799+
scenarios[name] = (leaf, leaf_key)
800+
return intermediate, intermediate_key
801+
802+
add_scenario("good_der", "good-der.localhost", "intermediate-der.der", "der")
803+
add_scenario("good_pkcs7", "good-pkcs7.localhost", "intermediate-pkcs7.p7c", "pkcs7")
804+
add_scenario("dead", "dead.localhost", None, "dead")
805+
shared_intermediate = add_scenario("dedup_a", "dedup-a.localhost", "intermediate-dedup.der", "der")
806+
add_scenario("dedup_b", "dedup-b.localhost", "intermediate-dedup.der", "der", shared_intermediate)
807+
808+
# Negative trust control: The caIssuers URL serves a self-signed cert that's *not* anchored in the trusted test
809+
# root. A fetched intermediate must never complete a chain to an untrusted anchor — so this host must fail to load.
810+
untrusted_key = new_key()
811+
untrusted = (
812+
x509.CertificateBuilder()
813+
.subject_name(dn("Ladybird AIA Test UNTRUSTED root"))
814+
.issuer_name(dn("Ladybird AIA Test UNTRUSTED root"))
815+
.public_key(untrusted_key.public_key())
816+
.serial_number(x509.random_serial_number())
817+
.not_valid_before(not_before)
818+
.not_valid_after(not_after)
819+
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
820+
.sign(untrusted_key, hashes.SHA256())
821+
)
822+
artifacts["/aia/untrusted.der"] = ("application/pkix-cert", untrusted.public_bytes(Encoding.DER))
823+
untrusted_leaf_key = new_key()
824+
untrusted_leaf = (
825+
x509.CertificateBuilder()
826+
.subject_name(dn("untrusted-leaf.localhost"))
827+
.issuer_name(untrusted.subject)
828+
.public_key(untrusted_leaf_key.public_key())
829+
.serial_number(x509.random_serial_number())
830+
.not_valid_before(not_before)
831+
.not_valid_after(not_after)
832+
.add_extension(x509.SubjectAlternativeName([x509.DNSName("localhost")]), critical=False)
833+
.add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False)
834+
.add_extension(
835+
x509.AuthorityInformationAccess(
836+
[
837+
x509.AccessDescription(
838+
AuthorityInformationAccessOID.CA_ISSUERS,
839+
x509.UniformResourceIdentifier(f"{aia_base}/untrusted.der"),
840+
)
841+
]
842+
),
843+
critical=False,
844+
)
845+
.sign(untrusted_key, hashes.SHA256())
846+
)
847+
scenarios["untrusted"] = (untrusted_leaf, untrusted_leaf_key)
848+
849+
TestHTTPRequestHandler.aia_artifacts = artifacts
850+
851+
# Write the root CA so RequestServer (via --certificate/CAINFO) trusts the completed chains.
852+
with open(ca_cert_output, "wb") as ca_file:
853+
ca_file.write(root.public_bytes(Encoding.PEM))
854+
855+
scratch = tempfile.mkdtemp(prefix="ladybird-aia-")
856+
config = {}
857+
for name, (leaf, leaf_key) in scenarios.items():
858+
# The cert file holds *only* the leaf — so, each HTTPS host presents a broken chain (the intermediate is omitted
859+
# and must be fetched via AIA).
860+
cert_path = os.path.join(scratch, f"{name}.crt")
861+
key_path = os.path.join(scratch, f"{name}.key")
862+
with open(cert_path, "wb") as f:
863+
f.write(leaf.public_bytes(Encoding.PEM))
864+
with open(key_path, "wb") as f:
865+
f.write(leaf_key.private_bytes(Encoding.PEM, PrivateFormat.TraditionalOpenSSL, NoEncryption()))
866+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
867+
context.load_cert_chain(certfile=cert_path, keyfile=key_path)
868+
server = AIATLSServer(("127.0.0.1", 0), AIALeafHandler, context)
869+
config[name] = server.socket.getsockname()[1]
870+
threading.Thread(target=server.serve_forever, daemon=True).start()
871+
872+
TestHTTPRequestHandler.aia_config = config
873+
logging.info("AIA test servers ready: %s (root CA at %s)", config, ca_cert_output)
874+
875+
876+
def start_server(port, static_directory, ca_cert_output=None):
640877
TestHTTPRequestHandler.static_directory = os.path.abspath(static_directory)
641878
TestHTTPRequestHandler.wpt_directory = os.path.join(
642879
TestHTTPRequestHandler.static_directory, "Text", "input", "wpt-import"
@@ -653,6 +890,12 @@ def start_server(port, static_directory):
653890
url_base="/static/",
654891
)
655892

893+
if ca_cert_output:
894+
try:
895+
_setup_aia_test_servers(httpd.socket.getsockname()[1], ca_cert_output)
896+
except Exception as error:
897+
logging.exception("AIA test setup failed: %s", error)
898+
656899
print(httpd.socket.getsockname()[1])
657900
sys.stdout.flush()
658901

@@ -681,6 +924,12 @@ def start_server(port, static_directory):
681924
default=0,
682925
help="Port to run the server on",
683926
)
927+
parser.add_argument(
928+
"--ca-cert-output",
929+
type=str,
930+
default=None,
931+
help="Path to write the AIA test root CA (PEM) to; enables the AIA broken-chain HTTPS servers",
932+
)
684933
args = parser.parse_args()
685934

686-
start_server(port=args.port, static_directory=args.directory)
935+
start_server(port=args.port, static_directory=args.directory, ca_cert_output=args.ca_cert_output)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
DER intermediate via AIA: loaded
2+
PKCS#7 intermediate via AIA: loaded
3+
dead AIA URL: failed
4+
untrusted intermediate (must not validate): failed
5+
both hosts sharing an intermediate: loaded, loaded
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<!doctype html>
2+
<script src="./include.js"></script>
3+
<script>
4+
// Exercises AIA fetching end-to-end: The fixture's HTTPS hosts present a broken chain (leaf only), and
5+
// RequestServer must fetch the missing intermediate from the leaf's caIssuers URL to complete verification.
6+
asyncTest(async done => {
7+
const base = httpTestServer().baseURL;
8+
9+
let config;
10+
try {
11+
config = await (await fetch(`${base}/aia-test/config`)).json();
12+
} catch (error) {
13+
println("FAIL - could not read AIA test config: " + error);
14+
done();
15+
return;
16+
}
17+
18+
if (!config.good_der) {
19+
println(
20+
"FAIL - AIA test HTTPS servers unavailable (is the 'cryptography' package installed for the test runner's python?)"
21+
);
22+
done();
23+
return;
24+
}
25+
26+
async function loads(port) {
27+
try {
28+
await fetch(`https://localhost:${port}/`, { mode: "no-cors" });
29+
return "loaded";
30+
} catch {
31+
return "failed";
32+
}
33+
}
34+
35+
const goodDer = await loads(config.good_der);
36+
const goodPkcs7 = await loads(config.good_pkcs7);
37+
const dead = await loads(config.dead);
38+
const untrusted = await loads(config.untrusted);
39+
const [dedupA, dedupB] = await Promise.all([loads(config.dedup_a), loads(config.dedup_b)]);
40+
41+
println("DER intermediate via AIA: " + goodDer);
42+
println("PKCS#7 intermediate via AIA: " + goodPkcs7);
43+
println("dead AIA URL: " + dead);
44+
println("untrusted intermediate (must not validate): " + untrusted);
45+
println("both hosts sharing an intermediate: " + dedupA + ", " + dedupB);
46+
done();
47+
});
48+
</script>

Tests/LibWeb/test-web/Application.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
#include "Application.h"
88
#include "Fixture.h"
99

10+
#include <AK/LexicalPath.h>
1011
#include <LibCore/ArgsParser.h>
1112
#include <LibCore/Environment.h>
13+
#include <LibCore/StandardPaths.h>
1214
#include <LibCore/System.h>
1315

1416
namespace TestWeb {
@@ -73,6 +75,10 @@ void Application::create_platform_options(WebView::BrowserOptions& browser_optio
7375

7476
request_server_options.http_disk_cache_mode = WebView::HTTPDiskCacheMode::Testing;
7577

78+
// Trust the AIA integration test's root CA (written by the http-test-server fixture) — so its broken-chain HTTPS
79+
// hosts validate once their intermediate is fetched. Must match the path passed in HttpEchoServerFixture::setup.
80+
request_server_options.certificates.append(LexicalPath::join(Core::StandardPaths::tempfile_directory(), "ladybird-aia-test-ca.pem"sv).string());
81+
7682
web_content_options.is_test_mode = WebView::IsTestMode::Yes;
7783

7884
// Allow window.open() to succeed for tests.

Tests/LibWeb/test-web/Fixture.cpp

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,10 @@ void HttpEchoServerFixture::teardown_impl()
6464
ErrorOr<void> HttpEchoServerFixture::setup(WebView::WebContentOptions& web_content_options)
6565
{
6666
auto const script_path = LexicalPath::join(s_fixtures_path, m_script_path);
67-
auto const arguments = Vector { script_path.string(), "--directory", Application::the().test_root_path };
67+
// Path where http-test-server.py writes the AIA test root CA. Must match the certificate trusted by
68+
// TestWeb::Application::create_platform_options.
69+
auto const ca_cert_path = LexicalPath::join(Core::StandardPaths::tempfile_directory(), "ladybird-aia-test-ca.pem"sv).string();
70+
auto const arguments = Vector { script_path.string(), "--directory", Application::the().test_root_path, "--ca-cert-output", ca_cert_path };
6871

6972
// FIXME: Pick a more reasonable log path that is more observable
7073
auto const log_path = LexicalPath::join(Core::StandardPaths::tempfile_directory(), "http-test-server.log"sv).string();

0 commit comments

Comments
 (0)