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='