Probe #24
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Probe | |
| on: | |
| workflow_dispatch: | |
| pull_request: | |
| branches: [ main ] | |
| jobs: | |
| probe: | |
| name: Compliance Probe | |
| runs-on: ubuntu-latest | |
| permissions: | |
| pull-requests: write | |
| contents: write | |
| actions: write | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Discover servers | |
| id: discover | |
| run: | | |
| SERVERS='[]' | |
| for f in src/Servers/*/probe.json; do | |
| dir=$(basename "$(dirname "$f")") | |
| name=$(jq -r .name "$f") | |
| lang=$(jq -r '.language // ""' "$f") | |
| SERVERS=$(echo "$SERVERS" | jq -c --arg d "$dir" --arg n "$name" --arg l "$lang" '. + [{"dir": $d, "name": $n, "language": $l}]') | |
| done | |
| echo "servers=$SERVERS" >> "$GITHUB_OUTPUT" | |
| echo "Discovered: $(echo "$SERVERS" | jq -r '.[].name' | tr '\n' ', ')" | |
| - name: Detect changes | |
| id: changes | |
| run: | | |
| SERVERS='${{ steps.discover.outputs.servers }}' | |
| set_all() { | |
| echo "servers=$SERVERS" >> "$GITHUB_OUTPUT" | |
| } | |
| # workflow_dispatch always runs everything | |
| if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then | |
| set_all | |
| exit 0 | |
| fi | |
| CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) | |
| # Global triggers → run all | |
| if echo "$CHANGED" | grep -qE '^(src/Http11Probe/|src/Http11Probe\.Cli/|Directory\.Build\.props|\.dockerignore|\.github/workflows/probe\.yml)'; then | |
| set_all | |
| exit 0 | |
| fi | |
| AFFECTED='[]' | |
| for row in $(echo "$SERVERS" | jq -r '.[] | @base64'); do | |
| dir=$(echo "$row" | base64 -d | jq -r '.dir') | |
| name=$(echo "$row" | base64 -d | jq -r '.name') | |
| lang=$(echo "$row" | base64 -d | jq -r '.language') | |
| if echo "$CHANGED" | grep -q "^src/Servers/${dir}/"; then | |
| AFFECTED=$(echo "$AFFECTED" | jq -c --arg d "$dir" --arg n "$name" --arg l "$lang" '. + [{"dir": $d, "name": $n, "language": $l}]') | |
| fi | |
| done | |
| echo "servers=$AFFECTED" >> "$GITHUB_OUTPUT" | |
| - name: Setup .NET | |
| if: steps.changes.outputs.servers != '[]' | |
| uses: actions/setup-dotnet@v4 | |
| with: | |
| dotnet-version: '10.0' | |
| - name: Build probe CLI | |
| if: steps.changes.outputs.servers != '[]' | |
| run: dotnet build Http11Probe.slnx -c Release | |
| # ── Build / Run / Probe / Kill — one server at a time ────────── | |
| - name: Probe servers | |
| if: steps.changes.outputs.servers != '[]' | |
| run: | | |
| SERVERS='${{ steps.changes.outputs.servers }}' | |
| PROBE_PORT=8080 | |
| for row in $(echo "$SERVERS" | jq -r '.[] | @base64'); do | |
| dir=$(echo "$row" | base64 -d | jq -r '.dir') | |
| name=$(echo "$row" | base64 -d | jq -r '.name') | |
| tag=$(echo "probe-$dir" | tr '[:upper:]' '[:lower:]') | |
| echo "::group::$name" | |
| # Build | |
| docker build -t "$tag" -f "src/Servers/$dir/Dockerfile" . | |
| # Run | |
| docker run -d --name probe-target --network host "$tag" | |
| # Wait | |
| for i in $(seq 1 30); do | |
| curl -sf "http://localhost:${PROBE_PORT}/" > /dev/null 2>&1 && break | |
| sleep 1 | |
| done | |
| # Probe | |
| dotnet run --no-build -c Release --project src/Http11Probe.Cli -- \ | |
| --host localhost --port "$PROBE_PORT" --output "probe-${dir}.json" || true | |
| # Kill | |
| docker stop probe-target && docker rm probe-target | |
| echo "::endgroup::" | |
| done | |
| - name: Cleanup | |
| if: always() | |
| run: docker rm -f probe-target 2>/dev/null || true | |
| # ── Process results ──────────────────────────────────────────── | |
| - name: Process results | |
| if: steps.changes.outputs.servers != '[]' | |
| env: | |
| PROBE_SERVERS: ${{ steps.changes.outputs.servers }} | |
| run: | | |
| python3 << 'PYEOF' | |
| import json, sys, os, subprocess, pathlib | |
| # ── Strict expectations ────────────────────────────────────── | |
| STRICT = { | |
| 'COMP-BASELINE': { | |
| 'accept': list(range(200, 300)), | |
| 'close_ok': False, 'timeout_ok': False, | |
| 'expected': '2xx', | |
| 'reason': 'Baseline connectivity — valid GET must receive 2xx' | |
| }, | |
| 'RFC9112-2.2-BARE-LF-REQUEST-LINE': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Bare LF — recipient MAY accept but rejection is stricter (RFC 9112 §2.2)' | |
| }, | |
| 'RFC9112-2.2-BARE-LF-HEADER': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Bare LF — recipient MAY accept but rejection is stricter (RFC 9112 §2.2)' | |
| }, | |
| 'RFC9112-5.1-OBS-FOLD': { | |
| 'accept': [400], 'close_ok': False, 'timeout_ok': False, | |
| 'expected': '400', | |
| 'reason': 'MUST reject by sending 400 or replace with SP (RFC 9112 §5.1)' | |
| }, | |
| 'RFC9110-5.6.2-SP-BEFORE-COLON': { | |
| 'accept': [400], 'close_ok': False, 'timeout_ok': False, | |
| 'expected': '400', | |
| 'reason': 'MUST reject with 400 (RFC 9112 §5)' | |
| }, | |
| 'RFC9112-3-MULTI-SP-REQUEST-LINE': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'SHOULD respond with 400 (RFC 9112 §3)' | |
| }, | |
| 'RFC9112-7.1-MISSING-HOST': { | |
| 'accept': [400], 'close_ok': False, 'timeout_ok': False, | |
| 'expected': '400', | |
| 'reason': 'MUST respond with 400 (RFC 9112 §3.2)' | |
| }, | |
| 'RFC9112-2.3-INVALID-VERSION': { | |
| 'accept': [400, 505], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400/505 or close', | |
| 'reason': 'No MUST — 505 is available but not mandated (RFC 9112 §2.3)' | |
| }, | |
| 'RFC9112-5-EMPTY-HEADER-NAME': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Empty header name (leading colon) is invalid (RFC 9112 §5)' | |
| }, | |
| 'RFC9112-3-CR-ONLY-LINE-ENDING': { | |
| 'accept': [400], 'close_ok': False, 'timeout_ok': False, | |
| 'expected': '400', | |
| 'reason': 'MUST consider invalid or replace with SP (RFC 9112 §2.2)' | |
| }, | |
| 'SMUG-CL-TE-BOTH': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'CL + TE together "ought to" be handled as error (RFC 9112 §6.3)' | |
| }, | |
| 'SMUG-DUPLICATE-CL': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'MUST treat as unrecoverable error (RFC 9112 §6.3)' | |
| }, | |
| 'SMUG-CL-LEADING-ZEROS': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Leading zeros in CL can cause length misinterpretation' | |
| }, | |
| 'SMUG-TE-XCHUNKED': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Unknown TE "xchunked" with CL present is ambiguous — must reject' | |
| }, | |
| 'SMUG-TE-TRAILING-SPACE': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'TE "chunked " (trailing space) is obfuscation — must reject' | |
| }, | |
| 'SMUG-TE-SP-BEFORE-COLON': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Space before colon is invalid header syntax (RFC 9110 §5.6.2)' | |
| }, | |
| 'SMUG-CL-NEGATIVE': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Negative Content-Length is syntactically invalid' | |
| }, | |
| 'SMUG-CLTE-PIPELINE': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'CL.TE smuggling vector — ambiguous framing must be rejected' | |
| }, | |
| 'MAL-BINARY-GARBAGE': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': True, | |
| 'expected': '400/close/timeout', | |
| 'reason': 'Binary garbage is not valid HTTP — must reject' | |
| }, | |
| 'MAL-LONG-URL': { | |
| 'accept': [400, 414, 431], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400/414/431 or close', | |
| 'reason': '100 KB URL exceeds any reasonable limit' | |
| }, | |
| 'MAL-LONG-HEADER-VALUE': { | |
| 'accept': [400, 431], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400/431 or close', | |
| 'reason': '100 KB header value exceeds limits' | |
| }, | |
| 'MAL-MANY-HEADERS': { | |
| 'accept': [400, 431], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400/431 or close', | |
| 'reason': '10,000 headers exceeds any reasonable limit' | |
| }, | |
| 'MAL-NUL-IN-URL': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'NUL byte in URL is not valid in HTTP request-target' | |
| }, | |
| 'MAL-CONTROL-CHARS-HEADER': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Control characters in header values are invalid (RFC 9110 §5.5)' | |
| }, | |
| 'MAL-INCOMPLETE-REQUEST': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': True, | |
| 'expected': '400/close/timeout', | |
| 'reason': 'Incomplete request — server must not crash, may timeout' | |
| }, | |
| 'MAL-EMPTY-REQUEST': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': True, | |
| 'expected': '400/close/timeout', | |
| 'reason': 'Empty request — server must not crash, may timeout' | |
| }, | |
| 'RFC9112-3-MISSING-TARGET': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'SHOULD respond with 400 (RFC 9112 §3)' | |
| }, | |
| 'RFC9112-3.2-FRAGMENT-IN-TARGET': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'SHOULD respond with 400 (RFC 9112 §3)' | |
| }, | |
| 'RFC9112-2.3-HTTP09-REQUEST': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': True, | |
| 'expected': '400/close/timeout', | |
| 'reason': 'Invalid request-line — SHOULD respond with 400 (RFC 9112 §3)' | |
| }, | |
| 'RFC9112-5-INVALID-HEADER-NAME': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Invalid characters in header name must be rejected (RFC 9112 §5)' | |
| }, | |
| 'RFC9112-5-HEADER-NO-COLON': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Header line without colon is malformed (RFC 9112 §5)' | |
| }, | |
| 'RFC9110-5.4-DUPLICATE-HOST': { | |
| 'accept': [400], 'close_ok': False, 'timeout_ok': False, | |
| 'expected': '400', | |
| 'reason': 'MUST respond with 400 (RFC 9112 §3.2)' | |
| }, | |
| 'RFC9112-6.1-CL-NON-NUMERIC': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'MUST treat as unrecoverable error (RFC 9112 §6.3)' | |
| }, | |
| 'RFC9112-6.1-CL-PLUS-SIGN': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'MUST treat as unrecoverable error (RFC 9112 §6.3)' | |
| }, | |
| 'SMUG-TECL-PIPELINE': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'TE.CL smuggling vector — ambiguous framing must be rejected' | |
| }, | |
| 'SMUG-CL-TRAILING-SPACE': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'warn_on_2xx': True, | |
| 'expected': '400 or 2xx', | |
| 'reason': 'Trailing space in CL — OWS trimming is valid per RFC 9110 §5.5' | |
| }, | |
| 'SMUG-HEADER-INJECTION': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'warn_on_2xx': True, | |
| 'expected': '400 or 2xx', | |
| 'reason': 'Payload is two valid headers on the wire — 2xx is RFC-compliant' | |
| }, | |
| 'SMUG-TE-DOUBLE-CHUNKED': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'warn_on_2xx': True, | |
| 'expected': '400 or 2xx', | |
| 'reason': 'Duplicate chunked TE with CL — 4xx is strict, 2xx is tolerable' | |
| }, | |
| 'SMUG-CL-EXTRA-LEADING-SP': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'warn_on_2xx': True, | |
| 'expected': '400 or 2xx', | |
| 'reason': 'Extra OWS after colon is valid per RFC 9110 §5.5' | |
| }, | |
| 'SMUG-TE-CASE-MISMATCH': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'warn_on_2xx': True, | |
| 'expected': '400 or 2xx', | |
| 'reason': 'Case-insensitive TE matching is valid per RFC — 2xx is compliant' | |
| }, | |
| 'MAL-LONG-HEADER-NAME': { | |
| 'accept': [400, 431], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400/431 or close', | |
| 'reason': '100 KB header name exceeds any reasonable limit' | |
| }, | |
| 'MAL-LONG-METHOD': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': '100 KB method name exceeds any reasonable limit' | |
| }, | |
| 'MAL-NON-ASCII-HEADER-NAME': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Non-ASCII bytes in header name are invalid' | |
| }, | |
| 'MAL-NON-ASCII-URL': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Non-ASCII bytes in URL are invalid' | |
| }, | |
| 'MAL-CL-OVERFLOW': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Integer overflow in Content-Length must be rejected' | |
| }, | |
| 'MAL-WHITESPACE-ONLY-LINE': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': True, | |
| 'expected': '400/close/timeout', | |
| 'reason': 'Whitespace-only request line is not valid HTTP' | |
| }, | |
| # ── New Compliance tests ─────────────────────────────────── | |
| 'COMP-WHITESPACE-BEFORE-HEADERS': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Whitespace before first header is invalid (RFC 9112 §2.2)' | |
| }, | |
| 'COMP-DUPLICATE-HOST-SAME': { | |
| 'accept': [400], 'close_ok': False, 'timeout_ok': False, | |
| 'expected': '400', | |
| 'reason': 'MUST respond with 400 for more than one Host header (RFC 9112 §3.2)' | |
| }, | |
| 'COMP-HOST-WITH-USERINFO': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Host header with userinfo is invalid (RFC 9112 §3.2)' | |
| }, | |
| 'COMP-HOST-WITH-PATH': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Host header with path component is invalid (RFC 9112 §3.2)' | |
| }, | |
| 'COMP-ASTERISK-WITH-GET': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Asterisk-form only valid for OPTIONS (RFC 9112 §3.2.4)' | |
| }, | |
| 'COMP-OPTIONS-STAR': { | |
| 'accept': list(range(200, 300)), | |
| 'close_ok': False, 'timeout_ok': False, | |
| 'expected': '2xx', | |
| 'reason': 'OPTIONS * is valid asterisk-form (RFC 9112 §3.2.4)' | |
| }, | |
| 'COMP-UNKNOWN-TE-501': { | |
| 'accept': [400, 501], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400/501 or close', | |
| 'reason': 'Unknown TE without CL should be rejected (RFC 9112 §6.1)' | |
| }, | |
| 'COMP-LEADING-CRLF': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'warn_on_2xx': True, | |
| 'expected': '400 or 2xx', | |
| 'reason': 'Leading CRLF — server MAY ignore per RFC 9112 §2.2' | |
| }, | |
| 'COMP-ABSOLUTE-FORM': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'warn_on_2xx': True, | |
| 'expected': '400 or 2xx', | |
| 'reason': 'Absolute-form is valid per RFC 9112 §3.2.2' | |
| }, | |
| 'COMP-METHOD-CASE': { | |
| 'accept': [400, 405, 501], 'close_ok': True, 'timeout_ok': False, | |
| 'warn_on_2xx': True, | |
| 'expected': '400/405/501 or 2xx', | |
| 'reason': 'Methods are case-sensitive per RFC 9110 §9.1 — 2xx means case-insensitive' | |
| }, | |
| 'COMP-CONNECT-EMPTY-PORT': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'CONNECT with empty port is invalid (RFC 9112 §3.2.3)' | |
| }, | |
| # ── New Smuggling tests (scored) ─────────────────────────── | |
| 'SMUG-CL-COMMA-DIFFERENT': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Comma-separated different CL values must be rejected (RFC 9110 §8.6)' | |
| }, | |
| 'SMUG-TE-NOT-FINAL-CHUNKED': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Chunked must be the final encoding (RFC 9112 §7)' | |
| }, | |
| 'SMUG-TE-HTTP10': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'TE in HTTP/1.0 is invalid (RFC 9112 §6.1)' | |
| }, | |
| 'SMUG-CHUNK-BARE-SEMICOLON': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Bare semicolon in chunk size without extension name (RFC 9112 §7.1.1)' | |
| }, | |
| 'SMUG-BARE-CR-HEADER-VALUE': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Bare CR must be rejected or replaced with SP (RFC 9112 §2.2)' | |
| }, | |
| 'SMUG-CL-OCTAL': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Octal prefix in CL is not valid DIGIT syntax (RFC 9110 §8.6)' | |
| }, | |
| 'SMUG-CHUNK-UNDERSCORE': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Underscores in chunk size are not valid HEXDIG (RFC 9112 §7.1)' | |
| }, | |
| 'SMUG-TE-EMPTY-VALUE': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Empty TE value with CL is ambiguous (RFC 9112 §6.1)' | |
| }, | |
| 'SMUG-TE-LEADING-COMMA': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Leading comma in TE is malformed (RFC 9112 §6.1)' | |
| }, | |
| 'SMUG-TE-DUPLICATE-HEADERS': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Two TE headers with CL is ambiguous framing (RFC 9112 §6.1)' | |
| }, | |
| 'SMUG-CHUNK-HEX-PREFIX': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': '0x prefix in chunk size is not valid HEXDIG (RFC 9112 §7.1)' | |
| }, | |
| 'SMUG-CL-HEX-PREFIX': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Hex prefix in CL is not valid DIGIT syntax (RFC 9110 §8.6)' | |
| }, | |
| 'SMUG-CL-INTERNAL-SPACE': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Internal space in CL value is not valid DIGIT (RFC 9110 §8.6)' | |
| }, | |
| 'SMUG-CHUNK-LEADING-SP': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Leading space in chunk size is not valid HEXDIG (RFC 9112 §7.1)' | |
| }, | |
| 'SMUG-CHUNK-MISSING-TRAILING-CRLF': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Chunk data without trailing CRLF is malformed (RFC 9112 §7.1)' | |
| }, | |
| # ── New Smuggling tests (unscored) ───────────────────────── | |
| 'SMUG-TRANSFER_ENCODING': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'warn_on_2xx': True, 'scored': False, | |
| 'expected': '400 or 2xx', | |
| 'reason': 'Underscore makes it a different header — 2xx is valid' | |
| }, | |
| 'SMUG-CL-COMMA-SAME': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'warn_on_2xx': True, 'scored': False, | |
| 'expected': '400 or 2xx', | |
| 'reason': 'RFC allows merging identical CL values (RFC 9110 §8.6)' | |
| }, | |
| 'SMUG-CHUNKED-WITH-PARAMS': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'warn_on_2xx': True, | |
| 'expected': '400 or 2xx', | |
| 'reason': 'Parameters on chunked encoding — some servers ignore' | |
| }, | |
| 'SMUG-EXPECT-100-CL': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'warn_on_2xx': True, 'scored': False, | |
| 'expected': '400 or 2xx', | |
| 'reason': 'Expect: 100-continue handling varies (RFC 9110 §10.1.1)' | |
| }, | |
| # ── New Malformed Input tests ────────────────────────────── | |
| 'MAL-NUL-IN-HEADER-VALUE': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'NUL byte in header value is invalid' | |
| }, | |
| 'MAL-CHUNK-SIZE-OVERFLOW': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Chunk size overflow must be rejected' | |
| }, | |
| 'MAL-H2-PREFACE': { | |
| 'accept': [400, 505], 'close_ok': True, 'timeout_ok': True, | |
| 'expected': '400/505/close/timeout', | |
| 'reason': 'HTTP/2 preface sent to HTTP/1.1 server is not valid HTTP/1.1' | |
| }, | |
| 'MAL-CL-EMPTY': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Empty Content-Length value is not valid DIGIT (RFC 9110 §8.6)' | |
| }, | |
| 'MAL-CL-TAB-BEFORE-VALUE': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'warn_on_2xx': True, | |
| 'expected': '400 or 2xx', | |
| 'reason': 'Tab as OWS is valid per RFC 9110 §5.5 — 2xx is compliant' | |
| }, | |
| # ── Missing Compliance tests ────────────────────────────────── | |
| 'COMP-UPGRADE-POST': { | |
| 'accept': [c for c in range(100, 600) if c != 101], | |
| 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '!101', | |
| 'reason': 'WebSocket upgrade via POST must not be accepted (RFC 6455 §4.1)' | |
| }, | |
| 'COMP-UPGRADE-MISSING-CONN': { | |
| 'accept': [c for c in range(100, 600) if c != 101], | |
| 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '!101', | |
| 'reason': 'Upgrade without Connection: Upgrade must not trigger 101 (RFC 9110 §7.8)' | |
| }, | |
| 'COMP-UPGRADE-UNKNOWN': { | |
| 'accept': [c for c in range(100, 600) if c != 101], | |
| 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '!101', | |
| 'reason': 'Upgrade to unknown protocol must not return 101 (RFC 9110 §7.8)' | |
| }, | |
| 'COMP-METHOD-CONNECT': { | |
| 'accept': [400, 405, 501], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400/405/501 or close', | |
| 'reason': 'CONNECT to an origin server must be rejected (RFC 9110 §9.3.6)' | |
| }, | |
| 'COMP-METHOD-CONNECT-NO-PORT': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'CONNECT without port in authority-form is invalid (RFC 9112 §3.2.3)' | |
| }, | |
| 'COMP-EXPECT-UNKNOWN': { | |
| 'accept': [417], 'close_ok': True, 'timeout_ok': False, | |
| 'warn_on_2xx': True, | |
| 'expected': '417 or 2xx', | |
| 'reason': 'Unknown Expect value — 417 is correct, 2xx means ignored (RFC 9110 §10.1.1)' | |
| }, | |
| 'COMP-UPGRADE-INVALID-VER': { | |
| 'accept': [426], 'close_ok': True, 'timeout_ok': False, | |
| 'warn_on_2xx': True, | |
| 'expected': '426 or 2xx', | |
| 'reason': 'Unsupported WebSocket version — 426 is correct, 101 is failure (RFC 6455 §4.4)' | |
| }, | |
| 'COMP-METHOD-TRACE': { | |
| 'accept': [405, 501], 'close_ok': True, 'timeout_ok': False, | |
| 'warn_on_2xx': True, 'scored': False, | |
| 'expected': '405/501 or 2xx', | |
| 'reason': 'TRACE should be disabled in production — 2xx means enabled (RFC 9110 §9.3.8)' | |
| }, | |
| # ── Missing Smuggling tests (scored) ───────────────────────── | |
| 'SMUG-CHUNK-EXT-LF': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Bare LF in chunk extension must be rejected (RFC 9112 §7.1.1)' | |
| }, | |
| 'SMUG-CHUNK-SPILL': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Oversized chunk data must be rejected (RFC 9112 §7.1)' | |
| }, | |
| 'SMUG-CHUNK-LF-TERM': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Bare LF as chunk data terminator must be rejected (RFC 9112 §7.1)' | |
| }, | |
| 'SMUG-CHUNK-EXT-CTRL': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'NUL byte in chunk extension must be rejected (RFC 9112 §7.1.1)' | |
| }, | |
| 'SMUG-CHUNK-LF-TRAILER': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Bare LF in chunked trailer termination must be rejected (RFC 9112 §7.1)' | |
| }, | |
| 'SMUG-TE-IDENTITY': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'TE: identity (deprecated) with CL must be rejected (RFC 9112 §7)' | |
| }, | |
| 'SMUG-CHUNK-NEGATIVE': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Negative chunk size must be rejected (RFC 9112 §7.1)' | |
| }, | |
| 'SMUG-CHUNK-EXT-CR': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Bare CR in chunk extension is invalid (RFC 9112 §7.1.1)' | |
| }, | |
| 'SMUG-TE-VTAB': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'VTAB in TE value is a control char obfuscation vector (RFC 9112 §6.1)' | |
| }, | |
| 'SMUG-TE-FORMFEED': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'Form feed in TE value is a control char obfuscation vector (RFC 9112 §6.1)' | |
| }, | |
| 'SMUG-TE-NULL': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '400 or close', | |
| 'reason': 'NUL in TE value — C-string truncation attack (RFC 9112 §6.1)' | |
| }, | |
| # ── Missing Smuggling tests (unscored) ─────────────────────── | |
| 'SMUG-TRAILER-CL': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'warn_on_2xx': True, 'scored': False, | |
| 'expected': '400 or 2xx', | |
| 'reason': 'CL in trailers is prohibited — 2xx means server ignored it (RFC 9110 §6.5.1)' | |
| }, | |
| 'SMUG-TRAILER-TE': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'warn_on_2xx': True, 'scored': False, | |
| 'expected': '400 or 2xx', | |
| 'reason': 'TE in trailers is prohibited — 2xx means server ignored it (RFC 9110 §6.5.1)' | |
| }, | |
| 'SMUG-TRAILER-HOST': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'warn_on_2xx': True, 'scored': False, | |
| 'expected': '400 or 2xx', | |
| 'reason': 'Host in trailers must not be used for routing (RFC 9110 §6.5.2)' | |
| }, | |
| 'SMUG-TRAILER-AUTH': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'warn_on_2xx': True, 'scored': False, | |
| 'expected': '400 or 2xx', | |
| 'reason': 'Authorization in trailers is prohibited — 2xx means ignored (RFC 9110 §6.5.1)' | |
| }, | |
| 'SMUG-HEAD-CL-BODY': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'warn_on_2xx': True, 'scored': False, | |
| 'expected': '400 or 2xx', | |
| 'reason': 'HEAD with body — server must not leave body on connection (RFC 9110 §9.3.2)' | |
| }, | |
| 'SMUG-OPTIONS-CL-BODY': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'warn_on_2xx': True, 'scored': False, | |
| 'expected': '400 or 2xx', | |
| 'reason': 'OPTIONS with body — server should consume or reject (RFC 9110 §9.3.7)' | |
| }, | |
| # ── Body / Content-Length / Chunked ────────────────────────── | |
| 'COMP-POST-CL-BODY': { | |
| 'accept': list(range(200, 300)), | |
| 'close_ok': False, 'timeout_ok': False, | |
| 'expected': '2xx', | |
| 'reason': 'Valid POST with CL and matching body must be accepted (RFC 9112 §6.2)' | |
| }, | |
| 'COMP-POST-CL-ZERO': { | |
| 'accept': list(range(200, 300)), | |
| 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '2xx or close', | |
| 'reason': 'POST with CL:0 and no body must be accepted (RFC 9112 §6.2)' | |
| }, | |
| 'COMP-POST-NO-CL-NO-TE': { | |
| 'accept': list(range(200, 300)), | |
| 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '2xx or close', | |
| 'reason': 'POST with no CL/TE implies zero-length body (RFC 9112 §6.3)' | |
| }, | |
| 'COMP-POST-CL-UNDERSEND': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': True, | |
| 'expected': '400/close/timeout', | |
| 'reason': 'Incomplete body — server blocks waiting then times out (RFC 9112 §6.2)' | |
| }, | |
| 'COMP-CHUNKED-BODY': { | |
| 'accept': list(range(200, 300)), | |
| 'close_ok': False, 'timeout_ok': False, | |
| 'expected': '2xx', | |
| 'reason': 'Valid single-chunk POST must be accepted (RFC 9112 §7.1)' | |
| }, | |
| 'COMP-CHUNKED-MULTI': { | |
| 'accept': list(range(200, 300)), | |
| 'close_ok': False, 'timeout_ok': False, | |
| 'expected': '2xx', | |
| 'reason': 'Valid multi-chunk POST must be accepted (RFC 9112 §7.1)' | |
| }, | |
| 'COMP-CHUNKED-EMPTY': { | |
| 'accept': list(range(200, 300)), | |
| 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '2xx or close', | |
| 'reason': 'Zero-length chunked body must be accepted (RFC 9112 §7.1)' | |
| }, | |
| 'COMP-CHUNKED-NO-FINAL': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': True, | |
| 'expected': '400/close/timeout', | |
| 'reason': 'Missing zero terminator — server blocks then times out (RFC 9112 §7.1)' | |
| }, | |
| 'COMP-GET-WITH-CL-BODY': { | |
| 'accept': [400], 'close_ok': True, 'timeout_ok': False, | |
| 'warn_on_2xx': True, 'scored': False, | |
| 'expected': '400 or 2xx', | |
| 'reason': 'GET with body is unusual but allowed (RFC 9110 §9.3.1)' | |
| }, | |
| 'COMP-CHUNKED-EXTENSION': { | |
| 'accept': list(range(200, 300)) + [400], | |
| 'close_ok': True, 'timeout_ok': False, | |
| 'expected': '2xx or 400', | |
| 'reason': 'Chunk extensions are valid per RFC 9112 §7.1.1 — 400 means unsupported' | |
| }, | |
| } | |
| # ── Evaluate one server's results ──────────────────────────── | |
| def evaluate(raw): | |
| results = [] | |
| for r in raw['results']: | |
| tid = r['id'] | |
| spec = STRICT.get(tid) | |
| if not spec: | |
| results.append({**r, 'verdict': r['verdict'], 'expected': '?', | |
| 'got': str(r.get('statusCode') or r.get('connectionState', '')), | |
| 'reason': 'No strict specification defined', 'scored': True}) | |
| continue | |
| status = r.get('statusCode') | |
| conn = r.get('connectionState', '') | |
| is_scored = spec.get('scored', True) | |
| passed = ( | |
| (status is not None and status in spec['accept']) or | |
| (status is None and spec['close_ok'] and conn == 'ClosedByServer') or | |
| (status is None and spec['timeout_ok'] and conn == 'TimedOut') | |
| ) | |
| got = str(status) if status is not None else conn | |
| # For unscored tests: 2xx is Warn (RFC-compliant), 4xx is Pass | |
| if passed: | |
| verdict = 'Pass' | |
| elif spec.get('warn_on_2xx') and status is not None and 200 <= status < 300: | |
| verdict = 'Warn' | |
| else: | |
| verdict = 'Fail' | |
| reason = spec['reason'] if verdict != 'Fail' else f"Expected {spec['expected']}, got {got} — {spec['reason']}" | |
| results.append({ | |
| 'id': tid, 'description': r['description'], | |
| 'category': r['category'], 'rfc': r.get('rfcReference'), | |
| 'verdict': verdict, 'statusCode': status, | |
| 'expected': spec['expected'], 'got': got, | |
| 'connectionState': conn, 'reason': reason, | |
| 'scored': is_scored, | |
| 'durationMs': r.get('durationMs', 0), | |
| }) | |
| scored_results = [r for r in results if r['scored'] and r['verdict'] != 'Warn'] | |
| total = len(scored_results) | |
| passed = sum(1 for r in scored_results if r['verdict'] == 'Pass') | |
| warned = sum(1 for r in results if r['verdict'] == 'Warn') | |
| return { | |
| 'summary': {'total': len(results), 'scored': total, 'passed': passed, 'failed': total - passed, 'warnings': warned}, | |
| 'results': results, | |
| } | |
| # ── Process each server ────────────────────────────────────── | |
| servers_config = json.loads(os.environ['PROBE_SERVERS']) | |
| SERVERS = [(s['name'], f"probe-{s['dir']}.json", s.get('language', '')) for s in servers_config] | |
| commit_id = subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode().strip() | |
| commit_msg = subprocess.check_output(['git', 'log', '-1', '--format=%s']).decode().strip() | |
| commit_time = subprocess.check_output(['git', 'log', '-1', '--format=%cI']).decode().strip() | |
| server_data = [] | |
| for name, path, language in SERVERS: | |
| p = pathlib.Path(path) | |
| if not p.exists(): | |
| print(f'::warning::{name}: result file {path} not found, skipping') | |
| continue | |
| with open(path) as f: | |
| raw = json.load(f) | |
| ev = evaluate(raw) | |
| ev['name'] = name | |
| ev['language'] = language | |
| server_data.append(ev) | |
| s = ev['summary'] | |
| print(f"{name}: {s['passed']}/{s['scored']} passed, {s['failed']} failed, {s['warnings']} warnings") | |
| if not server_data: | |
| print('::warning::No probe results found — nothing to report') | |
| sys.exit(0) | |
| # ── Write data.js ──────────────────────────────────────────── | |
| output = { | |
| 'commit': {'id': commit_id, 'message': commit_msg, 'timestamp': commit_time}, | |
| 'servers': server_data, | |
| } | |
| with open('probe-data.js', 'w') as f: | |
| f.write('window.PROBE_DATA = ' + json.dumps(output) + ';') | |
| # ── Write PR comment ───────────────────────────────────────── | |
| lines = ['<!-- http11probe-results -->', '## Http11Probe — Compliance Comparison', ''] | |
| # Summary table with bars | |
| max_scored = max(s['summary']['scored'] for s in server_data) | |
| BAR_WIDTH = 20 | |
| lines.append('| Server | Score | |') | |
| lines.append('|--------|------:|---|') | |
| for sv in sorted(server_data, key=lambda s: s['summary']['passed'], reverse=True): | |
| s = sv['summary'] | |
| pct = s['passed'] / s['scored'] if s['scored'] else 0 | |
| filled = round(pct * BAR_WIDTH) | |
| bar = '\u2588' * filled + '\u2591' * (BAR_WIDTH - filled) | |
| lines.append(f"| **{sv['name']}** | {s['passed']}/{s['scored']} | `{bar}` {pct:.0%} |") | |
| lines.append('') | |
| # Collect all test IDs in order from first server | |
| test_ids = [r['id'] for r in server_data[0]['results']] if server_data else [] | |
| # Build lookup: server_name -> {test_id -> result} | |
| lookup = {} | |
| for sv in server_data: | |
| lookup[sv['name']] = {r['id']: r for r in sv['results']} | |
| names = [sv['name'] for sv in server_data] | |
| import re | |
| def short(tid): | |
| return re.sub(r'^(RFC\d+-[\d.]+-|COMP-|SMUG-|MAL-)', '', tid) | |
| for cat_name, title in [('Compliance', 'Compliance'), ('Smuggling', 'Smuggling'), ('MalformedInput', 'Malformed Input')]: | |
| cat_tests = [tid for tid in test_ids if lookup[names[0]][tid]['category'] == cat_name] | |
| if not cat_tests: | |
| continue | |
| lines.append(f'### {title}') | |
| lines.append('') | |
| # Header row: Server | test1 | test2 | ... | |
| hdr = '| Server | ' + ' | '.join(f'`{short(tid)}`' for tid in cat_tests) + ' |' | |
| sep = '|---' + ''.join('|:---:' for _ in cat_tests) + '|' | |
| lines.append(hdr) | |
| lines.append(sep) | |
| # Expected row | |
| exp_cells = [] | |
| for tid in cat_tests: | |
| first = lookup[names[0]][tid] | |
| exp_cells.append(first['expected']) | |
| lines.append('| **Expected** | ' + ' | '.join(exp_cells) + ' |') | |
| # Server rows | |
| for n in names: | |
| cells = [] | |
| for tid in cat_tests: | |
| r = lookup[n].get(tid) | |
| if not r: | |
| cells.append('—') | |
| else: | |
| icon = '✅' if r['verdict'] == 'Pass' else ('⚠️' if r['verdict'] == 'Warn' else '❌') | |
| cells.append(f"{icon}`{r['got']}`") | |
| lines.append(f"| **{n}** | " + ' | '.join(cells) + ' |') | |
| lines.append('') | |
| lines.append(f"<sub>Commit: {commit_id[:7]}</sub>") | |
| with open('probe-comment.md', 'w') as f: | |
| f.write('\n'.join(lines)) | |
| PYEOF | |
| # ── Upload / publish ─────────────────────────────────────────── | |
| - name: Upload results | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: probe-results | |
| path: probe-*.json | |
| - name: Comment on PR | |
| if: github.event_name == 'pull_request' && steps.changes.outputs.servers != '[]' | |
| run: | | |
| COMMENT_ID=$(gh api repos/${{ github.repository }}/issues/${{ github.event.number }}/comments \ | |
| --jq '.[] | select(.body | contains("<!-- http11probe-results -->")) | .id' | head -1) | |
| if [ -n "$COMMENT_ID" ]; then | |
| gh api repos/${{ github.repository }}/issues/comments/$COMMENT_ID \ | |
| -X PATCH -f body="$(cat probe-comment.md)" | |
| else | |
| gh pr comment ${{ github.event.number }} --body-file probe-comment.md | |
| fi | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Push to latest-results | |
| if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| if git fetch origin latest-results 2>/dev/null; then | |
| git worktree add /tmp/latest-results origin/latest-results | |
| else | |
| git worktree add --detach /tmp/latest-results HEAD | |
| git -C /tmp/latest-results switch --orphan latest-results | |
| fi | |
| mkdir -p /tmp/latest-results/probe | |
| cp probe-data.js /tmp/latest-results/probe/data.js | |
| cd /tmp/latest-results | |
| git add probe/data.js | |
| if git diff --cached --quiet; then | |
| echo "No changes to commit." | |
| else | |
| git commit -m "Update probe results" | |
| git push origin HEAD:latest-results | |
| fi | |
| cd - | |
| git worktree remove /tmp/latest-results || true | |
| - name: Rebuild docs | |
| if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' | |
| run: gh workflow run "Deploy Docs to GitHub Pages" | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |