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