From a4637682c20c936a0a6f9bca47194bbc4b515e36 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 15 Feb 2026 17:05:23 +0000 Subject: [PATCH] Add tests, fix results for sequence tests --- .../content/docs/rfc-requirement-dashboard.md | 49 +- docs/content/docs/smuggling/_index.md | 9 + .../docs/smuggling/chunk-ext-invalid-token.md | 46 ++ .../smuggling/chunk-invalid-size-desync.md | 49 ++ .../content/docs/smuggling/chunk-size-plus.md | 42 ++ .../docs/smuggling/chunk-size-trailing-ows.md | 42 ++ .../content/docs/smuggling/cl0-body-poison.md | 43 ++ .../docs/smuggling/expect-100-cl-desync.md | 48 ++ .../docs/smuggling/get-cl-body-desync.md | 47 ++ .../docs/smuggling/options-cl-body-desync.md | 47 ++ .../docs/smuggling/options-te-obs-fold.md | 49 ++ docs/content/sequence-tests/_index.md | 7 + .../sequence-tests/smuggling/_index.md | 69 +++ docs/content/smuggling/_index.md | 31 +- docs/hugo.yaml | 2 +- .../TestCases/Suites/SmugglingSuite.cs | 425 ++++++++++++++++++ 16 files changed, 973 insertions(+), 32 deletions(-) create mode 100644 docs/content/docs/smuggling/chunk-ext-invalid-token.md create mode 100644 docs/content/docs/smuggling/chunk-invalid-size-desync.md create mode 100644 docs/content/docs/smuggling/chunk-size-plus.md create mode 100644 docs/content/docs/smuggling/chunk-size-trailing-ows.md create mode 100644 docs/content/docs/smuggling/cl0-body-poison.md create mode 100644 docs/content/docs/smuggling/expect-100-cl-desync.md create mode 100644 docs/content/docs/smuggling/get-cl-body-desync.md create mode 100644 docs/content/docs/smuggling/options-cl-body-desync.md create mode 100644 docs/content/docs/smuggling/options-te-obs-fold.md create mode 100644 docs/content/sequence-tests/_index.md create mode 100644 docs/content/sequence-tests/smuggling/_index.md diff --git a/docs/content/docs/rfc-requirement-dashboard.md b/docs/content/docs/rfc-requirement-dashboard.md index 30bb6c0..71c62a5 100644 --- a/docs/content/docs/rfc-requirement-dashboard.md +++ b/docs/content/docs/rfc-requirement-dashboard.md @@ -1,6 +1,6 @@ --- title: "RFC Requirement Dashboard" -description: "Complete RFC 2119 requirement-level analysis for all 172 Http11Probe tests" +description: "Complete RFC 2119 requirement-level analysis for all 183 Http11Probe tests" weight: 2 breadcrumbs: false --- @@ -11,18 +11,18 @@ This dashboard classifies every Http11Probe test by its [RFC 2119](https://www.r | Requirement Level | Count | Meaning (RFC 2119) | |---|---|---| -| **MUST** | 96 | Absolute requirement — no compliant implementation may deviate | +| **MUST** | 103 | Absolute requirement — no compliant implementation may deviate | | **SHOULD** | 29 | Recommended — valid exceptions exist but must be understood | | **MAY** | 10 | Truly optional — either behavior is fully compliant | | **"ought to"** | 1 | Weaker than SHOULD — recommended but not normative | -| **Unscored** | 25 | Informational — no pass/fail judgement | +| **Unscored** | 29 | Informational — no pass/fail judgement | | **N/A** | 11 | Best-practice / no single RFC verb applies | -**Total: 172 tests** +**Total: 183 tests** --- -## MUST-Level Requirements (96 tests) +## MUST-Level Requirements (103 tests) These tests enforce absolute RFC requirements. A compliant server has no discretion — it **MUST** behave as specified. @@ -99,11 +99,18 @@ The RFC requires rejection, but the mechanism (400 status or connection close) h | 56 | `SMUG-CLTE-KEEPALIVE` | Smuggling | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | CL+TE conflict with explicit `Connection: keep-alive`. **MUST** close connection regardless of keep-alive. | | 57 | `SMUG-CLTE-DESYNC` | Smuggling | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | Classic CL.TE desync: CL=6 with TE=chunked body `0\r\n\r\nX`. Poison byte after CL boundary confirms desync. **MUST** close connection. | | 58 | `SMUG-TECL-DESYNC` | Smuggling | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | Reverse TE.CL desync: TE=chunked terminates at `0\r\n\r\n` but CL=30. Extra bytes on wire confirm desync. **MUST** close connection. | +| 59 | `SMUG-CHUNK-SIZE-PLUS` | Smuggling | [RFC 9112 §7.1](https://www.rfc-editor.org/rfc/rfc9112#section-7.1) | Grammar: `chunk-size = 1*HEXDIG`. Leading `+` is not HEXDIG; invalid chunk framing **MUST** be rejected. | +| 60 | `SMUG-CHUNK-SIZE-TRAILING-OWS` | Smuggling | [RFC 9112 §7.1](https://www.rfc-editor.org/rfc/rfc9112#section-7.1) | Grammar: `chunk-size = 1*HEXDIG`. Trailing whitespace in chunk-size is invalid syntax and **MUST** be rejected. | +| 61 | `SMUG-CHUNK-EXT-INVALID-TOKEN` | Smuggling | [RFC 9112 §7.1.1](https://www.rfc-editor.org/rfc/rfc9112#section-7.1.1) | Grammar: `chunk-ext-name = token`. `[` is not a valid token character, so the chunk extension is invalid and **MUST** be rejected. | +| 62 | `SMUG-OPTIONS-TE-OBS-FOLD` | Smuggling | [RFC 9112 §5.2](https://www.rfc-editor.org/rfc/rfc9112#section-5.2) | "A server that receives an obs-fold in a request message ... **MUST** either reject the message by sending a 400 (Bad Request) ... or replace each received obs-fold with one or more SP octets." | +| 63 | `SMUG-CHUNK-INVALID-SIZE-DESYNC` | Smuggling | [RFC 9112 §7.1](https://www.rfc-editor.org/rfc/rfc9112#section-7.1) | Sequence test with invalid `+0` chunk-size plus poison byte. Since `chunk-size = 1*HEXDIG`, this framing error **MUST** be rejected to prevent desync. | | 54 | `COMP-CONNECTION-CLOSE` | Compliance | [RFC 9112 §9.6](https://www.rfc-editor.org/rfc/rfc9112#section-9.6) | "A server that receives a 'close' connection option **MUST** initiate closure of the connection after it sends the final response to the request that contained the 'close' connection option." | | 55 | `COMP-OPTIONS-STAR` | Compliance | [RFC 9112 §3.2.4](https://www.rfc-editor.org/rfc/rfc9112#section-3.2.4) | The asterisk-form `*` is defined only for OPTIONS. A valid OPTIONS * request **MUST** be accepted. | | 56 | `COMP-POST-CL-BODY` | Compliance | [RFC 9112 §6.2](https://www.rfc-editor.org/rfc/rfc9112#section-6.2) | "If a valid Content-Length header field is present without Transfer-Encoding, its decimal value defines the expected message body length in octets." Server **MUST** accept a well-formed POST with matching body. | | 57 | `COMP-POST-CL-ZERO` | Compliance | [RFC 9112 §6.2](https://www.rfc-editor.org/rfc/rfc9112#section-6.2) | Content-Length: 0 is a valid 1*DIGIT value. Server **MUST** accept zero-length body. | | 58 | `COMP-POST-NO-CL-NO-TE` | Compliance | [RFC 9112 §6.3](https://www.rfc-editor.org/rfc/rfc9112#section-6.3) | "If this is a request message and none of the above are true, then the message body length is zero (no message body is present)." Server **MUST** treat as zero-length. | +| 59 | `COMP-RANGE-POST` | Compliance | [RFC 9110 §14.2](https://www.rfc-editor.org/rfc/rfc9110#section-14.2) | "A server **MUST** ignore a Range header field received with a request method other than GET." | +| 60 | `COMP-UPGRADE-HTTP10` | Compliance | [RFC 9110 §7.8](https://www.rfc-editor.org/rfc/rfc9110#section-7.8) | "A server **MUST** ignore an Upgrade header field that is received in an HTTP/1.0 request." | | 59 | `COMP-CHUNKED-BODY` | Compliance | [RFC 9112 §7.1](https://www.rfc-editor.org/rfc/rfc9112#section-7.1) | "A recipient **MUST** be able to parse and decode the chunked transfer coding." | | 60 | `COMP-CHUNKED-MULTI` | Compliance | [RFC 9112 §7.1](https://www.rfc-editor.org/rfc/rfc9112#section-7.1) | Same — multi-chunk is the standard chunked format. **MUST** accept. | | 61 | `COMP-CHUNKED-EMPTY` | Compliance | [RFC 9112 §7.1](https://www.rfc-editor.org/rfc/rfc9112#section-7.1) | A zero-length chunked body (just `0\r\n\r\n`) is valid. **MUST** accept. | @@ -205,7 +212,7 @@ Weaker than SHOULD — recommends but does not normatively require. --- -## Unscored Tests (25 tests) +## Unscored Tests (29 tests) These tests are informational — they produce warnings but never fail. @@ -236,6 +243,10 @@ These tests are informational — they produce warnings but never fail. | 23 | `COMP-RANGE-INVALID` | Compliance | [RFC 9110 §14.2](https://www.rfc-editor.org/rfc/rfc9110#section-14.2) | "A server **MAY** ignore the Range header field." Invalid Range syntax — 2xx or 416 both acceptable. | | 24 | `COMP-POST-UNSUPPORTED-CT` | Compliance | [RFC 9110 §15.5.16](https://www.rfc-editor.org/rfc/rfc9110#section-15.5.16) | POST with unknown Content-Type — 415 or 2xx both acceptable. | | 25 | `SMUG-PIPELINE-SAFE` | Smuggling | [RFC 9112 §9.3](https://www.rfc-editor.org/rfc/rfc9112#section-9.3) | Baseline: two clean pipelined GETs. Validates sequence test infrastructure against the target. | +| 26 | `SMUG-CL0-BODY-POISON` | Smuggling | [RFC 9112 §6.2](https://www.rfc-editor.org/rfc/rfc9112#section-6.2) | `Content-Length: 0` plus trailing bytes, then follow-up GET on same socket. Sequence telemetry for `0.CL`-style poisoning behavior. | +| 27 | `SMUG-GET-CL-BODY-DESYNC` | Smuggling | [RFC 9110 §9.3.1](https://www.rfc-editor.org/rfc/rfc9110#section-9.3.1) | "Content received in a GET request ... might lead some implementations to reject the request and close the connection because of its potential as a request smuggling attack." Adds follow-up desync check. | +| 28 | `SMUG-OPTIONS-CL-BODY-DESYNC` | Smuggling | [RFC 9110 §9.3.7](https://www.rfc-editor.org/rfc/rfc9110#section-9.3.7) | OPTIONS with body plus follow-up GET to detect unread-body poisoning on persistent connections. | +| 29 | `SMUG-EXPECT-100-CL-DESYNC` | Smuggling | [RFC 9110 §10.1.1](https://www.rfc-editor.org/rfc/rfc9110#section-10.1.1) | Expect/continue flow with immediate body plus follow-up GET; highlights whether connection framing remains synchronized. | --- @@ -261,25 +272,25 @@ These tests don't map to a single RFC 2119 keyword but enforce defensive best pr ## Requirement Level by Suite -### Compliance Suite (74 tests) +### Compliance Suite (76 tests) | Level | Tests | |-------|-------| -| MUST | 45 | +| MUST | 47 | | SHOULD | 15 | | MAY | 6 | | Unscored | 7 | | N/A | 1 | -### Smuggling Suite (67 tests) +### Smuggling Suite (76 tests) | Level | Tests | |-------|-------| -| MUST | 39 | +| MUST | 44 | | SHOULD | 9 | | MAY | 3 | | "ought to" | 1 | -| Unscored | 15 | +| Unscored | 19 | ### Malformed Input Suite (26 tests) @@ -310,26 +321,26 @@ These tests don't map to a single RFC 2119 keyword but enforce defensive best pr | RFC 9112 §3 | 9 | Request line, method, request-target | | RFC 9112 §3.2 | 11 | Host header, request-target forms | | RFC 9112 §5 | 7 | Header field syntax, sp-before-colon | -| RFC 9112 §5.2 | 2 | Obsolete line folding | +| RFC 9112 §5.2 | 3 | Obsolete line folding | | RFC 9112 §6.1 | 21 | Transfer-Encoding, CL+TE ambiguity | -| RFC 9112 §6.2 | 4 | Content-Length body framing | +| RFC 9112 §6.2 | 5 | Content-Length body framing | | RFC 9112 §6.3 | 5 | Message body length determination | -| RFC 9112 §7.1 | 15 | Chunked transfer coding format | -| RFC 9112 §7.1.1 | 4 | Chunk extensions | +| RFC 9112 §7.1 | 18 | Chunked transfer coding format | +| RFC 9112 §7.1.1 | 5 | Chunk extensions | | RFC 9112 §7.1.2 | 1 | Chunked trailer section | | RFC 9112 §9.3-9.6 | 3 | Connection management | | RFC 9110 §5.3 | 1 | Header field duplication | | RFC 9110 §5.4-5.6 | 8 | Field limits, values, lists, tokens | | RFC 9110 §6.6.1 | 1 | Date header | | RFC 9110 §7.2 | 1 | Host header semantics | -| RFC 9110 §7.8 | 4 | Upgrade | +| RFC 9110 §7.8 | 5 | Upgrade | | RFC 9110 §8.3 | 1 | Content-Type | | RFC 9110 §8.6 | 14 | Content-Length semantics | -| RFC 9110 §9.1-9.3 | 10 | Methods (GET, HEAD, CONNECT, OPTIONS, TRACE) | -| RFC 9110 §10.1.1 | 2 | Expect header | +| RFC 9110 §9.1-9.3 | 12 | Methods (GET, HEAD, CONNECT, OPTIONS, TRACE) | +| RFC 9110 §10.1.1 | 3 | Expect header | | RFC 9110 §6.5 | 5 | Trailer field restrictions | | RFC 9110 §12.5.1 | 1 | Content negotiation (Accept) | -| RFC 9110 §14.2 | 2 | Range requests | +| RFC 9110 §14.2 | 3 | Range requests | | RFC 9110 §15.2 | 1 | 1xx status codes | | RFC 9110 §15.5.6 | 1 | 405 Method Not Allowed | | RFC 9110 §15.5.16 | 1 | 415 Unsupported Media Type | diff --git a/docs/content/docs/smuggling/_index.md b/docs/content/docs/smuggling/_index.md index f83a187..da41e97 100644 --- a/docs/content/docs/smuggling/_index.md +++ b/docs/content/docs/smuggling/_index.md @@ -72,6 +72,7 @@ For these, `400` is the strict/safe response and `2xx` is RFC-compliant. Http11P {{< card link="te-not-final-chunked" title="TE-NOT-FINAL-CHUNKED" subtitle="Chunked is not the final transfer encoding." >}} {{< card link="te-http10" title="TE-HTTP10" subtitle="Transfer-Encoding in HTTP/1.0 request." >}} {{< card link="chunk-bare-semicolon" title="CHUNK-BARE-SEMICOLON" subtitle="Bare semicolon in chunk size." >}} + {{< card link="chunk-ext-invalid-token" title="CHUNK-EXT-INVALID-TOKEN" subtitle="Invalid token character in chunk extension name." >}} {{< card link="bare-cr-header-value" title="BARE-CR-HEADER-VALUE" subtitle="Bare CR in header value." >}} {{< card link="cl-octal" title="CL-OCTAL" subtitle="Content-Length with octal prefix." >}} {{< card link="chunk-underscore" title="CHUNK-UNDERSCORE" subtitle="Underscore in chunk size." >}} @@ -79,12 +80,15 @@ For these, `400` is the strict/safe response and `2xx` is RFC-compliant. Http11P {{< card link="te-leading-comma" title="TE-LEADING-COMMA" subtitle="Leading comma in Transfer-Encoding." >}} {{< card link="te-duplicate-headers" title="TE-DUPLICATE-HEADERS" subtitle="Two TE headers with conflicting values." >}} {{< card link="chunk-hex-prefix" title="CHUNK-HEX-PREFIX" subtitle="Chunk size with 0x prefix." >}} + {{< card link="chunk-size-plus" title="CHUNK-SIZE-PLUS" subtitle="Chunk size with leading plus sign." >}} + {{< card link="chunk-size-trailing-ows" title="CHUNK-SIZE-TRAILING-OWS" subtitle="Chunk size with trailing whitespace." >}} {{< card link="cl-hex-prefix" title="CL-HEX-PREFIX" subtitle="Content-Length with 0x prefix." >}} {{< card link="cl-internal-space" title="CL-INTERNAL-SPACE" subtitle="Space inside Content-Length value." >}} {{< card link="chunk-leading-sp" title="CHUNK-LEADING-SP" subtitle="Leading space in chunk size." >}} {{< card link="chunk-missing-trailing-crlf" title="CHUNK-MISSING-TRAILING-CRLF" subtitle="Chunk data without trailing CRLF." >}} {{< card link="chunk-ext-lf" title="CHUNK-EXT-LF" subtitle="Bare LF in chunk extension (TERM.EXT vector)." >}} {{< card link="chunk-spill" title="CHUNK-SPILL" subtitle="Chunk declares size 5 but sends 7 bytes." >}} + {{< card link="chunk-invalid-size-desync" title="CHUNK-INVALID-SIZE-DESYNC" subtitle="Invalid chunk size plus poison-byte follow-up check." >}} {{< card link="chunk-lf-term" title="CHUNK-LF-TERM" subtitle="Bare LF as chunk data terminator." >}} {{< card link="chunk-ext-ctrl" title="CHUNK-EXT-CTRL" subtitle="NUL byte in chunk extension." >}} {{< card link="chunk-ext-cr" title="CHUNK-EXT-CR" subtitle="Bare CR inside chunk extension metadata." >}} @@ -100,6 +104,7 @@ For these, `400` is the strict/safe response and `2xx` is RFC-compliant. Http11P {{< card link="cl-double-zero" title="CL-DOUBLE-ZERO" subtitle="Content-Length: 00 — leading zero ambiguity." >}} {{< card link="cl-leading-zeros-octal" title="CL-LEADING-ZEROS-OCTAL" subtitle="Content-Length: 0200 — octal vs decimal disagreement." >}} {{< card link="te-obs-fold" title="TE-OBS-FOLD" subtitle="Transfer-Encoding with obs-fold line wrapping." >}} + {{< card link="options-te-obs-fold" title="OPTIONS-TE-OBS-FOLD" subtitle="OPTIONS path for TE obs-fold plus follow-up close check." >}} {{< card link="te-trailing-comma" title="TE-TRAILING-COMMA" subtitle="Transfer-Encoding: chunked, — trailing comma." >}} {{< card link="multiple-host-comma" title="MULTIPLE-HOST-COMMA" subtitle="Host with comma-separated values." >}} {{< /cards >}} @@ -116,13 +121,17 @@ For these, `400` is the strict/safe response and `2xx` is RFC-compliant. Http11P {{< card link="cl-comma-triple" title="CL-COMMA-TRIPLE" subtitle="Three comma-separated identical CL values." >}} {{< card link="chunked-with-params" title="CHUNKED-WITH-PARAMS" subtitle="Parameters on chunked encoding." >}} {{< card link="expect-100-cl" title="EXPECT-100-CL" subtitle="Expect: 100-continue with Content-Length." >}} + {{< card link="expect-100-cl-desync" title="EXPECT-100-CL-DESYNC" subtitle="Expect workflow with follow-up desync check." >}} {{< card link="trailer-cl" title="TRAILER-CL" subtitle="Content-Length in chunked trailers (prohibited)." >}} {{< card link="trailer-te" title="TRAILER-TE" subtitle="Transfer-Encoding in chunked trailers (prohibited)." >}} {{< card link="trailer-host" title="TRAILER-HOST" subtitle="Host header in chunked trailers (must not route)." >}} {{< card link="trailer-auth" title="TRAILER-AUTH" subtitle="Authorization in chunked trailers (prohibited)." >}} {{< card link="trailer-content-type" title="TRAILER-CONTENT-TYPE" subtitle="Content-Type in chunked trailers (prohibited)." >}} + {{< card link="cl0-body-poison" title="CL0-BODY-POISON" subtitle="CL:0 with trailing byte and follow-up request." >}} + {{< card link="get-cl-body-desync" title="GET-CL-BODY-DESYNC" subtitle="GET with body plus follow-up desync check." >}} {{< card link="head-cl-body" title="HEAD-CL-BODY" subtitle="HEAD with Content-Length and body." >}} {{< card link="options-cl-body" title="OPTIONS-CL-BODY" subtitle="OPTIONS with Content-Length and body." >}} + {{< card link="options-cl-body-desync" title="OPTIONS-CL-BODY-DESYNC" subtitle="OPTIONS with body plus follow-up desync check." >}} {{< card link="te-tab-before-value" title="TE-TAB-BEFORE-VALUE" subtitle="Tab as OWS before Transfer-Encoding value." >}} {{< card link="absolute-uri-host-mismatch" title="ABSOLUTE-URI-HOST-MISMATCH" subtitle="Absolute-form URI with different Host header." >}} {{< /cards >}} diff --git a/docs/content/docs/smuggling/chunk-ext-invalid-token.md b/docs/content/docs/smuggling/chunk-ext-invalid-token.md new file mode 100644 index 0000000..8bcac09 --- /dev/null +++ b/docs/content/docs/smuggling/chunk-ext-invalid-token.md @@ -0,0 +1,46 @@ +--- +title: "CHUNK-EXT-INVALID-TOKEN" +description: "SMUG-CHUNK-EXT-INVALID-TOKEN test documentation" +weight: 1 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-CHUNK-EXT-INVALID-TOKEN` | +| **Category** | Smuggling | +| **RFC** | [RFC 9112 §7.1.1](https://www.rfc-editor.org/rfc/rfc9112#section-7.1.1) | +| **Requirement** | Implicit MUST (grammar violation) | +| **Expected** | `400` or close | + +## What it sends + +A chunk extension with an invalid token character in the extension name (`bad[`): + +```http +POST / HTTP/1.1\r\n +Host: localhost:8080\r\n +Transfer-Encoding: chunked\r\n +\r\n +5;bad[=x\r\n +hello\r\n +0\r\n +\r\n +``` + +## What the RFC says + +> "chunk-ext-name = token" -- RFC 9112 Section 7.1.1 + +`[` is not a valid token character, so the extension syntax is invalid. + +## Partial Coverage Note + +Existing tests already cover malformed chunk extensions (`SMUG-CHUNK-BARE-SEMICOLON`, `SMUG-CHUNK-EXT-CTRL`, `SMUG-CHUNK-EXT-CR`, `SMUG-CHUNK-EXT-LF`). This case specifically targets invalid token characters in extension names. + +## Why it matters + +Different extension parsers may tokenize this differently, creating front-end/back-end framing inconsistencies. + +## Sources + +- [RFC 9112 §7.1.1](https://www.rfc-editor.org/rfc/rfc9112#section-7.1.1) diff --git a/docs/content/docs/smuggling/chunk-invalid-size-desync.md b/docs/content/docs/smuggling/chunk-invalid-size-desync.md new file mode 100644 index 0000000..c9fadd3 --- /dev/null +++ b/docs/content/docs/smuggling/chunk-invalid-size-desync.md @@ -0,0 +1,49 @@ +--- +title: "CHUNK-INVALID-SIZE-DESYNC" +description: "SMUG-CHUNK-INVALID-SIZE-DESYNC test documentation" +weight: 1 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-CHUNK-INVALID-SIZE-DESYNC` | +| **Category** | Smuggling | +| **RFC** | [RFC 9112 §7.1](https://www.rfc-editor.org/rfc/rfc9112#section-7.1) | +| **Requirement** | MUST | +| **Expected** | `400` or close | + +## What it sends + +A two-step sequence: invalid chunk-size `+0` with poison byte `X`, then a clean `GET`. + +```http +POST / HTTP/1.1\r\n +Host: localhost:8080\r\n +Transfer-Encoding: chunked\r\n +\r\n ++0\r\n +\r\n +X + +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +\r\n +``` + +## What the RFC says + +> "chunk-size = 1*HEXDIG" -- RFC 9112 Section 7.1 + +Invalid chunk-size is a framing error. This sequence confirms whether acceptance leads to follow-up parsing corruption. + +## Partial Coverage Note + +Existing tests (`SMUG-CHUNK-NEGATIVE`, `SMUG-CHUNK-HEX-PREFIX`, `SMUG-CHUNK-SPILL`, `MAL-CHUNK-SIZE-OVERFLOW`) cover invalid chunk primitives. This test adds explicit desync confirmation via a follow-up request. + +## Why it matters + +If invalid chunk-size is tolerated and the connection remains open, poison bytes can be interpreted as the next request. + +## Sources + +- [RFC 9112 §7.1](https://www.rfc-editor.org/rfc/rfc9112#section-7.1) diff --git a/docs/content/docs/smuggling/chunk-size-plus.md b/docs/content/docs/smuggling/chunk-size-plus.md new file mode 100644 index 0000000..0ccbbb6 --- /dev/null +++ b/docs/content/docs/smuggling/chunk-size-plus.md @@ -0,0 +1,42 @@ +--- +title: "CHUNK-SIZE-PLUS" +description: "SMUG-CHUNK-SIZE-PLUS test documentation" +weight: 1 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-CHUNK-SIZE-PLUS` | +| **Category** | Smuggling | +| **RFC** | [RFC 9112 §7.1](https://www.rfc-editor.org/rfc/rfc9112#section-7.1) | +| **Requirement** | Implicit MUST (grammar violation) | +| **Expected** | `400` or close | + +## What it sends + +A chunked request where chunk-size is prefixed by `+`. + +```http +POST / HTTP/1.1\r\n +Host: localhost:8080\r\n +Transfer-Encoding: chunked\r\n +\r\n ++5\r\n +hello\r\n +0\r\n +\r\n +``` + +## What the RFC says + +> "chunk-size = 1*HEXDIG" -- RFC 9112 Section 7.1 + +The plus sign is not a hexadecimal digit. The chunk-size token is invalid. + +## Why it matters + +Lenient numeric parsing (`+5`) in one component and strict parsing in another creates parser disagreement and desync opportunities. + +## Sources + +- [RFC 9112 §7.1](https://www.rfc-editor.org/rfc/rfc9112#section-7.1) diff --git a/docs/content/docs/smuggling/chunk-size-trailing-ows.md b/docs/content/docs/smuggling/chunk-size-trailing-ows.md new file mode 100644 index 0000000..5fa1762 --- /dev/null +++ b/docs/content/docs/smuggling/chunk-size-trailing-ows.md @@ -0,0 +1,42 @@ +--- +title: "CHUNK-SIZE-TRAILING-OWS" +description: "SMUG-CHUNK-SIZE-TRAILING-OWS test documentation" +weight: 1 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-CHUNK-SIZE-TRAILING-OWS` | +| **Category** | Smuggling | +| **RFC** | [RFC 9112 §7.1](https://www.rfc-editor.org/rfc/rfc9112#section-7.1) | +| **Requirement** | Implicit MUST (grammar violation) | +| **Expected** | `400` or close | + +## What it sends + +A chunked request with trailing whitespace after the chunk-size token. + +```http +POST / HTTP/1.1\r\n +Host: localhost:8080\r\n +Transfer-Encoding: chunked\r\n +\r\n +5 \r\n +hello\r\n +0\r\n +\r\n +``` + +## What the RFC says + +> "chunk-size = 1*HEXDIG" -- RFC 9112 Section 7.1 + +Whitespace is not part of `HEXDIG`; trailing OWS in chunk-size is invalid. + +## Why it matters + +Some parsers trim this value while others reject it. Differential behavior can create request boundary disagreements. + +## Sources + +- [RFC 9112 §7.1](https://www.rfc-editor.org/rfc/rfc9112#section-7.1) diff --git a/docs/content/docs/smuggling/cl0-body-poison.md b/docs/content/docs/smuggling/cl0-body-poison.md new file mode 100644 index 0000000..05ed74b --- /dev/null +++ b/docs/content/docs/smuggling/cl0-body-poison.md @@ -0,0 +1,43 @@ +--- +title: "CL0-BODY-POISON" +description: "SMUG-CL0-BODY-POISON test documentation" +weight: 1 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-CL0-BODY-POISON` | +| **Category** | Smuggling | +| **RFC** | [RFC 9112 §6.2](https://www.rfc-editor.org/rfc/rfc9112#section-6.2) | +| **Requirement** | Unscored | +| **Expected** | `400`/close preferred; poisoned follow-up = warn | + +## What it sends + +A two-step sequence: first a `POST` with `Content-Length: 0` plus one extra byte, then a clean `GET` on the same connection. + +```http +POST / HTTP/1.1\r\n +Host: localhost:8080\r\n +Content-Length: 0\r\n +\r\n +X + +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +\r\n +``` + +## What the RFC says + +> "If a valid Content-Length header field is present without Transfer-Encoding, its decimal value defines the expected message body length in octets." -- RFC 9112 Section 6.2 + +`Content-Length: 0` means no body bytes are part of the first request. This test checks whether trailing bytes can poison parsing of the next request on a keep-alive connection. + +## Why it matters + +`0.CL`-style desync chains rely on parser disagreement about where the first request ends. This sequence test surfaces that behavior directly. + +## Sources + +- [RFC 9112 §6.2](https://www.rfc-editor.org/rfc/rfc9112#section-6.2) diff --git a/docs/content/docs/smuggling/expect-100-cl-desync.md b/docs/content/docs/smuggling/expect-100-cl-desync.md new file mode 100644 index 0000000..fe90572 --- /dev/null +++ b/docs/content/docs/smuggling/expect-100-cl-desync.md @@ -0,0 +1,48 @@ +--- +title: "EXPECT-100-CL-DESYNC" +description: "SMUG-EXPECT-100-CL-DESYNC test documentation" +weight: 1 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-EXPECT-100-CL-DESYNC` | +| **Category** | Smuggling | +| **RFC** | [RFC 9110 §10.1.1](https://www.rfc-editor.org/rfc/rfc9110#section-10.1.1) | +| **Requirement** | Unscored | +| **Expected** | `417/400/close` preferred; poisoned follow-up = warn | + +## What it sends + +A `POST` with `Expect: 100-continue` and immediate body bytes, followed by a second `GET` on the same connection. + +```http +POST / HTTP/1.1\r\n +Host: localhost:8080\r\n +Content-Length: 5\r\n +Expect: 100-continue\r\n +\r\n +hello + +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +\r\n +``` + +## What the RFC says + +> "The 100 (Continue) interim response indicates that the initial part of a request has been received and has not yet been rejected by the server." -- RFC 9110 Section 10.1.1 + +This test checks whether servers that accept this flow keep connection framing safe for the next request. + +## Partial Coverage Note + +Existing test `SMUG-EXPECT-100-CL` checks one request. This desync variant verifies the post-response connection state using a second request. + +## Why it matters + +Desync risk appears when a server issues a final response without fully consuming the declared body. + +## Sources + +- [RFC 9110 §10.1.1](https://www.rfc-editor.org/rfc/rfc9110#section-10.1.1) diff --git a/docs/content/docs/smuggling/get-cl-body-desync.md b/docs/content/docs/smuggling/get-cl-body-desync.md new file mode 100644 index 0000000..fe7989d --- /dev/null +++ b/docs/content/docs/smuggling/get-cl-body-desync.md @@ -0,0 +1,47 @@ +--- +title: "GET-CL-BODY-DESYNC" +description: "SMUG-GET-CL-BODY-DESYNC test documentation" +weight: 1 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-GET-CL-BODY-DESYNC` | +| **Category** | Smuggling | +| **RFC** | [RFC 9110 §9.3.1](https://www.rfc-editor.org/rfc/rfc9110#section-9.3.1) | +| **Requirement** | Unscored | +| **Expected** | `400`/close/pass-through; poisoned follow-up = warn | + +## What it sends + +A `GET` with `Content-Length: 5` and body `hello`, followed by a second `GET` on the same socket. + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +Content-Length: 5\r\n +\r\n +hello + +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +\r\n +``` + +## What the RFC says + +> "Content received in a GET request has no generally defined semantics... and might lead some implementations to reject the request and close the connection because of its potential as a request smuggling attack." -- RFC 9110 Section 9.3.1 + +This test extends the GET-with-body case into a sequence to detect unread-body desynchronization. + +## Partial Coverage Note + +Existing test `COMP-GET-WITH-CL-BODY` already checks single-request behavior. This test adds a follow-up request to detect connection poisoning. + +## Why it matters + +Single-request `2xx` is not enough to prove safety. The second request reveals whether body bytes were consumed or leaked into the next parse. + +## Sources + +- [RFC 9110 §9.3.1](https://www.rfc-editor.org/rfc/rfc9110#section-9.3.1) diff --git a/docs/content/docs/smuggling/options-cl-body-desync.md b/docs/content/docs/smuggling/options-cl-body-desync.md new file mode 100644 index 0000000..5a5a136 --- /dev/null +++ b/docs/content/docs/smuggling/options-cl-body-desync.md @@ -0,0 +1,47 @@ +--- +title: "OPTIONS-CL-BODY-DESYNC" +description: "SMUG-OPTIONS-CL-BODY-DESYNC test documentation" +weight: 1 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-OPTIONS-CL-BODY-DESYNC` | +| **Category** | Smuggling | +| **RFC** | [RFC 9110 §9.3.7](https://www.rfc-editor.org/rfc/rfc9110#section-9.3.7) | +| **Requirement** | Unscored | +| **Expected** | `400`/close/pass-through; poisoned follow-up = warn | + +## What it sends + +An `OPTIONS` request with `Content-Length: 5` and body `hello`, followed by a second `GET` on the same connection. + +```http +OPTIONS / HTTP/1.1\r\n +Host: localhost:8080\r\n +Content-Length: 5\r\n +\r\n +hello + +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +\r\n +``` + +## What the RFC says + +> "An OPTIONS request containing content must send a valid Content-Type header field describing the representation media type." -- RFC 9110 Section 9.3.7 + +While server behavior differs across frameworks, this sequence checks whether body handling leaves the connection in a desynchronized state. + +## Partial Coverage Note + +Existing test `SMUG-OPTIONS-CL-BODY` checks a single request. This variant adds a follow-up request to detect unread-body poisoning. + +## Why it matters + +OPTIONS is common in CORS workflows. If a server responds before consuming bytes, those bytes can corrupt the next request boundary. + +## Sources + +- [RFC 9110 §9.3.7](https://www.rfc-editor.org/rfc/rfc9110#section-9.3.7) diff --git a/docs/content/docs/smuggling/options-te-obs-fold.md b/docs/content/docs/smuggling/options-te-obs-fold.md new file mode 100644 index 0000000..8453c60 --- /dev/null +++ b/docs/content/docs/smuggling/options-te-obs-fold.md @@ -0,0 +1,49 @@ +--- +title: "OPTIONS-TE-OBS-FOLD" +description: "SMUG-OPTIONS-TE-OBS-FOLD test documentation" +weight: 1 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-OPTIONS-TE-OBS-FOLD` | +| **Category** | Smuggling | +| **RFC** | [RFC 9112 §5.2](https://www.rfc-editor.org/rfc/rfc9112#section-5.2) | +| **Requirement** | MUST | +| **Expected** | `400` or `2xx` + close | + +## What it sends + +A two-step sequence: `OPTIONS` with folded `Transfer-Encoding` plus `Content-Length`, then a follow-up `GET`. + +```http +OPTIONS / HTTP/1.1\r\n +Host: localhost:8080\r\n +Transfer-Encoding:\r\n + chunked\r\n +Content-Length: 5\r\n +\r\n +hello + +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +\r\n +``` + +## What the RFC says + +> "A server that receives an obs-fold in a request message ... MUST either reject the message by sending a 400 (Bad Request) ... or replace each received obs-fold with one or more SP octets." -- RFC 9112 Section 5.2 + +If unfolded to `Transfer-Encoding: chunked` while `Content-Length` is also present, connection safety rules still apply. + +## Partial Coverage Note + +Existing test `SMUG-TE-OBS-FOLD` already covers this grammar issue in a single request. This variant exercises the `OPTIONS` method path and verifies follow-up connection handling. + +## Why it matters + +Method-specific parser branches can bypass generic TE validation. This is the class highlighted by recent OPTIONS+obs-fold smuggling disclosures. + +## Sources + +- [RFC 9112 §5.2](https://www.rfc-editor.org/rfc/rfc9112#section-5.2) diff --git a/docs/content/sequence-tests/_index.md b/docs/content/sequence-tests/_index.md new file mode 100644 index 0000000..c5d2d22 --- /dev/null +++ b/docs/content/sequence-tests/_index.md @@ -0,0 +1,7 @@ +--- +title: Sequence Tests +toc: false +--- + +Multi-step tests that send more than one request on the same TCP connection to detect desynchronization, request queue poisoning, or missing connection-close behavior. + diff --git a/docs/content/sequence-tests/smuggling/_index.md b/docs/content/sequence-tests/smuggling/_index.md new file mode 100644 index 0000000..3da0952 --- /dev/null +++ b/docs/content/sequence-tests/smuggling/_index.md @@ -0,0 +1,69 @@ +--- +title: Smuggling +layout: wide +toc: false +--- + +## HTTP Request Smuggling (Sequence Tests) + +These tests send multiple requests on the same TCP connection to detect desynchronization and request queue poisoning. A safe server should reject the first ambiguous request (usually `400`) or close the connection so the follow-up request cannot be interpreted out-of-sync. + + +
+
Server Name
Click to view Dockerfile and source code
+
Table Row
Click to expand all results for that server
+
Result Cell
Click to see the full HTTP request and response
+
+ +
+
+
+
+
+

Loading...

+ + + + + diff --git a/docs/content/smuggling/_index.md b/docs/content/smuggling/_index.md index 9c17499..eae91ab 100644 --- a/docs/content/smuggling/_index.md +++ b/docs/content/smuggling/_index.md @@ -10,6 +10,10 @@ HTTP request smuggling exploits disagreements between front-end and back-end ser These tests send requests with ambiguous framing — conflicting `Content-Length` and `Transfer-Encoding` headers, duplicated values, obfuscated encoding names — and verify the server rejects them outright rather than guessing. +{{< callout type="info" >}} +Multi-step desync checks are under [Sequence Tests → Smuggling](../sequence-tests/smuggling/). +{{< /callout >}} + {{< callout type="warning" >}} Some tests are **unscored** (marked with `*`). These send payloads where the RFC permits multiple valid interpretations — for example, OWS trimming or case-insensitive TE matching. A `2xx` response is RFC-compliant but shown as a warning since stricter rejection is preferred. {{< /callout >}} @@ -36,10 +40,15 @@ Some tests are **unscored** (marked with `*`). These send payloads where the RFC document.getElementById('table-smuggling').innerHTML = '

No probe data available yet. Run the Probe workflow manually on main to generate results.

'; return; } + var SEQUENCE_IDS = [ + 'SMUG-CLTE-CONN-CLOSE','SMUG-TECL-CONN-CLOSE','SMUG-CLTE-KEEPALIVE', + 'SMUG-CLTE-DESYNC','SMUG-TECL-DESYNC','SMUG-PIPELINE-SAFE', + 'SMUG-CL0-BODY-POISON','SMUG-GET-CL-BODY-DESYNC','SMUG-OPTIONS-CL-BODY-DESYNC', + 'SMUG-EXPECT-100-CL-DESYNC','SMUG-OPTIONS-TE-OBS-FOLD','SMUG-CHUNK-INVALID-SIZE-DESYNC' + ]; var GROUPS = [ { key: 'framing', label: 'Framing Conflicts', testIds: [ - 'SMUG-CL-TE-BOTH','SMUG-CLTE-CONN-CLOSE','SMUG-TECL-CONN-CLOSE','SMUG-CLTE-KEEPALIVE', - 'SMUG-CLTE-PIPELINE','SMUG-TECL-PIPELINE','SMUG-TE-HTTP10', + 'SMUG-CL-TE-BOTH','SMUG-CLTE-PIPELINE','SMUG-TECL-PIPELINE','SMUG-TE-HTTP10', 'SMUG-DUPLICATE-CL','SMUG-CL-LEADING-ZEROS','SMUG-CL-NEGATIVE', 'SMUG-CL-COMMA-DIFFERENT','SMUG-CL-OCTAL','SMUG-CL-HEX-PREFIX', 'SMUG-CL-INTERNAL-SPACE','SMUG-CL-COMMA-SAME', @@ -58,16 +67,13 @@ Some tests are **unscored** (marked with `*`). These send payloads where the RFC 'SMUG-CHUNK-BARE-SEMICOLON','SMUG-CHUNK-HEX-PREFIX','SMUG-CHUNK-UNDERSCORE', 'SMUG-CHUNK-LEADING-SP','SMUG-CHUNK-MISSING-TRAILING-CRLF', 'SMUG-CHUNK-EXT-LF','SMUG-CHUNK-SPILL','SMUG-CHUNK-LF-TERM', - 'SMUG-CHUNK-EXT-CTRL','SMUG-CHUNK-EXT-CR','SMUG-CHUNK-LF-TRAILER', - 'SMUG-CHUNK-NEGATIVE','SMUG-CHUNK-BARE-CR-TERM' - ]}, - { key: 'desync', label: 'Desync Detection', testIds: [ - 'SMUG-CLTE-DESYNC','SMUG-TECL-DESYNC','SMUG-PIPELINE-SAFE' - ]}, - { key: 'headers-trailers', label: 'Headers, Trailers & Methods', testIds: [ - 'SMUG-BARE-CR-HEADER-VALUE', - 'SMUG-TRAILER-CL','SMUG-TRAILER-TE','SMUG-TRAILER-HOST', - 'SMUG-TRAILER-AUTH','SMUG-TRAILER-CONTENT-TYPE', + 'SMUG-CHUNK-EXT-CTRL','SMUG-CHUNK-EXT-CR','SMUG-CHUNK-LF-TRAILER', + 'SMUG-CHUNK-NEGATIVE','SMUG-CHUNK-BARE-CR-TERM' + ]}, + { key: 'headers-trailers', label: 'Headers, Trailers & Methods', testIds: [ + 'SMUG-BARE-CR-HEADER-VALUE', + 'SMUG-TRAILER-CL','SMUG-TRAILER-TE','SMUG-TRAILER-HOST', + 'SMUG-TRAILER-AUTH','SMUG-TRAILER-CONTENT-TYPE', 'SMUG-EXPECT-100-CL','SMUG-HEAD-CL-BODY','SMUG-OPTIONS-CL-BODY', 'SMUG-ABSOLUTE-URI-HOST-MISMATCH','SMUG-MULTIPLE-HOST-COMMA' ]} @@ -81,6 +87,7 @@ Some tests are **unscored** (marked with `*`). These send payloads where the RFC if (methodFilter) data = ProbeRender.filterByMethod(data, methodFilter); if (rfcLevelFilter) data = ProbeRender.filterByRfcLevel(data, rfcLevelFilter); var ctx = ProbeRender.buildLookups(data.servers); + ctx.testIds = ctx.testIds.filter(function (tid) { return SEQUENCE_IDS.indexOf(tid) === -1; }); ProbeRender.renderSubTables('table-smuggling', 'Smuggling', ctx, GROUPS); } rerender(); diff --git a/docs/hugo.yaml b/docs/hugo.yaml index 4cd9d67..c5cf69a 100644 --- a/docs/hugo.yaml +++ b/docs/hugo.yaml @@ -40,7 +40,7 @@ menu: weight: 3 - name: Smuggling parent: Sequence Tests - pageRef: /smuggling + pageRef: /sequence-tests/smuggling weight: 1 - name: Glossary pageRef: /docs diff --git a/src/Http11Probe/TestCases/Suites/SmugglingSuite.cs b/src/Http11Probe/TestCases/Suites/SmugglingSuite.cs index 0c931e4..5ae7cc3 100644 --- a/src/Http11Probe/TestCases/Suites/SmugglingSuite.cs +++ b/src/Http11Probe/TestCases/Suites/SmugglingSuite.cs @@ -428,6 +428,21 @@ public static IEnumerable GetTestCases() } }; + yield return new TestCase + { + Id = "SMUG-CHUNK-EXT-INVALID-TOKEN", + Description = "Chunk extension with invalid token character must be rejected", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9112 §7.1.1", + PayloadFactory = ctx => MakeRequest( + $"POST / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nTransfer-Encoding: chunked\r\n\r\n5;bad[=x\r\nhello\r\n0\r\n\r\n"), + Expected = new ExpectedBehavior + { + ExpectedStatus = StatusCodeRange.Exact(400), + AllowConnectionClose = true + } + }; + yield return new TestCase { Id = "SMUG-BARE-CR-HEADER-VALUE", @@ -550,6 +565,36 @@ public static IEnumerable GetTestCases() } }; + yield return new TestCase + { + Id = "SMUG-CHUNK-SIZE-PLUS", + Description = "Chunk size with leading plus sign must be rejected", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9112 §7.1", + PayloadFactory = ctx => MakeRequest( + $"POST / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nTransfer-Encoding: chunked\r\n\r\n+5\r\nhello\r\n0\r\n\r\n"), + Expected = new ExpectedBehavior + { + ExpectedStatus = StatusCodeRange.Exact(400), + AllowConnectionClose = true + } + }; + + yield return new TestCase + { + Id = "SMUG-CHUNK-SIZE-TRAILING-OWS", + Description = "Chunk size with trailing whitespace before CRLF must be rejected", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9112 §7.1", + PayloadFactory = ctx => MakeRequest( + $"POST / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nTransfer-Encoding: chunked\r\n\r\n5 \r\nhello\r\n0\r\n\r\n"), + Expected = new ExpectedBehavior + { + ExpectedStatus = StatusCodeRange.Exact(400), + AllowConnectionClose = true + } + }; + yield return new TestCase { Id = "SMUG-CL-HEX-PREFIX", @@ -1788,6 +1833,386 @@ public static IEnumerable GetSequenceTestCases() } }; + yield return new SequenceTestCase + { + Id = "SMUG-CL0-BODY-POISON", + Description = "Content-Length: 0 with trailing bytes — checks if leftover bytes poison the next request", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9112 §6.2", + RfcLevel = RfcLevel.NotApplicable, + Scored = false, + Expected = new ExpectedBehavior + { + Description = "400/close preferred; poisoned follow-up = warn" + }, + Steps = + [ + new SequenceStep + { + Label = "POST with CL:0 plus poison byte", + PayloadFactory = ctx => MakeRequest( + $"POST / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nContent-Length: 0\r\n\r\nX") + }, + new SequenceStep + { + Label = "Follow-up GET", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n") + } + ], + Validator = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (step1.Response?.StatusCode == 400) + return TestVerdict.Pass; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return TestVerdict.Pass; + if (step1.Response?.StatusCode is >= 200 and < 300) + { + if (!step2.Executed) + return TestVerdict.Pass; + if (step2.Response?.StatusCode == 400) + return TestVerdict.Warn; + return TestVerdict.Warn; + } + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (step1.Response?.StatusCode == 400) + return "Rejected CL:0 + trailing-bytes request with 400"; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return "Connection closed after CL:0 request"; + if (!step2.Executed) + return $"Step 1 returned {step1.Response?.StatusCode}; connection then closed"; + if (step2.Response?.StatusCode == 400) + return $"Follow-up parsed as poisoned request (XGET...) after step 1 status {step1.Response?.StatusCode}"; + return $"Step 1: {step1.Response?.StatusCode}, step 2: {step2.Response?.StatusCode}"; + } + }; + + yield return new SequenceTestCase + { + Id = "SMUG-GET-CL-BODY-DESYNC", + Description = "GET with Content-Length body followed by a second request — detects unread-body desync", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9110 §9.3.1", + RfcLevel = RfcLevel.May, + Scored = false, + Expected = new ExpectedBehavior + { + Description = "400/close/pass-through; poisoned follow-up = warn" + }, + Steps = + [ + new SequenceStep + { + Label = "GET with CL body", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nContent-Length: 5\r\n\r\nhello") + }, + new SequenceStep + { + Label = "Follow-up GET", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n") + } + ], + Validator = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (step1.Response?.StatusCode == 400) + return TestVerdict.Pass; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return TestVerdict.Pass; + if (step1.Response?.StatusCode is >= 200 and < 300) + { + if (!step2.Executed) + return TestVerdict.Pass; + if (step2.Response?.StatusCode == 400) + return TestVerdict.Warn; + if (step2.Response?.StatusCode is >= 200 and < 300) + return TestVerdict.Pass; + return TestVerdict.Warn; + } + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (step1.Response?.StatusCode == 400) + return "Rejected GET with body"; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return "Connection closed after GET-with-body request"; + if (!step2.Executed) + return $"Step 1 returned {step1.Response?.StatusCode}; connection then closed"; + if (step2.Response?.StatusCode == 400) + return $"Possible desync: follow-up GET returned 400 after GET-with-body acceptance ({step1.Response?.StatusCode})"; + return $"Step 1: {step1.Response?.StatusCode}, step 2: {step2.Response?.StatusCode}"; + } + }; + + yield return new SequenceTestCase + { + Id = "SMUG-OPTIONS-CL-BODY-DESYNC", + Description = "OPTIONS with Content-Length body followed by a second request — detects unread-body desync", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9110 §9.3.7", + RfcLevel = RfcLevel.NotApplicable, + Scored = false, + Expected = new ExpectedBehavior + { + Description = "400/close/pass-through; poisoned follow-up = warn" + }, + Steps = + [ + new SequenceStep + { + Label = "OPTIONS with CL body", + PayloadFactory = ctx => MakeRequest( + $"OPTIONS / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nContent-Length: 5\r\n\r\nhello") + }, + new SequenceStep + { + Label = "Follow-up GET", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n") + } + ], + Validator = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (step1.Response?.StatusCode == 400) + return TestVerdict.Pass; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return TestVerdict.Pass; + if (step1.Response?.StatusCode is >= 200 and < 300) + { + if (!step2.Executed) + return TestVerdict.Pass; + if (step2.Response?.StatusCode == 400) + return TestVerdict.Warn; + if (step2.Response?.StatusCode is >= 200 and < 300) + return TestVerdict.Pass; + return TestVerdict.Warn; + } + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (step1.Response?.StatusCode == 400) + return "Rejected OPTIONS with body"; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return "Connection closed after OPTIONS-with-body request"; + if (!step2.Executed) + return $"Step 1 returned {step1.Response?.StatusCode}; connection then closed"; + if (step2.Response?.StatusCode == 400) + return $"Possible desync: follow-up GET returned 400 after OPTIONS-with-body acceptance ({step1.Response?.StatusCode})"; + return $"Step 1: {step1.Response?.StatusCode}, step 2: {step2.Response?.StatusCode}"; + } + }; + + yield return new SequenceTestCase + { + Id = "SMUG-EXPECT-100-CL-DESYNC", + Description = "Expect: 100-continue with immediate body followed by a second request — detects unread-body desync", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9110 §10.1.1", + RfcLevel = RfcLevel.NotApplicable, + Scored = false, + Expected = new ExpectedBehavior + { + Description = "417/400/close preferred; poisoned follow-up = warn" + }, + Steps = + [ + new SequenceStep + { + Label = "POST with Expect: 100-continue and body", + PayloadFactory = ctx => MakeRequest( + $"POST / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nContent-Length: 5\r\nExpect: 100-continue\r\n\r\nhello") + }, + new SequenceStep + { + Label = "Follow-up GET", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n") + } + ], + Validator = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (step1.Response?.StatusCode is 400 or 417) + return TestVerdict.Pass; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return TestVerdict.Pass; + if (step1.Response?.StatusCode is >= 200 and < 300) + { + if (!step2.Executed) + return TestVerdict.Pass; + if (step2.Response?.StatusCode == 400) + return TestVerdict.Warn; + if (step2.Response?.StatusCode is >= 200 and < 300) + return TestVerdict.Pass; + return TestVerdict.Warn; + } + return TestVerdict.Warn; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (step1.Response?.StatusCode is 400 or 417) + return $"Rejected Expect workflow with {step1.Response.StatusCode}"; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return "Connection closed after Expect request"; + if (!step2.Executed) + return $"Step 1 returned {step1.Response?.StatusCode}; connection then closed"; + if (step2.Response?.StatusCode == 400) + return $"Possible desync: follow-up GET returned 400 after Expect acceptance ({step1.Response?.StatusCode})"; + return $"Step 1: {step1.Response?.StatusCode}, step 2: {step2.Response?.StatusCode}"; + } + }; + + yield return new SequenceTestCase + { + Id = "SMUG-OPTIONS-TE-OBS-FOLD", + Description = "OPTIONS with TE obs-fold and CL present — server must reject or close after response", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9112 §5.2", + Expected = new ExpectedBehavior + { + Description = "400, or 2xx + close" + }, + Steps = + [ + new SequenceStep + { + Label = "OPTIONS with folded TE and CL", + PayloadFactory = ctx => MakeRequest( + $"OPTIONS / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nTransfer-Encoding:\r\n chunked\r\nContent-Length: 5\r\n\r\nhello") + }, + new SequenceStep + { + Label = "Follow-up GET", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n") + } + ], + Validator = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (step1.Response?.StatusCode == 400) + return TestVerdict.Pass; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return TestVerdict.Pass; + if (step1.Response?.StatusCode is >= 200 and < 300) + return !step2.Executed ? TestVerdict.Pass : TestVerdict.Fail; + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (step1.Response?.StatusCode == 400) + return "Rejected folded Transfer-Encoding request with 400"; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return "Connection closed after OPTIONS folded-TE request"; + if (step1.Response?.StatusCode is >= 200 and < 300) + { + if (!step2.Executed) + return $"Accepted with {step1.Response.StatusCode}, then closed connection"; + return $"Accepted with {step1.Response.StatusCode} but kept connection open — follow-up GET returned {step2.Response?.StatusCode.ToString() ?? "no response"}"; + } + return $"Unexpected response: {step1.Response?.StatusCode.ToString() ?? "none"}"; + } + }; + + yield return new SequenceTestCase + { + Id = "SMUG-CHUNK-INVALID-SIZE-DESYNC", + Description = "Invalid chunk size (+0) with poison byte — detects chunk-size parser desync", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9112 §7.1", + Expected = new ExpectedBehavior + { + Description = "400, or close" + }, + Steps = + [ + new SequenceStep + { + Label = "Poison POST with invalid chunk-size (+0)", + PayloadFactory = ctx => MakeRequest( + $"POST / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nTransfer-Encoding: chunked\r\n\r\n+0\r\n\r\nX") + }, + new SequenceStep + { + Label = "Follow-up GET", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n") + } + ], + Validator = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (step1.Response?.StatusCode == 400) + return TestVerdict.Pass; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return TestVerdict.Pass; + if (step1.Response?.StatusCode is >= 200 and < 300) + { + if (!step2.Executed) + return TestVerdict.Pass; + if (step2.Response?.StatusCode == 400) + return TestVerdict.Fail; + return TestVerdict.Fail; + } + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (step1.Response?.StatusCode == 400) + return "Rejected invalid chunk-size request with 400"; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return "Connection closed after invalid chunk-size request"; + if (step1.Response?.StatusCode is >= 200 and < 300) + { + if (!step2.Executed) + return $"Accepted with {step1.Response.StatusCode}, then closed connection"; + if (step2.Response?.StatusCode == 400) + return $"DESYNC: accepted invalid chunk-size request ({step1.Response.StatusCode}), follow-up GET parsed as poisoned request"; + return $"Accepted invalid chunk-size request and kept connection open — follow-up GET returned {step2.Response?.StatusCode.ToString() ?? "no response"}"; + } + return $"Unexpected response: {step1.Response?.StatusCode.ToString() ?? "none"}"; + } + }; + // ── PIPELINE-SAFE ─────────────────────────────────────────── // Baseline: two clean, unambiguous GET requests on one connection. // Validates that the server supports normal HTTP/1.1 pipelining.