From 55f82a2f3ecfd990823a3a7321e87cc205087250 Mon Sep 17 00:00:00 2001 From: MDA2AV Date: Mon, 24 Aug 2026 15:33:54 +0100 Subject: [PATCH] tls_check: an opt-in TLS hardening check, with a badge on the H1 composite Opted into with a meta.json field: "tls_check": true It needs a TLS listener on :9000 reading /certs-tls, a directory mounted for that entry alone. Nothing is measured; passing earns a badge on the HTTP/1.1 composite, and only there -- the check covers :8081-class HTTP/1.1 TLS, so a badge earned on it must not follow the entry into h2 and h3 views it says nothing about. certificate rotation the pair at /certs-tls is replaced under a running server; the new certificate must be served without a restart and still answer. Measured first: caddy, bun and h2o-mruby all keep serving the old one. rotation keeps serving 30 requests across the swap, all must succeed SNI handshakes with a server name and without session resumption reported, not required close_notify closed at the TLS layer, not just the socket vulnerability suite testssl.sh -U: Heartbleed, ROBOT, POODLE, SWEET32, LUCKY13 and 14 more. HIGH or CRITICAL fails. the shared TLS checks certificate identity, TLS 1.3, AEAD, ALPN, obsolete protocols and weak ciphers The dedicated port and private directory are the point of the design: the check rotates certificates under a running server, and doing that to the shared /certs would move the ground under json-tls, static-tls and every h2 profile in the same run. /certs is verified byte identical across a run that rotates twice. aspnet-minimal opts in and implements it. RotatingCertificate re-reads the pair when its mtime moves, behind Kestrel's ServerCertificateSelector, which runs per handshake. 114 passed, 0 failed; rotation lands in 1s and 30/30 requests survive the swap. --- frameworks/aspnet-minimal/Program.cs | 32 +- .../aspnet-minimal/RotatingCertificate.cs | 99 ++++++ frameworks/aspnet-minimal/meta.json | 3 +- scripts/gen_leaderboard_data.py | 17 +- scripts/validate.sh | 299 +++++++++++++++++- .../h1/isolated/tls/validation.md | 83 +++++ site/data/tls/aspnet-minimal.json | 5 + site/leaderboard/index.html | 28 +- 8 files changed, 554 insertions(+), 12 deletions(-) create mode 100644 frameworks/aspnet-minimal/RotatingCertificate.cs create mode 100644 site/content/docs/test-profiles/h1/isolated/tls/validation.md create mode 100644 site/data/tls/aspnet-minimal.json diff --git a/frameworks/aspnet-minimal/Program.cs b/frameworks/aspnet-minimal/Program.cs index 8f5552872..9614b1af2 100644 --- a/frameworks/aspnet-minimal/Program.cs +++ b/frameworks/aspnet-minimal/Program.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography.X509Certificates; +using HttpArena; using HttpArena.Services; using HttpArena.Types; @@ -23,6 +24,14 @@ var keyPath = Environment.GetEnvironmentVariable("TLS_KEY") ?? "/certs/server.key"; var hasCert = File.Exists(certPath) && File.Exists(keyPath); +// The opt-in tls_check gets its own listener on :9000 and its own pair at +// /certs-tls. It rotates certificates under a running server, and pointing it +// at /certs would move the ground under json-tls, static-tls and the h2 +// profiles in the same validation run. +var tlsCheckCert = "/certs-tls/server.crt"; +var tlsCheckKey = "/certs-tls/server.key"; +var hasTlsCheck = File.Exists(tlsCheckCert) && File.Exists(tlsCheckKey); + builder.WebHost.ConfigureKestrel(options => { options.Limits.Http2.MaxStreamsPerConnection = 256; @@ -45,12 +54,15 @@ if (hasCert) { - var cert = X509Certificate2.CreateFromPemFile(certPath, keyPath); + // Re-read when the files change, so a rotation lands without a restart. + // The selector below runs per handshake, which is what makes that + // visible to the next connection rather than the next process. + var cert = new RotatingCertificate(certPath, keyPath); options.ListenAnyIP(8443, lo => { lo.Protocols = HttpProtocols.Http1AndHttp2AndHttp3; - lo.UseHttps(cert); + lo.UseHttps(https => https.ServerCertificateSelector = (_, _) => cert.Current); }); // HTTP/1.1-only TLS listener for the json-tls profile. Kestrel @@ -59,7 +71,21 @@ options.ListenAnyIP(8081, lo => { lo.Protocols = HttpProtocols.Http1; - lo.UseHttps(cert); + lo.UseHttps(https => https.ServerCertificateSelector = (_, _) => cert.Current); + }); + } + + if (hasTlsCheck) + { + // Same rotating handle, a different pair. The selector runs per + // handshake, which is what lets a replaced file reach the next + // connection instead of the next process. + var checkCert = new RotatingCertificate(tlsCheckCert, tlsCheckKey); + + options.ListenAnyIP(9000, lo => + { + lo.Protocols = HttpProtocols.Http1; + lo.UseHttps(https => https.ServerCertificateSelector = (_, _) => checkCert.Current); }); } }); diff --git a/frameworks/aspnet-minimal/RotatingCertificate.cs b/frameworks/aspnet-minimal/RotatingCertificate.cs new file mode 100644 index 000000000..ab1d17baa --- /dev/null +++ b/frameworks/aspnet-minimal/RotatingCertificate.cs @@ -0,0 +1,99 @@ +using System.Security.Cryptography.X509Certificates; + +namespace HttpArena; + +/// +/// The server certificate, re-read from disk when the file underneath it +/// changes. +/// +/// Kestrel's ServerCertificateSelector runs per handshake, so this is where a +/// rotation becomes visible without a restart: the selector asks for Current, +/// and Current notices the PEM was replaced. That is what the opt-in `tls` +/// profile checks -- a certificate is renewed roughly every 60 days in +/// production, and a server that needs a restart to pick one up is a weaker +/// server. +/// +/// The mtime check is throttled rather than run on every handshake. A stat is +/// cheap next to a TLS handshake, but not next to a resumed one, and a second +/// of staleness costs nothing when the thing being tracked changes every two +/// months. +/// +internal sealed class RotatingCertificate : IDisposable +{ + private const int CheckIntervalMs = 1000; + + private readonly string _certPath; + private readonly string _keyPath; + private readonly object _gate = new(); + + private X509Certificate2 _current; + private DateTime _loadedStamp; + private long _lastCheck; + + public RotatingCertificate(string certPath, string keyPath) + { + _certPath = certPath; + _keyPath = keyPath; + _current = Load(certPath, keyPath); + _loadedStamp = Stamp(certPath, keyPath); + _lastCheck = Environment.TickCount64; + } + + public X509Certificate2 Current + { + get + { + var now = Environment.TickCount64; + if (now - Interlocked.Read(ref _lastCheck) >= CheckIntervalMs) + { + Interlocked.Exchange(ref _lastCheck, now); + ReloadIfChanged(); + } + return Volatile.Read(ref _current); + } + } + + private void ReloadIfChanged() + { + try + { + var stamp = Stamp(_certPath, _keyPath); + if (stamp == _loadedStamp) return; + + lock (_gate) + { + if (stamp == _loadedStamp) return; + // A rotation is two files. Loading between the two writes gives + // a mismatched pair, so a failure here is left for the next + // check rather than thrown at a handshake in progress. + var fresh = Load(_certPath, _keyPath); + var previous = _current; + Volatile.Write(ref _current, fresh); + _loadedStamp = stamp; + previous.Dispose(); + } + } + catch + { + // Keep serving the certificate that works. + } + } + + private static DateTime Stamp(string certPath, string keyPath) + { + var c = File.GetLastWriteTimeUtc(certPath); + var k = File.GetLastWriteTimeUtc(keyPath); + return c > k ? c : k; + } + + private static X509Certificate2 Load(string certPath, string keyPath) + { + // CreateFromPemFile hands back a certificate whose key is ephemeral, + // which SslStream will not use on every platform; the PKCS12 round trip + // gives it one it will. + using var pem = X509Certificate2.CreateFromPemFile(certPath, keyPath); + return X509CertificateLoader.LoadPkcs12(pem.Export(X509ContentType.Pkcs12), null); + } + + public void Dispose() => _current.Dispose(); +} diff --git a/frameworks/aspnet-minimal/meta.json b/frameworks/aspnet-minimal/meta.json index 658dbde4a..f666711fe 100644 --- a/frameworks/aspnet-minimal/meta.json +++ b/frameworks/aspnet-minimal/meta.json @@ -13,6 +13,7 @@ "description": "Minimal ASP.NET Core server using .NET 10 with Kestrel and minimal API routing.", "repo": "https://github.com/dotnet/aspnetcore", "enabled": true, + "tls_check": true, "tests": [ "baseline", "pipelined", @@ -36,4 +37,4 @@ "static-h3" ], "maintainers": [] -} \ No newline at end of file +} diff --git a/scripts/gen_leaderboard_data.py b/scripts/gen_leaderboard_data.py index 8717b9732..cc8e03da5 100644 --- a/scripts/gen_leaderboard_data.py +++ b/scripts/gen_leaderboard_data.py @@ -3443,6 +3443,19 @@ def main(): langcolors = load("langcolors.json") or {} current = load("current.json") or {} + # tls_check verdicts, written by validate.sh when an entry opts in. Keyed by directory name, which is what validate.sh is given; + # meta is keyed by display name, so the mapping goes through "dir". + tls_check = {} + tls_dir = ROOT / "site" / "data" / "tls" + if tls_dir.is_dir(): + for f in sorted(tls_dir.glob("*.json")): + try: + v = json.loads(f.read_text()) + except Exception: + continue + if v.get("check"): + tls_check[f.stem] = v["check"] + meta = {n: {"type": m.get("type", "emerging"), "mode": m.get("mode", "standard"), "language": m.get("language", ""), @@ -3454,7 +3467,9 @@ def main(): # Only ever set when the probes ran and were clean. Absent # means unverified, which the board renders as no shield # rather than as a failure. - } for n, m in frameworks.items()} + # "pass" only when the opt-in section ran and its own checks + # were clean. Absent for every entry that did not opt in. + "tlsCheck": tls_check.get(m.get("dir", ""))} for n, m in frameworks.items()} docs_tree, docs_content = build_docs() diff --git a/scripts/validate.sh b/scripts/validate.sh index 0801c53df..daf4c4226 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -8,12 +8,22 @@ PORT=8080 H2PORT=8443 H1TLS_PORT=8081 H2C_PORT=8082 +# The opt-in TLS section gets its own listener and its own certificate pair. +# It rotates certificates underneath a running server, and doing that to the +# shared /certs would move the ground under json-tls, static-tls and every h2 +# profile in the same run. +TLS_CHECK_PORT=9000 PASS=0 FAIL=0 # Set by the TLS probes; written out at the end so the board can show which # entries have actually been checked rather than trusting a self-declared flag. TLS_CHECKED=false TLS_CLEAN=true +# Set when the opt-in TLS section runs, so the stronger badge is only ever +# claimed by an entry that actually subscribed to it. +TLS_CHECK_RUN=false +TLS_CHECK_FAIL_BEFORE=0 +TLS_CHECK_OK=false SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$SCRIPT_DIR/.." @@ -27,6 +37,9 @@ PG_NETWORK="httparena-validate-net" cleanup() { # put back any static file a staleness probe replaced, before anything else restore_static_probe 2>/dev/null || true + # and any certificate the TLS section rotated, then drop its private dir + restore_tls_certs 2>/dev/null || true + [ -n "${TLS_CHECK_CERTS:-}" ] && rm -rf "$TLS_CHECK_CERTS" 2>/dev/null || true # Kill watchdog if still running [ -n "${WATCHDOG_PID:-}" ] && kill "$WATCHDOG_PID" 2>/dev/null || true docker rm -f "$CONTAINER_NAME" 2>/dev/null || true @@ -105,6 +118,9 @@ if [ ! -f "$META_FILE" ]; then exit 0 fi TESTS=$(python3 -c "import json; print(' '.join(json.load(open('$META_FILE'))['tests']))") +# The TLS section is a capability an entry opts into, not a profile it is +# measured on, so it is its own field rather than an entry in "tests". +TLS_CHECK_OPTIN=$(python3 -c "import json; print('yes' if json.load(open('$META_FILE')).get('tls_check') else 'no')") FRAMEWORK_TYPE=$(python3 -c "import json; print(json.load(open('$META_FILE')).get('type',''))") echo "[info] Subscribed tests: $TESTS" @@ -203,6 +219,19 @@ fi # h2c uses no TLS so no certs mount needed; just expose the port. $needs_h2c && docker_args+=(-p "$H2C_PORT:8082") +# The TLS section's own listener, with a certificate directory nothing else +# reads. Seeded from the mounted pair so the entry starts from the same +# material, then rotated freely without touching /certs. +TLS_CHECK_CERTS="" +if [ "$TLS_CHECK_OPTIN" = "yes" ] && [ -d "$CERTS_DIR" ]; then + TLS_CHECK_CERTS=$(mktemp -d) + cp -p "$CERTS_DIR/server.crt" "$TLS_CHECK_CERTS/server.crt" + cp -p "$CERTS_DIR/server.key" "$TLS_CHECK_CERTS/server.key" + chmod 644 "$TLS_CHECK_CERTS/server.crt" "$TLS_CHECK_CERTS/server.key" + docker_args+=(-v "$TLS_CHECK_CERTS:/certs-tls:ro") + docker_args+=(-p "$TLS_CHECK_PORT:9000") +fi + if has_test "gateway-64" || has_test "gateway-h3"; then docker_args+=(-v "$DATA_DIR/dataset-large.json:/data/dataset-large.json:ro") fi @@ -571,6 +600,223 @@ static_staleness_probe() { fi } +# ───── tls_check (opt-in, validation only) ───── +# +# Subscribed by putting "tls" in meta.json "tests". Nothing is measured: this +# is a hardening bar an entry opts into, and every check needs the entry to +# have done something deliberate. HTTP/1.1 on :8081 only -- h2 and h3 have +# their own listeners and are a separate question. +# +# Certificates are swapped underneath a running server here, so they are +# restored on the way out, including when a check fails midway. +# Rotation happens in $TLS_CHECK_CERTS, a directory mounted at /certs-tls +# for this entry alone. /certs is never written to, so json-tls, static-tls and +# the h2 profiles cannot see anything this section does. +TLS_CERT_BACKUP="" +TLS_KEY_BACKUP="" +restore_tls_certs() { + [ -n "$TLS_CHECK_CERTS" ] || return 0 + if [ -n "$TLS_CERT_BACKUP" ] && [ -f "$TLS_CERT_BACKUP" ]; then + mv -f "$TLS_CERT_BACKUP" "$TLS_CHECK_CERTS/server.crt" 2>/dev/null || true + fi + if [ -n "$TLS_KEY_BACKUP" ] && [ -f "$TLS_KEY_BACKUP" ]; then + mv -f "$TLS_KEY_BACKUP" "$TLS_CHECK_CERTS/server.key" 2>/dev/null || true + fi + TLS_CERT_BACKUP="" + TLS_KEY_BACKUP="" + return 0 +} + +_served_fp() { + timeout 8 openssl s_client -connect "localhost:$TLS_CHECK_PORT" -servername localhost /dev/null \ + | openssl x509 -noout -fingerprint -sha256 2>/dev/null | sed 's/.*=//' || true +} + +_new_pair() { + openssl req -x509 -newkey rsa:2048 -nodes -keyout "$1/new.key" -out "$1/new.crt" \ + -days 3650 -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,DNS:*.localhost,IP:127.0.0.1,IP:0.0.0.0,IP:::1" \ + -addext "keyUsage=critical,digitalSignature,keyEncipherment" \ + -addext "extendedKeyUsage=serverAuth" >/dev/null 2>&1 +} + +_swap_in_pair() { + local dir="$1" + TLS_CERT_BACKUP=$(mktemp); TLS_KEY_BACKUP=$(mktemp) + cp -p "$TLS_CHECK_CERTS/server.crt" "$TLS_CERT_BACKUP" + cp -p "$TLS_CHECK_CERTS/server.key" "$TLS_KEY_BACKUP" + # mode carried over: mktemp is 0600, and a non-root container that cannot + # read the new pair would look exactly like one that ignored the rotation + chmod --reference="$TLS_CHECK_CERTS/server.crt" "$dir/new.crt" + chmod --reference="$TLS_CHECK_CERTS/server.key" "$dir/new.key" + mv -f "$dir/new.crt" "$TLS_CHECK_CERTS/server.crt" + mv -f "$dir/new.key" "$TLS_CHECK_CERTS/server.key" +} + +# Replace the pair on disk and require the server to serve it without a +# restart. A certificate is renewed roughly every 60 days in production, and a +# server that needs a restart to pick one up is a weaker server. +tls_rotation_probe() { + local docs="$1" window="${HTTPARENA_TLS_ROTATE_WINDOW:-30}" + local before; before=$(_served_fp) + if [ -z "$before" ]; then + fail_with_link "[tls_check certificate rotation]: no certificate served on :$TLS_CHECK_PORT before the probe" "$docs" + return 0 + fi + local tmp; tmp=$(mktemp -d) + if ! _new_pair "$tmp"; then + echo " SKIP [tls_check certificate rotation] (could not generate a replacement pair)" + rm -rf "$tmp"; return 0 + fi + _swap_in_pair "$tmp"; rm -rf "$tmp" + + local waited=0 rotated=false + while [ "$waited" -le "$window" ]; do + [ "$(_served_fp)" != "$before" ] && { rotated=true; break; } + sleep 1; waited=$((waited + 1)) + done + + # Rotating by dying is not rotating. Asked while the new pair is still in + # place, so the answer is about the new certificate. + local alive="no" + curl -sk --max-time 8 -o /dev/null "https://localhost:$TLS_CHECK_PORT/json/1" 2>/dev/null && alive="yes" + + restore_tls_certs + local back=0 + while [ "$back" -le "$window" ]; do + [ "$(_served_fp)" = "$before" ] && break + sleep 1; back=$((back + 1)) + done + + if [ "$rotated" != "true" ]; then + fail_with_link "[tls_check certificate rotation]: the pair at /certs was replaced and the server still served the old certificate after ${window}s" "$docs" + elif [ "$alive" != "yes" ]; then + fail_with_link "[tls_check certificate rotation]: the new certificate was served, but the server stopped answering on it" "$docs" + else + echo " PASS [tls_check certificate rotation] (new certificate served in ${waited}s, original back in ${back}s, still answering)" + PASS=$((PASS + 1)) + fi +} + +# Rotation is only useful if it does not drop what is in flight. +tls_rotation_graceful_probe() { + local docs="$1" + local tmp; tmp=$(mktemp -d) + if ! _new_pair "$tmp"; then + echo " SKIP [tls_check rotation keeps serving] (could not generate a replacement pair)" + rm -rf "$tmp"; return 0 + fi + local out; out=$(mktemp) + ( for _ in $(seq 1 30); do + curl -sk --max-time 5 -o /dev/null -w '%{http_code}\n' "https://localhost:$TLS_CHECK_PORT/json/1" 2>/dev/null || echo "000" + sleep 0.2 + done ) > "$out" & + local pid=$! + sleep 2 + _swap_in_pair "$tmp"; rm -rf "$tmp" + wait "$pid" 2>/dev/null || true + restore_tls_certs + + local total ok + total=$(wc -l < "$out"); ok=$(grep -c '^200$' "$out" || true) + rm -f "$out" + if [ "${total:-0}" -gt 0 ] && [ "${ok:-0}" -eq "${total:-0}" ]; then + echo " PASS [tls_check rotation keeps serving] ($ok/$total requests answered across the swap)" + PASS=$((PASS + 1)) + else + fail_with_link "[tls_check rotation keeps serving]: ${ok:-0} of ${total:-0} requests succeeded while the certificate was replaced" "$docs" + fi +} + +# The certificate must be chosen per handshake, not bound once at startup. +tls_sni_probe() { + local docs="$1" with without + with=$(timeout 8 openssl s_client -connect "localhost:$TLS_CHECK_PORT" -servername localhost /dev/null \ + | openssl x509 -noout -subject 2>/dev/null || true) + without=$(timeout 8 openssl s_client -connect "localhost:$TLS_CHECK_PORT" -noservername /dev/null \ + | openssl x509 -noout -subject 2>/dev/null || true) + if [ -n "$with" ] && [ -n "$without" ]; then + echo " PASS [tls_check SNI] (answers both with a server name and without one)" + PASS=$((PASS + 1)) + else + fail_with_link "[tls_check SNI]: no handshake completed $([ -z "$with" ] && echo "with SNI=localhost" || echo "without SNI"). A client that omits SNI must still get a usable answer" "$docs" + fi +} + +# Resumption decides what a reconnecting client pays. Noted rather than failed: +# TLS 1.3 tickets are off by default in several stacks. +tls_resumption_probe() { + local docs="$1" sess out + sess=$(mktemp) + timeout 8 openssl s_client -connect "localhost:$TLS_CHECK_PORT" -servername localhost \ + -sess_out "$sess" /dev/null 2>&1 || true + if [ ! -s "$sess" ]; then + echo " NOTE [tls_check session resumption]: no session ticket issued, so every connection pays a full handshake" + rm -f "$sess"; return 0 + fi + out=$(timeout 8 openssl s_client -connect "localhost:$TLS_CHECK_PORT" -servername localhost \ + -sess_in "$sess" /dev/null || true) + rm -f "$sess" + if printf '%s' "$out" | grep -q "Reused"; then + echo " PASS [tls_check session resumption] (ticket issued and accepted)" + PASS=$((PASS + 1)) + else + echo " NOTE [tls_check session resumption]: a ticket was issued but not accepted on reconnect" + fi +} + +# Without close_notify a truncated response is indistinguishable from a +# complete one. +tls_close_notify_probe() { + local docs="$1" out + # -quiet is deliberately not used: it suppresses the very lines this reads. + # A clean shutdown ends with DONE; a server that just drops the socket makes + # openssl report "unexpected eof while reading". + out=$(printf 'GET /json/1 HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' \ + | timeout 8 openssl s_client -connect "localhost:$TLS_CHECK_PORT" -servername localhost 2>&1 >/dev/null || true) + if printf '%s' "$out" | grep -qi "unexpected eof"; then + fail_with_link "[tls_check close_notify]: the server dropped the connection without a close_notify alert, so a truncated response is indistinguishable from a complete one" "$docs" + elif printf '%s' "$out" | grep -qE "DONE|close notify"; then + echo " PASS [tls_check close_notify] (closed at the TLS layer, not just the socket)" + PASS=$((PASS + 1)) + else + echo " NOTE [tls_check close_notify]: could not tell from the client whether the close was clean" + fi +} + +# The vulnerability suite, from the tool that already knows them all. Only run +# for this opt-in profile, where 30s is affordable. +tls_vuln_scan() { + local docs="$1" + if [ "${HTTPARENA_SKIP_TLS_SCAN:-0}" = "1" ]; then + echo " SKIP [tls_check vulnerability suite] (HTTPARENA_SKIP_TLS_SCAN=1)" + return 0 + fi + local od json; od=$(mktemp -d); json="$od/v.json" + timeout 600 docker run --rm --network host -v "$od:/out" "${HTTPARENA_TESTSSL_IMAGE:-drwetter/testssl.sh}" \ + -U --quiet --color 0 --jsonfile /out/v.json "127.0.0.1:$TLS_CHECK_PORT" >/dev/null 2>&1 || true + if [ ! -s "$json" ]; then + echo " SKIP [tls_check vulnerability suite] (testssl.sh unavailable)" + rm -rf "$od"; return 0 + fi + local bad + bad=$(python3 - "$json" <<'PYEOF' +import json, sys +rows = json.load(open(sys.argv[1])) +rows = rows if isinstance(rows, list) else rows.get("scanResult", []) +hits = [r.get("id") for r in rows if str(r.get("severity", "")).upper() in ("HIGH", "CRITICAL")] +print(",".join(sorted(set(h for h in hits if h)))) +PYEOF +) + rm -rf "$od" + if [ -n "$bad" ]; then + fail_with_link "[tls_check vulnerability suite]: testssl.sh reports HIGH or CRITICAL findings: ${bad//,/, }" "$docs" + else + echo " PASS [tls_check vulnerability suite] (no HIGH or CRITICAL finding)" + PASS=$((PASS + 1)) + fi +} + # ───── TLS quality ───── # # The posture probe below asks what this connection negotiated. This asks what @@ -1594,6 +1840,44 @@ if has_test "static"; then fi +# ───── TLS hardening (opt-in; validation only, nothing is measured) ───── + +if [ "$TLS_CHECK_OPTIN" = "yes" ]; then + TLS_CHECK_DOCS="$DOCS_BASE/h1/isolated/tls/validation" + echo "[test] tls_check — TLS hardening (opt-in)" + # The badge answers for this section, so it counts this section's failures. + # An unrelated check failing elsewhere says nothing about whether the entry + # rotates a certificate. + TLS_CHECK_FAIL_BEFORE=$FAIL + if [ -z "$TLS_CHECK_CERTS" ]; then + echo " SKIP [tls_check] (no certificate directory to rotate)" + elif ! timeout 30 bash -c "until (echo > /dev/tcp/localhost/$TLS_CHECK_PORT) 2>/dev/null; do sleep 1; done"; then + fail_with_link "[tls_check listener]: nothing accepted a connection on :$TLS_CHECK_PORT. An entry subscribing to \"tls\" has to open a TLS listener there, separate from :8081, so the section can rotate its certificate without disturbing the other profiles" "$TLS_CHECK_DOCS" + TLS_CHECK_RUN=true + TLS_CHECK_OK=false + else + + # The shared checks first: no point asking whether an entry can rotate a + # certificate before knowing it serves the right one to begin with. + tls_posture_probe "tls_check" "$TLS_CHECK_PORT" "$TLS_CHECK_DOCS" "http/1.1" + tls_quality_probe "tls_check" "$TLS_CHECK_PORT" "$TLS_CHECK_DOCS" + tls_sni_probe "$TLS_CHECK_DOCS" + tls_resumption_probe "$TLS_CHECK_DOCS" + tls_close_notify_probe "$TLS_CHECK_DOCS" + tls_rotation_probe "$TLS_CHECK_DOCS" + tls_rotation_graceful_probe "$TLS_CHECK_DOCS" + tls_vuln_scan "$TLS_CHECK_DOCS" + TLS_CHECK_RUN=true + # Settled here rather than at the end of the run: a check that fails after + # this point is not part of the section and must not decide its badge. + if [ "$FAIL" -eq "$TLS_CHECK_FAIL_BEFORE" ]; then + TLS_CHECK_OK=true + else + TLS_CHECK_OK=false + fi + fi +fi + # ───── Static Files TLS (GET /static/* over HTTP/1.1 + TLS on :8081) ───── if has_test "static-tls"; then @@ -2527,14 +2811,21 @@ fi # be earned by the probes, not declared by the entry. if [ "$TLS_CHECKED" = "true" ]; then mkdir -p "$ROOT_DIR/site/data/tls" - if [ "$TLS_CLEAN" = "true" ] && [ "$FAIL" -eq 0 ]; then + if [ "$TLS_CLEAN" = "true" ]; then tls_state="pass" else tls_state="fail" fi - printf '{\n "framework": "%s",\n "tls": "%s"\n}\n' \ - "$FRAMEWORK" "$tls_state" > "$ROOT_DIR/site/data/tls/$FRAMEWORK.json" - echo "[info] TLS verdict: $tls_state (site/data/tls/$FRAMEWORK.json)" + if [ "$TLS_CHECK_RUN" != "true" ]; then + tls_check="none" + elif [ "$TLS_CHECK_OK" = "true" ]; then + tls_check="pass" + else + tls_check="fail" + fi + printf '{\n "framework": "%s",\n "tls": "%s",\n "check": "%s"\n}\n' \ + "$FRAMEWORK" "$tls_state" "$tls_check" > "$ROOT_DIR/site/data/tls/$FRAMEWORK.json" + echo "[info] TLS verdict: $tls_state, opt-in tls_check: $tls_check" fi echo "" diff --git a/site/content/docs/test-profiles/h1/isolated/tls/validation.md b/site/content/docs/test-profiles/h1/isolated/tls/validation.md new file mode 100644 index 000000000..aae5aff38 --- /dev/null +++ b/site/content/docs/test-profiles/h1/isolated/tls/validation.md @@ -0,0 +1,83 @@ +--- +title: Validation +seo_title: "TLS Hardening — Validation Checks" +description: "The opt-in TLS section: certificate rotation, SNI, resumption, close_notify and the vulnerability suite, on the HTTP/1.1 TLS listener." +--- + +An **opt-in** section. Nothing here is measured and nothing here affects a score — it is a hardening bar an entry chooses to be held to, and passing it earns the TLS badge on the HTTP/1.1 composite. + +Subscribe with the `tls_check` field in `meta.json` — a capability the entry opts into, not a profile it is measured on, so it is its own field rather than an entry in `tests`: + +```json +"enabled": true, +"tls_check": true +``` + +## A listener of its own, on :9000 + +The section needs **a second TLS listener on port 9000**, reading its certificate and key from **`/certs-tls`**. That directory is mounted for this entry alone and seeded from the usual pair. + +This is not incidental. The section replaces certificates underneath a running server, and doing that to the shared `/certs` would move the ground under `json-tls`, `static-tls` and every h2 profile in the same validation run. A dedicated port and a private directory keep it from touching anything else — `/certs` is never written to. + +The listener is HTTP/1.1 over TLS. The h2 and h3 listeners are separate and not covered here, which is why the badge only appears on the H1 composite. + +An entry that opts in without opening :9000 fails the section with a clear message rather than being skipped. + +## Why it is opt-in + +Most of these need the entry to have done something deliberate. Binding a certificate once at startup — which is what almost every entry does — fails the first check on this page. Opting in is a statement that the entry has gone further. + +## Checks + +### Certificate rotation + +The certificate and key at `/certs-tls` are replaced with a freshly generated RSA-2048 pair while the server is running. The server must serve the new certificate **without a restart**, within 30 seconds, and must still answer requests on it. + +A certificate is renewed roughly every 60 days in production. A server that needs a restart to pick one up is a weaker server, and nothing else in the suite notices the difference. + +The usual way to pass is a per-handshake certificate callback rather than a value bound at startup — in Kestrel, `ServerCertificateSelector`; in Go, `tls.Config.GetCertificate`; in Rust with rustls, a `ResolvesServerCert`. + +### Rotation keeps serving + +Thirty requests are issued across the swap. All of them must succeed. Rotating by dropping traffic is not rotating. + +### SNI + +The server must complete a handshake both **with** a server name and **without** one. A client that omits SNI has to get a usable answer rather than a dropped connection. + +### Session resumption + +Reported, not required. If the server issues a session ticket, a second connection presenting it should resume. An entry that issues no ticket makes every connection pay a full handshake, which is worth knowing but is not a failure. + +### close_notify + +The server must close at the TLS layer rather than dropping the socket. Without the alert, a truncated response is indistinguishable from a complete one. + +### Vulnerability suite + +[testssl.sh](https://github.com/testssl/testssl.sh) `-U`, which covers Heartbleed, CCS, Ticketbleed, ROBOT, secure renegotiation, CRIME, BREACH, POODLE, `TLS_FALLBACK_SCSV`, SWEET32, FREAK, DROWN, LOGJAM, BEAST, LUCKY13, Winshock and RC4 — about 30 seconds. Any **HIGH** or **CRITICAL** finding fails the section. + +Set `HTTPARENA_SKIP_TLS_SCAN=1` to skip it; it also skips itself rather than failing an entry when the scanner image is unavailable. + +### The shared TLS checks + +The section also runs everything the TLS-carrying profiles already run — the certificate must be the one the harness mounted, the connection must negotiate TLS 1.3 with an AEAD cipher, ALPN must not name a protocol the client did not offer, and no obsolete protocol or weak cipher may be accepted. See [json-tls validation](../json-tls/validation/#tls-checks). + +## The badge + +Two badges, and they mean different things: + +| badge | meaning | +|---|---| +| green shield | the TLS basics, checked on any entry with a TLS profile | +| **gold shield** | opted into this section **and passed it** | + +Both are earned by the probes, never declared in `meta.json`. No badge means *not verified* — most entries have no TLS profile at all — and never *failed*. + +## Running locally + +```bash +./scripts/validate.sh +``` + +The rotation checks replace files in the private `/certs-tls` directory and put them back afterwards, including when a check fails midway. The shared `certs/` directory is never written to. diff --git a/site/data/tls/aspnet-minimal.json b/site/data/tls/aspnet-minimal.json new file mode 100644 index 000000000..ae294dacd --- /dev/null +++ b/site/data/tls/aspnet-minimal.json @@ -0,0 +1,5 @@ +{ + "framework": "aspnet-minimal", + "tls": "pass", + "check": "pass" +} diff --git a/site/leaderboard/index.html b/site/leaderboard/index.html index abd12e4dd..68de0fd3d 100644 --- a/site/leaderboard/index.html +++ b/site/leaderboard/index.html @@ -668,6 +668,28 @@

