Skip to content

Commit 1d512e5

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 358e3b7 commit 1d512e5

6 files changed

Lines changed: 348 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: 279 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,10 @@ class TestHTTPServer(socketserver.ThreadingTCPServer):
155156
class TestHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
156157
static_directory: str
157158
wpt_directory: str
159+
aia_artifacts: dict
160+
aia_fetch_counts: dict
161+
aia_lock: Any
162+
aia_config: Optional[dict]
158163

159164
def __init__(self, *arguments, **kwargs):
160165
super().__init__(*arguments, directory=self.static_directory, **kwargs)
@@ -252,6 +257,54 @@ def _serve_wpt_file_request(self):
252257
def _serve_static_request(self):
253258
self._serve_wpt_file_request()
254259

260+
def _serve_aia_artifact(self, request_path):
261+
# Serves the intermediate certificate that the broken-chain HTTPS hosts point at via their AIA caIssuers URL.
262+
# Fetched by RequestServer, not by the page.
263+
lock = getattr(TestHTTPRequestHandler, "aia_lock", None)
264+
if lock:
265+
with lock:
266+
counts = TestHTTPRequestHandler.aia_fetch_counts
267+
counts[request_path] = counts.get(request_path, 0) + 1
268+
entry = getattr(TestHTTPRequestHandler, "aia_artifacts", {}).get(request_path)
269+
if entry is None:
270+
# for example, /aia/dead: a deliberately missing artifact, to exercise the failure path.
271+
self.send_error(404, "Not Found")
272+
return
273+
content_type, body = entry
274+
self.send_response(200)
275+
self.send_header("Content-Type", content_type)
276+
self.send_header("Content-Length", str(len(body)))
277+
self.end_headers()
278+
self.wfile.write(body)
279+
280+
def _serve_aia_config(self):
281+
# Lets the test page discover the per-scenario HTTPS ports (each an OS-assigned port).
282+
config = getattr(TestHTTPRequestHandler, "aia_config", None)
283+
body = json.dumps(config or {}).encode("utf-8")
284+
self.send_response(200 if config else 503)
285+
self.send_header("Content-Type", "application/json")
286+
self.send_header("Access-Control-Allow-Origin", "*")
287+
self.send_header("Content-Length", str(len(body)))
288+
self.end_headers()
289+
self.wfile.write(body)
290+
291+
def _serve_aia_fetch_counts(self):
292+
# Exposes how many times each AIA URL has been fetched — so the test can assert negative caching (a dead URL is
293+
# fetched once, not retried to the budget) and de-dupe (a shared intermediate is fetched once across two hosts).
294+
lock = getattr(TestHTTPRequestHandler, "aia_lock", None)
295+
if lock:
296+
with lock:
297+
snapshot = dict(getattr(TestHTTPRequestHandler, "aia_fetch_counts", {}))
298+
else:
299+
snapshot = {}
300+
body = json.dumps(snapshot).encode("utf-8")
301+
self.send_response(200)
302+
self.send_header("Content-Type", "application/json")
303+
self.send_header("Access-Control-Allow-Origin", "*")
304+
self.send_header("Content-Length", str(len(body)))
305+
self.end_headers()
306+
self.wfile.write(body)
307+
255308
def do_GET(self):
256309
if self.headers.get("Upgrade", "").lower() == "websocket":
257310
self._serve_websocket_echo()
@@ -267,6 +320,12 @@ def do_GET(self):
267320
self._serve_recorded_request_headers()
268321
elif request_path.endswith(".py"):
269322
self._serve_wpt_python_script()
323+
elif request_path.startswith("/aia/"):
324+
self._serve_aia_artifact(request_path)
325+
elif request_path == "/aia-test/config":
326+
self._serve_aia_config()
327+
elif request_path == "/aia-test/fetch-counts":
328+
self._serve_aia_fetch_counts()
270329
else:
271330
self._serve_static_request()
272331

@@ -636,7 +695,213 @@ def _send_websocket_close(self):
636695
pass
637696

638697

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

921+
if ca_cert_output:
922+
try:
923+
_setup_aia_test_servers(httpd.socket.getsockname()[1], ca_cert_output)
924+
except Exception as error:
925+
logging.exception("AIA test setup failed: %s", error)
926+
656927
print(httpd.socket.getsockname()[1])
657928
sys.stdout.flush()
658929

@@ -681,6 +952,12 @@ def start_server(port, static_directory):
681952
default=0,
682953
help="Port to run the server on",
683954
)
955+
parser.add_argument(
956+
"--ca-cert-output",
957+
type=str,
958+
default=None,
959+
help="Path to write the AIA test root CA (PEM) to; enables the AIA broken-chain HTTPS servers",
960+
)
684961
args = parser.parse_args()
685962

686-
start_server(port=args.port, static_directory=args.directory)
963+
start_server(port=args.port, static_directory=args.directory, ca_cert_output=args.ca_cert_output)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
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
6+
dead URL fetched once (negative cache): true
7+
shared intermediate fetched only once: true
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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+
const counts = await (await fetch(`${base}/aia-test/fetch-counts`)).json();
41+
42+
println("DER intermediate via AIA: " + goodDer);
43+
println("PKCS#7 intermediate via AIA: " + goodPkcs7);
44+
println("dead AIA URL: " + dead);
45+
println("untrusted intermediate (must not validate): " + untrusted);
46+
println("both hosts sharing an intermediate: " + dedupA + ", " + dedupB);
47+
println("dead URL fetched once (negative cache): " + (counts["/aia/dead"] === 1));
48+
println("shared intermediate fetched only once: " + (counts["/aia/intermediate-dedup.der"] === 1));
49+
done();
50+
});
51+
</script>

0 commit comments

Comments
 (0)