return modeOf(fw)==='tuned' ? 'tuned' : ''; } + // A shield beside the name for an entry whose TLS was probed and came back + // clean: the mounted certificate, TLS 1.3, an AEAD cipher, and no obsolete + // protocol or weak cipher accepted. Absent means unverified, not failed -- + // most entries have no TLS profile at all. + var TLS_TIP = 'TLS verified - serves the certificate the harness mounts, negotiates TLS 1.3 with an AEAD cipher, and refuses SSLv3, TLS 1.0/1.1 and NULL, anonymous, export, RC4, DES and 3DES ciphers.'; + // The opt-in TLS section: certificate rotation, SNI, resumption, close_notify + // and the vulnerability suite, all on the HTTP/1.1 listener. Only ever shown + // on the H1 composite -- h2 and h3 have their own listeners and are not + // covered by it, so a badge earned on :8081 must not follow the entry into + // views it says nothing about. + var TLS_CHECK_TIP = 'TLS hardened - opted into the TLS section and passed it: rotates its certificate without a restart and without dropping traffic, answers with and without SNI, closes at the TLS layer, and reports no HIGH or CRITICAL finding from the vulnerability suite.'; + // The composite views are keyed by scope; 'h1' is the HTTP/1.1 one. + function h1View(){ return state.view === 'composite' && state.scope === 'h1'; } + function tlsCheckBadge(fw){ + var m = D.meta[fw]; + if(!m || m.tlsCheck !== 'pass' || !h1View()) return ''; + return '' + + ''; + } function repoOf(fw){ return D.meta[fw] && D.meta[fw].repo; } function langOf(fw){ return FWLANG[fw] || ''; } function engineOf(fw){ return (D.meta[fw] && D.meta[fw].engine) || ''; } @@ -745,7 +767,7 @@

var impl='https://github.com/MDA2AV/HttpArena/tree/main/frameworks/'+encodeURIComponent(dir); var h='