From f44286d174db28eb9ee52d18b9cb4515fbd1f15d Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 16 Feb 2026 11:29:20 +0000 Subject: [PATCH] Add more sequence tests --- .gitignore | 4 + .../content/docs/rfc-requirement-dashboard.md | 51 +- docs/content/docs/smuggling/_index.md | 42 +- docs/content/docs/smuggling/cl-te-both.md | 8 +- docs/content/docs/smuggling/clte-desync.md | 2 +- docs/content/docs/smuggling/clte-keepalive.md | 64 -- .../clte-smuggled-get-cl-non-numeric.md | 60 ++ .../smuggling/clte-smuggled-get-cl-plus.md | 60 ++ .../clte-smuggled-get-te-case-mismatch.md | 54 ++ .../clte-smuggled-get-te-leading-comma.md | 54 ++ .../clte-smuggled-get-te-obs-fold.md | 64 ++ .../clte-smuggled-get-te-trailing-space.md | 54 ++ .../docs/smuggling/clte-smuggled-get.md | 61 ++ .../docs/smuggling/clte-smuggled-head.md | 54 ++ .../smuggling/duplicate-cl-smuggled-get.md | 59 ++ .../docs/smuggling/get-cl-prefix-desync.md | 60 ++ .../te-duplicate-headers-smuggled-get.md | 64 ++ .../docs/smuggling/tecl-smuggled-get.md | 65 ++ .../sequence-tests/smuggling/_index.md | 20 +- docs/content/smuggling/_index.md | 16 +- docs/static/probe/render.js | 33 +- scripts/probe-local.sh | 406 ++++++++ scripts/script.txt | 2 + src/Http11Probe/Runner/TestRunner.cs | 78 +- src/Http11Probe/TestCases/SequenceSendPart.cs | 12 + src/Http11Probe/TestCases/SequenceStep.cs | 6 +- .../TestCases/Suites/SmugglingSuite.cs | 864 ++++++++++++++++-- 27 files changed, 2125 insertions(+), 192 deletions(-) delete mode 100644 docs/content/docs/smuggling/clte-keepalive.md create mode 100644 docs/content/docs/smuggling/clte-smuggled-get-cl-non-numeric.md create mode 100644 docs/content/docs/smuggling/clte-smuggled-get-cl-plus.md create mode 100644 docs/content/docs/smuggling/clte-smuggled-get-te-case-mismatch.md create mode 100644 docs/content/docs/smuggling/clte-smuggled-get-te-leading-comma.md create mode 100644 docs/content/docs/smuggling/clte-smuggled-get-te-obs-fold.md create mode 100644 docs/content/docs/smuggling/clte-smuggled-get-te-trailing-space.md create mode 100644 docs/content/docs/smuggling/clte-smuggled-get.md create mode 100644 docs/content/docs/smuggling/clte-smuggled-head.md create mode 100644 docs/content/docs/smuggling/duplicate-cl-smuggled-get.md create mode 100644 docs/content/docs/smuggling/get-cl-prefix-desync.md create mode 100644 docs/content/docs/smuggling/te-duplicate-headers-smuggled-get.md create mode 100644 docs/content/docs/smuggling/tecl-smuggled-get.md create mode 100755 scripts/probe-local.sh create mode 100644 scripts/script.txt create mode 100644 src/Http11Probe/TestCases/SequenceSendPart.cs diff --git a/.gitignore b/.gitignore index 2dac362..090cd6e 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,7 @@ package-lock.json # Probe results (local testing) probe-*.json + +# Probe action outputs (local script) +probe-data.js +probe-comment.md diff --git a/docs/content/docs/rfc-requirement-dashboard.md b/docs/content/docs/rfc-requirement-dashboard.md index 71c62a5..47ee73e 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 183 Http11Probe tests" +description: "Complete RFC 2119 requirement-level analysis for all 194 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** | 103 | Absolute requirement — no compliant implementation may deviate | +| **MUST** | 113 | 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** | 29 | Informational — no pass/fail judgement | +| **Unscored** | 30 | Informational — no pass/fail judgement | | **N/A** | 11 | Best-practice / no single RFC verb applies | -**Total: 183 tests** +**Total: 194 tests** --- -## MUST-Level Requirements (103 tests) +## MUST-Level Requirements (113 tests) These tests enforce absolute RFC requirements. A compliant server has no discretion — it **MUST** behave as specified. @@ -96,14 +96,14 @@ The RFC requires rejection, but the mechanism (400 status or connection close) h | 53 | `SMUG-TE-XCHUNKED` | Smuggling | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | Unknown TE with CL present: "Regardless, the server **MUST** close the connection after responding to such a request." Combined with §6.1: "A server that receives a request message with a transfer coding it does not understand **SHOULD** respond with 501." | | 54 | `SMUG-CLTE-CONN-CLOSE` | Smuggling | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | Sequence test: CL+TE combined, then follow-up GET on same socket. "The server **MUST** close the connection after responding to such a request." If follow-up receives a response, MUST-close violated. | | 55 | `SMUG-TECL-CONN-CLOSE` | Smuggling | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | Same as CLTE-CONN-CLOSE with TE before CL header order. **MUST** close connection. | -| 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. | +| 58 | `SMUG-CLTE-SMUGGLED-GET` | Smuggling | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | CL.TE desync payload where the trailing bytes form a full `GET /` request. If the server returns multiple HTTP responses on one send, the embedded request was executed. "Regardless, the server **MUST** close the connection after responding to such a request." | +| 59 | `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. | +| 60 | `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. | +| 61 | `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. | +| 62 | `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. | +| 63 | `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." | +| 64 | `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. | @@ -142,6 +142,16 @@ The RFC requires rejection, but the mechanism (400 status or connection close) h | 87 | `COMP-DATE-HEADER` | Compliance | [RFC 9110 §6.6.1](https://www.rfc-editor.org/rfc/rfc9110#section-6.6.1) | "An origin server with a clock **MUST** generate a Date header field in all 2xx (Successful), 3xx (Redirection), and 4xx (Client Error) responses." | | 88 | `COMP-NO-1XX-HTTP10` | Compliance | [RFC 9110 §15.2](https://www.rfc-editor.org/rfc/rfc9110#section-15.2) | "Since HTTP/1.0 did not define any 1xx status codes, a server **MUST NOT** send a 1xx response to an HTTP/1.0 client." | | 89 | `COMP-NO-CL-IN-204` | Compliance | [RFC 9110 §8.6](https://www.rfc-editor.org/rfc/rfc9110#section-8.6) | "A server **MUST NOT** send a Content-Length header field in any response with a status code of 1xx (Informational) or 204 (No Content)." | +| 90 | `SMUG-CLTE-SMUGGLED-GET-CL-PLUS` | Smuggling | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | Variant of `SMUG-CLTE-SMUGGLED-GET` with `Content-Length: +N` (malformed CL) and `Transfer-Encoding: chunked`, embedding a full `GET /` in the body. "Regardless, the server **MUST** close the connection after responding to such a request." | +| 91 | `SMUG-CLTE-SMUGGLED-GET-CL-NON-NUMERIC` | Smuggling | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | Variant of `SMUG-CLTE-SMUGGLED-GET` with `Content-Length: N` (non-numeric suffix) and `Transfer-Encoding: chunked`, embedding a full `GET /` in the body. "Regardless, the server **MUST** close the connection after responding to such a request." | +| 92 | `SMUG-CLTE-SMUGGLED-GET-TE-OBS-FOLD` | Smuggling | [RFC 9112 §5.2](https://www.rfc-editor.org/rfc/rfc9112#section-5.2) | Variant of `SMUG-CLTE-SMUGGLED-GET` with obs-folded `Transfer-Encoding:\r\n chunked` plus `Content-Length`, embedding a full `GET /` in the body. "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 prior to interpreting the field value..." | +| 93 | `SMUG-CLTE-SMUGGLED-HEAD` | Smuggling | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | Embedded-request confirmation variant using a smuggled `HEAD /` request. "Regardless, the server **MUST** close the connection after responding to such a request." | +| 94 | `SMUG-CLTE-SMUGGLED-GET-TE-TRAILING-SPACE` | Smuggling | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | Variant of `SMUG-CLTE-SMUGGLED-GET` with `Transfer-Encoding: chunked␠` (trailing space) plus `Content-Length`. "Regardless, the server **MUST** close the connection after responding to such a request." | +| 95 | `SMUG-CLTE-SMUGGLED-GET-TE-LEADING-COMMA` | Smuggling | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | Variant of `SMUG-CLTE-SMUGGLED-GET` with `Transfer-Encoding: , chunked` plus `Content-Length`. "Regardless, the server **MUST** close the connection after responding to such a request." | +| 96 | `SMUG-CLTE-SMUGGLED-GET-TE-CASE-MISMATCH` | Smuggling | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | Variant of `SMUG-CLTE-SMUGGLED-GET` with `Transfer-Encoding: Chunked` (case mismatch) plus `Content-Length`. "Regardless, the server **MUST** close the connection after responding to such a request." | +| 97 | `SMUG-TE-DUPLICATE-HEADERS-SMUGGLED-GET` | Smuggling | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | Sequence confirmation variant using duplicate `Transfer-Encoding` header fields (`chunked` + `identity`) plus `Content-Length`, embedding a full `GET /` in the body. "Regardless, the server **MUST** close the connection after responding to such a request." | +| 98 | `SMUG-TECL-SMUGGLED-GET` | Smuggling | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | TE.CL confirmation using a chunk-size prefix trick: `Content-Length` covers only the chunk-size line, leaving chunk-data that begins with a `GET /` request. "Regardless, the server **MUST** close the connection after responding to such a request." | +| 99 | `SMUG-DUPLICATE-CL-SMUGGLED-GET` | Smuggling | [RFC 9110 §8.6](https://www.rfc-editor.org/rfc/rfc9110#section-8.6) | Sequence confirmation variant of duplicate `Content-Length` using an embedded `GET /` immediately after the shorter body's boundary. "If a message is received without Transfer-Encoding and with an invalid Content-Length header field... the recipient **MUST** treat it as an unrecoverable error." | --- @@ -212,7 +222,7 @@ Weaker than SHOULD — recommends but does not normatively require. --- -## Unscored Tests (29 tests) +## Unscored Tests (30 tests) These tests are informational — they produce warnings but never fail. @@ -247,6 +257,7 @@ These tests are informational — they produce warnings but never fail. | 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. | +| 30 | `SMUG-GET-CL-PREFIX-DESYNC` | Smuggling | [RFC 9110 §9.3.1](https://www.rfc-editor.org/rfc/rfc9110#section-9.3.1) | GET with a body containing an incomplete request prefix (missing the blank line). The follow-up write completes it and then sends a normal GET. If multiple responses are observed on step 2, the prefix bytes were likely left unread and executed. | --- @@ -282,15 +293,15 @@ These tests don't map to a single RFC 2119 keyword but enforce defensive best pr | Unscored | 7 | | N/A | 1 | -### Smuggling Suite (76 tests) +### Smuggling Suite (87 tests) | Level | Tests | |-------|-------| -| MUST | 44 | +| MUST | 54 | | SHOULD | 9 | | MAY | 3 | | "ought to" | 1 | -| Unscored | 19 | +| Unscored | 20 | ### Malformed Input Suite (26 tests) @@ -321,8 +332,8 @@ 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 | 3 | Obsolete line folding | -| RFC 9112 §6.1 | 21 | Transfer-Encoding, CL+TE ambiguity | +| RFC 9112 §5.2 | 4 | Obsolete line folding | +| RFC 9112 §6.1 | 29 | Transfer-Encoding, CL+TE ambiguity | | RFC 9112 §6.2 | 5 | Content-Length body framing | | RFC 9112 §6.3 | 5 | Message body length determination | | RFC 9112 §7.1 | 18 | Chunked transfer coding format | @@ -335,8 +346,8 @@ These tests don't map to a single RFC 2119 keyword but enforce defensive best pr | RFC 9110 §7.2 | 1 | Host header semantics | | 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 | 12 | Methods (GET, HEAD, CONNECT, OPTIONS, TRACE) | +| RFC 9110 §8.6 | 15 | Content-Length semantics | +| RFC 9110 §9.1-9.3 | 13 | 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) | diff --git a/docs/content/docs/smuggling/_index.md b/docs/content/docs/smuggling/_index.md index da41e97..a642936 100644 --- a/docs/content/docs/smuggling/_index.md +++ b/docs/content/docs/smuggling/_index.md @@ -64,14 +64,25 @@ For these, `400` is the strict/safe response and `2xx` is RFC-compliant. Http11P {{< card link="cl-leading-zeros" title="CL-LEADING-ZEROS" subtitle="Content-Length with leading zeros (007)." >}} {{< card link="cl-negative" title="CL-NEGATIVE" subtitle="Negative Content-Length value." >}} {{< card link="te-xchunked" title="TE-XCHUNKED" subtitle="Unknown TE 'xchunked' with CL present." >}} - {{< card link="te-trailing-space" title="TE-TRAILING-SPACE" subtitle="TE 'chunked ' with trailing space." >}} - {{< card link="te-sp-before-colon" title="TE-SP-BEFORE-COLON" subtitle="Space before colon in Transfer-Encoding." >}} - {{< card link="clte-pipeline" title="CLTE-PIPELINE" subtitle="Full CL.TE smuggling payload." >}} - {{< card link="tecl-pipeline" title="TECL-PIPELINE" subtitle="Full TE.CL smuggling payload." >}} - {{< card link="cl-comma-different" title="CL-COMMA-DIFFERENT" subtitle="Comma-separated CL with different values." >}} - {{< 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="te-trailing-space" title="TE-TRAILING-SPACE" subtitle="TE 'chunked ' with trailing space." >}} + {{< card link="te-sp-before-colon" title="TE-SP-BEFORE-COLON" subtitle="Space before colon in Transfer-Encoding." >}} + {{< card link="clte-pipeline" title="CLTE-PIPELINE" subtitle="Full CL.TE smuggling payload." >}} + {{< card link="tecl-pipeline" title="TECL-PIPELINE" subtitle="Full TE.CL smuggling payload." >}} + {{< card link="clte-smuggled-get" title="CLTE-SMUGGLED-GET" subtitle="CL.TE payload with embedded GET; multiple responses indicate request boundary confusion." >}} + {{< card link="clte-smuggled-head" title="CLTE-SMUGGLED-HEAD" subtitle="CL.TE payload with embedded HEAD; multiple responses indicate request boundary confusion." >}} + {{< card link="tecl-smuggled-get" title="TECL-SMUGGLED-GET" subtitle="TE.CL payload (chunk-size prefix trick); multiple responses indicate request boundary confusion." >}} + {{< card link="te-duplicate-headers-smuggled-get" title="TE-DUPLICATE-HEADERS-SMUGGLED-GET" subtitle="Duplicate TE headers + CL with embedded GET; multiple responses indicate request boundary confusion." >}} + {{< card link="duplicate-cl-smuggled-get" title="DUPLICATE-CL-SMUGGLED-GET" subtitle="Duplicate Content-Length + embedded GET; multiple responses indicate request boundary confusion." >}} + {{< card link="clte-smuggled-get-te-trailing-space" title="CLTE-SMUGGLED-GET-TE-TRAILING-SPACE" subtitle="CL.TE smuggled GET with TE trailing space." >}} + {{< card link="clte-smuggled-get-te-leading-comma" title="CLTE-SMUGGLED-GET-TE-LEADING-COMMA" subtitle="CL.TE smuggled GET with TE leading comma." >}} + {{< card link="clte-smuggled-get-te-case-mismatch" title="CLTE-SMUGGLED-GET-TE-CASE-MISMATCH" subtitle="CL.TE smuggled GET with TE case mismatch." >}} + {{< card link="clte-smuggled-get-cl-plus" title="CLTE-SMUGGLED-GET-CL-PLUS" subtitle="CL.TE smuggled GET with malformed Content-Length (+N)." >}} + {{< card link="clte-smuggled-get-cl-non-numeric" title="CLTE-SMUGGLED-GET-CL-NON-NUMERIC" subtitle="CL.TE smuggled GET with non-numeric Content-Length (N)." >}} + {{< card link="clte-smuggled-get-te-obs-fold" title="CLTE-SMUGGLED-GET-TE-OBS-FOLD" subtitle="CL.TE smuggled GET with obs-folded Transfer-Encoding." >}} + {{< card link="cl-comma-different" title="CL-COMMA-DIFFERENT" subtitle="Comma-separated CL with different values." >}} + {{< 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." >}} @@ -127,11 +138,12 @@ For these, `400` is the strict/safe response and `2xx` is RFC-compliant. Http11P {{< 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." >}} + {{< 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="get-cl-prefix-desync" title="GET-CL-PREFIX-DESYNC" subtitle="GET with incomplete request-prefix body; follow-up completes it to detect unread-body desync." >}} + {{< 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/cl-te-both.md b/docs/content/docs/smuggling/cl-te-both.md index 5262988..bb709c3 100644 --- a/docs/content/docs/smuggling/cl-te-both.md +++ b/docs/content/docs/smuggling/cl-te-both.md @@ -19,9 +19,11 @@ A request with both `Content-Length` and `Transfer-Encoding` headers present. ```http POST / HTTP/1.1\r\n Host: localhost:8080\r\n -Content-Length: 6\r\n +Content-Length: 23\r\n Transfer-Encoding: chunked\r\n \r\n +D\r\n +hello-bananas\r\n 0\r\n \r\n ``` @@ -54,7 +56,7 @@ This is **the** classic request smuggling setup. If the front-end uses Content-L ### ABNF Analysis -This test is not about an ABNF grammar violation. Both `Content-Length: 6` and `Transfer-Encoding: chunked` are individually valid headers. The issue is their simultaneous presence in the same message, which the RFC treats as a conflicting-framing condition. +This test is not about an ABNF grammar violation. Both `Content-Length: 23` and `Transfer-Encoding: chunked` are individually valid headers. The issue is their simultaneous presence in the same message, which the RFC treats as a conflicting-framing condition. ### RFC Evidence Chain @@ -82,7 +84,7 @@ This test is scored as **"ought to" handle as error** (Pass for 400, Warn for 2x ### Real-World Smuggling Scenario -This is the original CL.TE / TE.CL smuggling attack described by Watchfire in 2005 and popularized by PortSwigger in 2019. In a CL.TE attack, the front-end proxy uses `Content-Length: 6` to determine body length and forwards 6 bytes. The back-end uses `Transfer-Encoding: chunked` and parses `0\r\n\r\n` (5 bytes) as an empty chunked body, leaving 1 byte unconsumed. In the reverse (TE.CL), the front-end uses chunked encoding and the back-end uses Content-Length, with the opposite byte mismatch. Either way, leftover bytes on the connection are parsed as the start of the next request. This single test case represents the most widely exploited class of HTTP request smuggling vulnerabilities. The RFC's MUST-close-connection requirement exists specifically to prevent connection reuse after this ambiguity. +This is the classic CL.TE / TE.CL smuggling setup described by Watchfire (2005) and popularized by PortSwigger (2019). If different components disagree on whether to use `Content-Length` or `Transfer-Encoding`, they can disagree on request body boundaries or request body content. In real-world smuggling exploits, the attacker typically chooses a conflicting `Content-Length` such that one parser stops early and the remaining bytes are interpreted as the start of the next request on a persistent connection. This test uses a non-empty chunked body (`hello-bananas`) to make it obvious whether an echo-capable server decoded chunked framing (TE) or treated the raw chunked bytes as the body (CL). ## Sources diff --git a/docs/content/docs/smuggling/clte-desync.md b/docs/content/docs/smuggling/clte-desync.md index 2b1bf73..192f4a1 100644 --- a/docs/content/docs/smuggling/clte-desync.md +++ b/docs/content/docs/smuggling/clte-desync.md @@ -56,7 +56,7 @@ The only safe outcomes are rejection (400) or closing the connection. Any other ## Why it matters -This test detects **actual request smuggling**, not just RFC non-compliance. If the poison byte `X` merges with the follow-up GET, the server's request boundary parsing is broken. In a real proxy chain, an attacker could replace `X` with a complete smuggled request. +This test detects request **desynchronization on a single server**, not a full proxy-chain exploit. If the poison byte `X` merges with the follow-up GET, the server's request boundary parsing is broken. In a real proxy chain, this class of bug is what enables request smuggling. ## Verdicts diff --git a/docs/content/docs/smuggling/clte-keepalive.md b/docs/content/docs/smuggling/clte-keepalive.md deleted file mode 100644 index 301d18f..0000000 --- a/docs/content/docs/smuggling/clte-keepalive.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: "CLTE-KEEPALIVE" -description: "CLTE-KEEPALIVE sequence test documentation" -weight: 12 ---- - -| | | -|---|---| -| **Test ID** | `SMUG-CLTE-KEEPALIVE` | -| **Category** | Smuggling | -| **Type** | Sequence (2 steps) | -| **Scored** | Yes | -| **RFC** | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | -| **RFC Level** | MUST | -| **Expected** | `400`, or `2xx` + connection close | - -## What it does - -This is a **sequence test** that verifies the MUST-close requirement still applies even when the client explicitly requests a persistent connection. - -### Step 1: Ambiguous POST with keep-alive - -```http -POST / HTTP/1.1\r\n -Host: localhost:8080\r\n -Connection: keep-alive\r\n -Content-Length: 5\r\n -Transfer-Encoding: chunked\r\n -\r\n -0\r\n -\r\n -``` - -A POST with both `Content-Length: 5` and `Transfer-Encoding: chunked`, plus an explicit `Connection: keep-alive` header pressuring the server to maintain the connection. - -### Step 2: Follow-up GET - -```http -GET / HTTP/1.1\r\n -Host: localhost:8080\r\n -\r\n -``` - -A normal GET sent on the same connection. This step only executes if the connection is still open after step 1. - -## What the RFC says - -> "**Regardless, the server MUST close the connection after responding to such a request** to avoid the potential attacks." — RFC 9112 §6.1 - -The word "regardless" means the MUST-close requirement overrides any `Connection: keep-alive` request from the client. The server has no choice — it must close. - -## Why it matters - -This is the most tempting edge case for servers to get wrong. A server that correctly detects the CL+TE conflict might still honor the client's `keep-alive` request instead of closing. This test specifically targets that logic path. - -## Verdicts - -- **Pass** — Server returns `400` (rejected outright), OR returns `2xx` and closes the connection despite `keep-alive` (step 2 never executes) -- **Fail** — Server returns `2xx` and honors `keep-alive`, keeping the connection open (step 2 executes and gets a response) - -## Sources - -- [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) -- [RFC 9112 §9.3](https://www.rfc-editor.org/rfc/rfc9112#section-9.3) diff --git a/docs/content/docs/smuggling/clte-smuggled-get-cl-non-numeric.md b/docs/content/docs/smuggling/clte-smuggled-get-cl-non-numeric.md new file mode 100644 index 0000000..9f51d3f --- /dev/null +++ b/docs/content/docs/smuggling/clte-smuggled-get-cl-non-numeric.md @@ -0,0 +1,60 @@ +--- +title: "CLTE-SMUGGLED-GET-CL-NON-NUMERIC" +description: "CLTE-SMUGGLED-GET-CL-NON-NUMERIC sequence test documentation" +weight: 16 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-CLTE-SMUGGLED-GET-CL-NON-NUMERIC` | +| **Category** | Smuggling | +| **Type** | Sequence (single send) | +| **Scored** | Yes | +| **RFC** | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | +| **RFC Level** | MUST | +| **Expected** | `400`, or connection close | + +## What it does + +This is a variant of `SMUG-CLTE-SMUGGLED-GET` that uses a non-numeric `Content-Length` value (`N`) while also sending `Transfer-Encoding: chunked`. + +Some HTTP stacks reject non-numeric Content-Length outright; others parse a numeric prefix and ignore the trailing junk. In a proxy chain, this can create framing disagreements that enable request smuggling. + +## What it sends + +The request body begins with a valid chunked terminator (`0\r\n\r\n`) and then immediately contains an entire `GET /` request. + +```http +POST / HTTP/1.1\r\n +Host: localhost:8080\r\n +Content-Length: 45x\r\n +Transfer-Encoding: chunked\r\n +\r\n +0\r\n +\r\n +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +\r\n +``` + +## What the RFC says + +> "A server MAY reject a request that contains both Content-Length and Transfer-Encoding... **Regardless, the server MUST close the connection after responding to such a request.**" — RFC 9112 §6.1 + +Closing the connection after responding prevents any leftover bytes (including an embedded request) from being interpreted as a second request on the same persistent connection. + +## Why it matters + +Numeric-prefix parsing (`45x` parsed as `45`) is a frequent hardening gap. If one hop reads 45 bytes while another treats the value as invalid, their views of the byte stream diverge and the embedded `GET /` can be executed out of band. + +This test checks for smuggling by looking for **multiple HTTP status lines** (multiple responses) after a single client send. + +## Verdicts + +- **Pass**: The server rejects with `400`, or closes the connection. +- **Fail**: Multiple HTTP responses are observed (embedded GET likely executed). +- **Fail**: The server accepts and keeps the connection open (MUST-close violated), even if no extra response is observed. + +## Sources + +- [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) diff --git a/docs/content/docs/smuggling/clte-smuggled-get-cl-plus.md b/docs/content/docs/smuggling/clte-smuggled-get-cl-plus.md new file mode 100644 index 0000000..8ef0818 --- /dev/null +++ b/docs/content/docs/smuggling/clte-smuggled-get-cl-plus.md @@ -0,0 +1,60 @@ +--- +title: "CLTE-SMUGGLED-GET-CL-PLUS" +description: "CLTE-SMUGGLED-GET-CL-PLUS sequence test documentation" +weight: 15 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-CLTE-SMUGGLED-GET-CL-PLUS` | +| **Category** | Smuggling | +| **Type** | Sequence (single send) | +| **Scored** | Yes | +| **RFC** | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | +| **RFC Level** | MUST | +| **Expected** | `400`, or connection close | + +## What it does + +This is a variant of `SMUG-CLTE-SMUGGLED-GET` that uses a malformed `Content-Length` header (`+N`) while also sending `Transfer-Encoding: chunked`. + +Some HTTP stacks reject `Content-Length: +N` as invalid; others parse it leniently. In a proxy chain, these disagreements can reintroduce classic CL.TE smuggling. + +## What it sends + +The request body begins with a valid chunked terminator (`0\r\n\r\n`) and then immediately contains an entire `GET /` request. + +```http +POST / HTTP/1.1\r\n +Host: localhost:8080\r\n +Content-Length: +45\r\n +Transfer-Encoding: chunked\r\n +\r\n +0\r\n +\r\n +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +\r\n +``` + +## What the RFC says + +> "A server MAY reject a request that contains both Content-Length and Transfer-Encoding... **Regardless, the server MUST close the connection after responding to such a request.**" — RFC 9112 §6.1 + +Even if a server chooses to accept and process the message, it must close the connection afterward to prevent request boundary confusion and smuggling. + +## Why it matters + +Malformed framing headers are a common source of front-end/back-end parsing disagreements. If one hop accepts `Content-Length: +N` while another rejects it (or ignores it in favor of chunked framing), the embedded `GET /` can be interpreted as a separate request. + +This test looks for concrete evidence of request-boundary confusion by checking whether the server emits **multiple HTTP status lines** (multiple responses) after a single client send. In a proxy chain, this same behavior is what enables request smuggling. + +## Verdicts + +- **Pass**: The server rejects with `400`, or closes the connection. +- **Fail**: Multiple HTTP responses are observed (embedded GET likely executed). +- **Fail**: The server accepts and keeps the connection open (MUST-close violated), even if no extra response is observed. + +## Sources + +- [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) diff --git a/docs/content/docs/smuggling/clte-smuggled-get-te-case-mismatch.md b/docs/content/docs/smuggling/clte-smuggled-get-te-case-mismatch.md new file mode 100644 index 0000000..1f4af53 --- /dev/null +++ b/docs/content/docs/smuggling/clte-smuggled-get-te-case-mismatch.md @@ -0,0 +1,54 @@ +--- +title: "CLTE-SMUGGLED-GET-TE-CASE-MISMATCH" +description: "CLTE-SMUGGLED-GET-TE-CASE-MISMATCH sequence test documentation" +weight: 21 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-CLTE-SMUGGLED-GET-TE-CASE-MISMATCH` | +| **Category** | Smuggling | +| **Type** | Sequence (single send) | +| **Scored** | Yes | +| **RFC** | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | +| **RFC Level** | MUST | +| **Expected** | `400`, or connection close | + +## What it does + +This is a variant of `SMUG-CLTE-SMUGGLED-GET` where the `Transfer-Encoding` token is written as `Chunked` (case mismatch). + +Some components are case-insensitive as required by the HTTP token rules; others are not. Any case-sensitivity bug in a proxy chain can reintroduce CL.TE framing disagreement. + +## What it sends + +```http +POST / HTTP/1.1\r\n +Host: localhost:8080\r\n +Content-Length: 45\r\n +Transfer-Encoding: Chunked\r\n +\r\n +0\r\n +\r\n +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +\r\n +``` + +## What the RFC says + +> "A server MAY reject a request that contains both Content-Length and Transfer-Encoding... **Regardless, the server MUST close the connection after responding to such a request.**" — RFC 9112 §6.1 + +## Why it matters + +Transfer-coding tokens are a classic source of normalization differences. If one hop treats `Chunked` as `chunked` and another treats it as unknown, message framing can diverge and the embedded `GET` can be processed as a separate request. + +## Verdicts + +- **Pass**: The server rejects with `400`, or closes the connection. +- **Fail**: Multiple HTTP responses are observed (embedded GET likely executed). +- **Fail**: The server accepts and keeps the connection open (MUST-close violated), even if no extra response is observed. + +## Sources + +- [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) diff --git a/docs/content/docs/smuggling/clte-smuggled-get-te-leading-comma.md b/docs/content/docs/smuggling/clte-smuggled-get-te-leading-comma.md new file mode 100644 index 0000000..5446456 --- /dev/null +++ b/docs/content/docs/smuggling/clte-smuggled-get-te-leading-comma.md @@ -0,0 +1,54 @@ +--- +title: "CLTE-SMUGGLED-GET-TE-LEADING-COMMA" +description: "CLTE-SMUGGLED-GET-TE-LEADING-COMMA sequence test documentation" +weight: 20 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-CLTE-SMUGGLED-GET-TE-LEADING-COMMA` | +| **Category** | Smuggling | +| **Type** | Sequence (single send) | +| **Scored** | Yes | +| **RFC** | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | +| **RFC Level** | MUST | +| **Expected** | `400`, or connection close | + +## What it does + +This is a variant of `SMUG-CLTE-SMUGGLED-GET` where the `Transfer-Encoding` field value contains a **leading comma** (`", chunked"`). + +Some parsers ignore empty list elements and treat this as equivalent to `chunked`; others reject it or ignore the header. That discrepancy can enable request smuggling in multi-hop deployments. + +## What it sends + +```http +POST / HTTP/1.1\r\n +Host: localhost:8080\r\n +Content-Length: 45\r\n +Transfer-Encoding: , chunked\r\n +\r\n +0\r\n +\r\n +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +\r\n +``` + +## What the RFC says + +> "A server MAY reject a request that contains both Content-Length and Transfer-Encoding... **Regardless, the server MUST close the connection after responding to such a request.**" — RFC 9112 §6.1 + +## Why it matters + +Comma-list parsing differences are a recurring source of TE normalization bugs. If one hop sees TE as valid and another does not, the embedded `GET` can be interpreted as a separate request by one side. + +## Verdicts + +- **Pass**: The server rejects with `400`, or closes the connection. +- **Fail**: Multiple HTTP responses are observed (embedded GET likely executed). +- **Fail**: The server accepts and keeps the connection open (MUST-close violated), even if no extra response is observed. + +## Sources + +- [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) diff --git a/docs/content/docs/smuggling/clte-smuggled-get-te-obs-fold.md b/docs/content/docs/smuggling/clte-smuggled-get-te-obs-fold.md new file mode 100644 index 0000000..e935258 --- /dev/null +++ b/docs/content/docs/smuggling/clte-smuggled-get-te-obs-fold.md @@ -0,0 +1,64 @@ +--- +title: "CLTE-SMUGGLED-GET-TE-OBS-FOLD" +description: "CLTE-SMUGGLED-GET-TE-OBS-FOLD sequence test documentation" +weight: 17 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-CLTE-SMUGGLED-GET-TE-OBS-FOLD` | +| **Category** | Smuggling | +| **Type** | Sequence (single send) | +| **Scored** | Yes | +| **RFC** | [RFC 9112 §5.2](https://www.rfc-editor.org/rfc/rfc9112#section-5.2) · [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | +| **RFC Level** | MUST | +| **Expected** | `400`, or connection close | + +## What it does + +This is a variant of `SMUG-CLTE-SMUGGLED-GET` that uses **obs-fold** (obsolete line folding) on the `Transfer-Encoding` header while also sending `Content-Length`. + +If a server unfolds obs-fold into `Transfer-Encoding: chunked` and then fails to close the connection, the embedded `GET /` can be interpreted as a second request and the server may emit multiple HTTP responses. + +## What it sends + +Transfer-Encoding is split across two lines using obs-fold: + +```http +POST / HTTP/1.1\r\n +Host: localhost:8080\r\n +Transfer-Encoding:\r\n + chunked\r\n +Content-Length: 45\r\n +\r\n +0\r\n +\r\n +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 prior to interpreting the field value..." — RFC 9112 §5.2 + +If unfolded, the message still contains both `Transfer-Encoding` and `Content-Length`, triggering connection safety requirements: + +> "**Regardless, the server MUST close the connection after responding** to such a request." — RFC 9112 §6.1 + +## Why it matters + +Obs-fold is a well-known parsing differential: some components unfold it, others treat it as malformed. When it is applied to `Transfer-Encoding` with `Content-Length` present, that disagreement is directly usable as a CL.TE smuggling vector. + +This test checks for concrete evidence of request-boundary confusion by looking for **multiple HTTP status lines** (multiple responses) after a single client send. In a proxy chain, this same behavior is what enables request smuggling. + +## Verdicts + +- **Pass**: The server rejects with `400`, or closes the connection. +- **Fail**: Multiple HTTP responses are observed (embedded GET likely executed). +- **Fail**: The server accepts and keeps the connection open (MUST-close violated), even if no extra response is observed. + +## Sources + +- [RFC 9112 §5.2](https://www.rfc-editor.org/rfc/rfc9112#section-5.2) +- [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) diff --git a/docs/content/docs/smuggling/clte-smuggled-get-te-trailing-space.md b/docs/content/docs/smuggling/clte-smuggled-get-te-trailing-space.md new file mode 100644 index 0000000..3129048 --- /dev/null +++ b/docs/content/docs/smuggling/clte-smuggled-get-te-trailing-space.md @@ -0,0 +1,54 @@ +--- +title: "CLTE-SMUGGLED-GET-TE-TRAILING-SPACE" +description: "CLTE-SMUGGLED-GET-TE-TRAILING-SPACE sequence test documentation" +weight: 19 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-CLTE-SMUGGLED-GET-TE-TRAILING-SPACE` | +| **Category** | Smuggling | +| **Type** | Sequence (single send) | +| **Scored** | Yes | +| **RFC** | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | +| **RFC Level** | MUST | +| **Expected** | `400`, or connection close | + +## What it does + +This is a variant of `SMUG-CLTE-SMUGGLED-GET` where the `Transfer-Encoding` value includes a **trailing space** (`chunked␠`). + +Some components treat this as invalid and fall back to `Content-Length`; others trim and treat it as `chunked`. In a multi-hop chain, that parsing differential can enable CL.TE request smuggling. + +## What it sends + +```http +POST / HTTP/1.1\r\n +Host: localhost:8080\r\n +Content-Length: 45\r\n +Transfer-Encoding: chunked \r\n +\r\n +0\r\n +\r\n +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +\r\n +``` + +## What the RFC says + +> "A server MAY reject a request that contains both Content-Length and Transfer-Encoding... **Regardless, the server MUST close the connection after responding to such a request.**" — RFC 9112 §6.1 + +## Why it matters + +TE value normalization is a common hardening gap. If one hop trims and another does not, the chain can disagree about message framing and interpret the embedded `GET` as a second request. + +## Verdicts + +- **Pass**: The server rejects with `400`, or closes the connection. +- **Fail**: Multiple HTTP responses are observed (embedded GET likely executed). +- **Fail**: The server accepts and keeps the connection open (MUST-close violated), even if no extra response is observed. + +## Sources + +- [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) diff --git a/docs/content/docs/smuggling/clte-smuggled-get.md b/docs/content/docs/smuggling/clte-smuggled-get.md new file mode 100644 index 0000000..48abf3c --- /dev/null +++ b/docs/content/docs/smuggling/clte-smuggled-get.md @@ -0,0 +1,61 @@ +--- +title: "CLTE-SMUGGLED-GET" +description: "CLTE-SMUGGLED-GET sequence test documentation" +weight: 14 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-CLTE-SMUGGLED-GET` | +| **Category** | Smuggling | +| **Type** | Sequence (single send) | +| **Scored** | Yes | +| **RFC** | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | +| **RFC Level** | MUST | +| **Expected** | `400`, or connection close | + +## What it does + +This test is the "real" version of `SMUG-CLTE-DESYNC`: instead of smuggling a single poison byte (`X`), it smuggles a complete `GET /` request into the ambiguous body. + +If a server accepts an ambiguous `Content-Length` + `Transfer-Encoding: chunked` request and keeps the connection open, it risks parsing the embedded `GET /` as a second request and sending **two HTTP responses** on one connection. + +## What it sends + +The request body begins with a valid chunked terminator (`0\r\n\r\n`) and then immediately contains an entire `GET /` request. + +```http +POST / HTTP/1.1\r\n +Host: localhost:8080\r\n +Content-Length: 45\r\n +Transfer-Encoding: chunked\r\n +\r\n +0\r\n +\r\n +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +\r\n +``` + +## What the RFC says + +> "A server MAY reject a request that contains both Content-Length and Transfer-Encoding... **Regardless, the server MUST close the connection after responding to such a request.**" — RFC 9112 §6.1 + +This rule exists specifically to prevent request smuggling and desynchronization when different HTTP processors disagree about message boundaries. + +## Why it matters + +In a real proxy chain, if a front-end uses `Content-Length` while a back-end uses `Transfer-Encoding: chunked`, the embedded `GET /` can be treated as a separate request by the back-end and "smuggled" past the front-end's routing and security checks. + +This test looks for concrete evidence of request-boundary confusion by checking whether the server emits **multiple HTTP status lines** (multiple responses) after a single client send. In a proxy chain, this same behavior is what enables request smuggling. + +## Verdicts + +- **Pass**: The server rejects with `400`, or closes the connection. +- **Fail**: Multiple HTTP responses are observed (embedded GET likely executed). +- **Fail**: The server accepts and keeps the connection open (MUST-close violated), even if no extra response is observed. + +## Sources + +- [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) +- [RFC 9112 §11.2](https://www.rfc-editor.org/rfc/rfc9112#section-11.2) diff --git a/docs/content/docs/smuggling/clte-smuggled-head.md b/docs/content/docs/smuggling/clte-smuggled-head.md new file mode 100644 index 0000000..f3d6415 --- /dev/null +++ b/docs/content/docs/smuggling/clte-smuggled-head.md @@ -0,0 +1,54 @@ +--- +title: "CLTE-SMUGGLED-HEAD" +description: "CLTE-SMUGGLED-HEAD sequence test documentation" +weight: 18 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-CLTE-SMUGGLED-HEAD` | +| **Category** | Smuggling | +| **Type** | Sequence (single send) | +| **Scored** | Yes | +| **RFC** | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | +| **RFC Level** | MUST | +| **Expected** | `400`, or connection close | + +## What it does + +This is a `HEAD`-based confirmation variant of `SMUG-CLTE-SMUGGLED-GET`. + +It sends an ambiguous `Content-Length` + `Transfer-Encoding: chunked` request whose body contains a complete smuggled `HEAD /` request. If the server parses the body bytes as a second request, it may emit **multiple HTTP responses** after a single client send. + +## What it sends + +```http +POST / HTTP/1.1\r\n +Host: localhost:8080\r\n +Content-Length: 46\r\n +Transfer-Encoding: chunked\r\n +\r\n +0\r\n +\r\n +HEAD / HTTP/1.1\r\n +Host: localhost:8080\r\n +\r\n +``` + +## What the RFC says + +> "A server MAY reject a request that contains both Content-Length and Transfer-Encoding... **Regardless, the server MUST close the connection after responding to such a request.**" — RFC 9112 §6.1 + +## Why it matters + +The ambiguity is the same as classic CL.TE smuggling. Using `HEAD` as the embedded request helps confirm tunneling/smuggling behavior in stacks where response bodies are suppressed or buffered differently. + +## Verdicts + +- **Pass**: The server rejects with `400`, or closes the connection. +- **Fail**: Multiple HTTP responses are observed (embedded `HEAD` likely executed). +- **Fail**: The server accepts and keeps the connection open (MUST-close violated), even if no extra response is observed. + +## Sources + +- [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) diff --git a/docs/content/docs/smuggling/duplicate-cl-smuggled-get.md b/docs/content/docs/smuggling/duplicate-cl-smuggled-get.md new file mode 100644 index 0000000..40205cc --- /dev/null +++ b/docs/content/docs/smuggling/duplicate-cl-smuggled-get.md @@ -0,0 +1,59 @@ +--- +title: "DUPLICATE-CL-SMUGGLED-GET" +description: "DUPLICATE-CL-SMUGGLED-GET sequence test documentation" +weight: 24 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-DUPLICATE-CL-SMUGGLED-GET` | +| **Category** | Smuggling | +| **Type** | Sequence (single send) | +| **Scored** | Yes | +| **RFC** | [RFC 9110 §8.6](https://www.rfc-editor.org/rfc/rfc9110#section-8.6) | +| **RFC Level** | MUST | +| **Expected** | `400`, or connection close | + +## What it does + +This is a CL.CL smuggling confirmation variant of `SMUG-DUPLICATE-CL`. + +It sends two different `Content-Length` header fields and includes an embedded `GET /` request immediately after the shorter body's boundary. If a server selects the shorter Content-Length value, the embedded `GET` can be interpreted as the next request on the connection and cause multiple responses to be emitted. + +## What it sends + +```http +POST / HTTP/1.1\r\n +Host: localhost:8080\r\n +Content-Length: 4\r\n +Content-Length: 49\r\n +\r\n +PINGGET / HTTP/1.1\r\n +Host: localhost:8080\r\n +\r\n +``` + +(Actual `Content-Length: 49` is computed to match the body bytes.) + +## What the RFC says + +RFC 9110 defines `Content-Length` as a single decimal length. Conflicting Content-Length header fields make framing invalid: + +> "If a message is received without Transfer-Encoding and with an invalid Content-Length header field, then the message framing is invalid and the recipient MUST treat it as an unrecoverable error." -- RFC 9110 §8.6 + +## Why it matters + +CL.CL disagreement is a classic request smuggling vector when two HTTP processors choose different Content-Length values (for example, first vs last header). If the connection stays open, the leftover bytes can become a smuggled request. + +This test looks for concrete evidence of request-boundary confusion by detecting multiple HTTP status lines in the response to a single client send. In a proxy chain, this same behavior is what enables request smuggling. + +## Verdicts + +- **Pass**: The server rejects with `400`, or closes the connection. +- **Fail**: Multiple HTTP status lines are observed (embedded `GET` likely executed). +- **Fail**: The server accepts and keeps the connection open (duplicate CL not rejected). + +## Sources + +- [RFC 9110 §8.6](https://www.rfc-editor.org/rfc/rfc9110#section-8.6) +- [HTTP Request Smuggling (PortSwigger)](https://portswigger.net/web-security/request-smuggling) diff --git a/docs/content/docs/smuggling/get-cl-prefix-desync.md b/docs/content/docs/smuggling/get-cl-prefix-desync.md new file mode 100644 index 0000000..ad29b15 --- /dev/null +++ b/docs/content/docs/smuggling/get-cl-prefix-desync.md @@ -0,0 +1,60 @@ +--- +title: "GET-CL-PREFIX-DESYNC" +description: "GET-CL-PREFIX-DESYNC sequence test documentation" +weight: 62 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-GET-CL-PREFIX-DESYNC` | +| **Category** | Smuggling | +| **Type** | Sequence (2 steps) | +| **Scored** | No | +| **RFC** | [RFC 9110 §9.3.1](https://www.rfc-editor.org/rfc/rfc9110#section-9.3.1) | +| **RFC Level** | MAY | +| **Expected** | `400/close` preferred; extra response on step 2 = warn | + +## What it does + +Step 1 sends a `GET` with a `Content-Length` body containing an **incomplete** request prefix (it intentionally omits the blank line that ends the header section). Step 2 begins with a blank line to complete that prefix and then sends a normal `GET`. + +If the server fails to fully consume the GET body from step 1, the prefix can remain on the connection. Step 2 can then "complete" it, causing the leftover bytes to be interpreted as a real request. + +## What it sends + +Step 1: + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +Content-Length: 31\r\n +\r\n +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +``` + +Step 2: + +```http +\r\n +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +\r\n +``` + +(Actual `Content-Length` is computed to match the prefix bytes.) + +## Why it matters + +RFC 9110 notes that content 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." Even if a server chooses to accept such a request, it must ensure it stays synchronized by consuming or discarding the body bytes. + +This test is unscored because GET-with-body handling is not uniformly defined across deployments, but a desync signal is still valuable telemetry. + +## Verdicts + +- **Pass**: The server rejects step 1 with `400`, or closes the connection. +- **Warn**: Step 2 yields multiple HTTP status lines (leftover prefix likely executed), or other evidence of desynchronization. + +## Sources + +- [RFC 9110 §9.3.1](https://www.rfc-editor.org/rfc/rfc9110#section-9.3.1) diff --git a/docs/content/docs/smuggling/te-duplicate-headers-smuggled-get.md b/docs/content/docs/smuggling/te-duplicate-headers-smuggled-get.md new file mode 100644 index 0000000..16c0ab5 --- /dev/null +++ b/docs/content/docs/smuggling/te-duplicate-headers-smuggled-get.md @@ -0,0 +1,64 @@ +--- +title: "TE-DUPLICATE-HEADERS-SMUGGLED-GET" +description: "TE-DUPLICATE-HEADERS-SMUGGLED-GET sequence test documentation" +weight: 22 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-TE-DUPLICATE-HEADERS-SMUGGLED-GET` | +| **Category** | Smuggling | +| **Type** | Sequence (single send) | +| **Scored** | Yes | +| **RFC** | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | +| **RFC Level** | MUST | +| **Expected** | `400`, or connection close | + +## What it does + +This is a TE.TE + CL ambiguity variant of `SMUG-CLTE-SMUGGLED-GET`. + +It sends two `Transfer-Encoding` header fields with different values (`chunked` and `identity`) plus a `Content-Length`, and embeds a full `GET /` request after the chunked terminator. If the server keeps the connection reusable and the embedded request is executed, the probe will observe multiple HTTP status lines after a single client send. + +## What it sends + +```http +POST / HTTP/1.1\r\n +Host: localhost:8080\r\n +Transfer-Encoding: chunked\r\n +Transfer-Encoding: identity\r\n +Content-Length: 45\r\n +\r\n +0\r\n +\r\n +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +\r\n +``` + +(Actual `Content-Length` is computed to match the body bytes.) + +## What the RFC says + +> "A server MAY reject a request that contains both Content-Length and Transfer-Encoding... Regardless, the server MUST close the connection after responding to such a request." -- RFC 9112 §6.1 + +## Why it matters + +Request smuggling often relies on parsing disagreements about: + +- whether duplicate TE header fields are merged, rejected, or one is ignored +- whether CL is honored in the presence of TE +- whether the connection is kept open after an ambiguous request + +This test looks for concrete evidence of request-boundary confusion by detecting multiple HTTP responses produced from a single client send. In a proxy chain, this same behavior is what enables request smuggling. + +## Verdicts + +- **Pass**: The server rejects with `400`, or closes the connection. +- **Fail**: Multiple HTTP status lines are observed (embedded `GET` likely executed). +- **Fail**: The server accepts and keeps the connection open (MUST-close violated), even if no extra response is observed. + +## Sources + +- [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) +- [HTTP Request Smuggling (PortSwigger)](https://portswigger.net/web-security/request-smuggling) diff --git a/docs/content/docs/smuggling/tecl-smuggled-get.md b/docs/content/docs/smuggling/tecl-smuggled-get.md new file mode 100644 index 0000000..16295a3 --- /dev/null +++ b/docs/content/docs/smuggling/tecl-smuggled-get.md @@ -0,0 +1,65 @@ +--- +title: "TECL-SMUGGLED-GET" +description: "TECL-SMUGGLED-GET sequence test documentation" +weight: 23 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-TECL-SMUGGLED-GET` | +| **Category** | Smuggling | +| **Type** | Sequence (single send) | +| **Scored** | Yes | +| **RFC** | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | +| **RFC Level** | MUST | +| **Expected** | `400`, or connection close | + +## What it does + +This test is a TE.CL smuggling confirmation technique inspired by common request smuggling labs. + +The body begins with a valid chunk-size line (for the chunked framing), but the `Content-Length` is set to only cover the chunk-size prefix (the `{hex}\r\n` line). If a server incorrectly uses `Content-Length` framing in the presence of `Transfer-Encoding: chunked`, it can leave the chunk-data bytes on the wire and interpret them as the next request. + +To make the signal cleaner, the smuggled `GET` includes `Content-Length: 7` so the remaining chunked framing bytes (`\r\n0\r\n\r\n`) get consumed as the smuggled request body if it is parsed as a second request. + +## What it sends + +```http +POST / HTTP/1.1\r\n +Host: localhost:8080\r\n +Transfer-Encoding: chunked\r\n +Content-Length: 4\r\n +\r\n +2b\r\n +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +Content-Length: 7\r\n +\r\n +\r\n +0\r\n +\r\n +``` + +(`2b` and `Content-Length: 4` are examples; the probe computes the exact chunk size and the corresponding prefix length.) + +## What the RFC says + +> "A server MAY reject a request that contains both Content-Length and Transfer-Encoding... Regardless, the server MUST close the connection after responding to such a request." -- RFC 9112 §6.1 + +## Why it matters + +In real deployments, TE.CL smuggling happens when one hop uses chunked framing and another hop uses Content-Length framing. If the connection is left open, leftover bytes can be interpreted as a new request and smuggled past security controls. + +This test looks for concrete evidence of request-boundary confusion by detecting multiple HTTP status lines in the response to a single client send. In a proxy chain, this same behavior is what enables request smuggling. + +## Verdicts + +- **Pass**: The server rejects with `400`, or closes the connection. +- **Fail**: Multiple HTTP status lines are observed (smuggled `GET` likely executed). +- **Fail**: The server accepts and keeps the connection open (MUST-close violated), even if no extra response is observed. + +## Sources + +- [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) +- [HTTP Request Smuggling (PortSwigger)](https://portswigger.net/web-security/request-smuggling) +- [Request smuggling (PortSwigger labs)](https://portswigger.net/web-security/request-smuggling/lab-basic-te-cl) diff --git a/docs/content/sequence-tests/smuggling/_index.md b/docs/content/sequence-tests/smuggling/_index.md index 3da0952..d7af6a8 100644 --- a/docs/content/sequence-tests/smuggling/_index.md +++ b/docs/content/sequence-tests/smuggling/_index.md @@ -31,12 +31,25 @@ These tests send multiple requests on the same TCP connection to detect desynchr return; } var GROUPS = [ - { key: 'conn-close', label: 'Connection Close Requirements', testIds: [ - 'SMUG-CLTE-CONN-CLOSE','SMUG-TECL-CONN-CLOSE','SMUG-CLTE-KEEPALIVE' - ]}, + { key: 'conn-close', label: 'Connection Close Requirements', testIds: [ + 'SMUG-CLTE-CONN-CLOSE','SMUG-TECL-CONN-CLOSE' + ]}, { key: 'baselines', label: 'Baseline Desync Detection', testIds: [ 'SMUG-CLTE-DESYNC','SMUG-TECL-DESYNC','SMUG-PIPELINE-SAFE' + ]}, + { key: 'confirm', label: 'Embedded Request Execution Signals', testIds: [ + 'SMUG-CLTE-SMUGGLED-GET','SMUG-CLTE-SMUGGLED-HEAD', + 'SMUG-TECL-SMUGGLED-GET','SMUG-TE-DUPLICATE-HEADERS-SMUGGLED-GET','SMUG-DUPLICATE-CL-SMUGGLED-GET' + ]}, + { key: 'obf-te', label: 'Obfuscated Transfer-Encoding Variants', testIds: [ + 'SMUG-CLTE-SMUGGLED-GET-TE-TRAILING-SPACE','SMUG-CLTE-SMUGGLED-GET-TE-LEADING-COMMA','SMUG-CLTE-SMUGGLED-GET-TE-CASE-MISMATCH' + ]}, + { key: 'malformed', label: 'Malformed CL/TE Smuggling Variants', testIds: [ + 'SMUG-CLTE-SMUGGLED-GET-CL-PLUS','SMUG-CLTE-SMUGGLED-GET-CL-NON-NUMERIC','SMUG-CLTE-SMUGGLED-GET-TE-OBS-FOLD' ]}, + { key: 'cl-body', label: 'Ignored Body / Unread-Body Desync', testIds: [ + 'SMUG-GET-CL-PREFIX-DESYNC' + ]}, { key: 'vectors', label: 'Real-World Desync Vectors', testIds: [ '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' @@ -66,4 +79,3 @@ These tests send multiple requests on the same TCP connection to detect desynchr ProbeRender.renderRfcLevelFilter('rfc-level-filter', catData, function (l) { rfcLevelFilter = l; rerender(); }); })(); - diff --git a/docs/content/smuggling/_index.md b/docs/content/smuggling/_index.md index eae91ab..2a8d35e 100644 --- a/docs/content/smuggling/_index.md +++ b/docs/content/smuggling/_index.md @@ -40,12 +40,16 @@ 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 SEQUENCE_IDS = [ + 'SMUG-CLTE-CONN-CLOSE','SMUG-TECL-CONN-CLOSE', + 'SMUG-CLTE-DESYNC','SMUG-CLTE-SMUGGLED-GET','SMUG-TECL-DESYNC','SMUG-PIPELINE-SAFE', + 'SMUG-CLTE-SMUGGLED-GET-CL-PLUS','SMUG-CLTE-SMUGGLED-GET-CL-NON-NUMERIC','SMUG-CLTE-SMUGGLED-GET-TE-OBS-FOLD', + 'SMUG-CLTE-SMUGGLED-HEAD','SMUG-CLTE-SMUGGLED-GET-TE-TRAILING-SPACE','SMUG-CLTE-SMUGGLED-GET-TE-LEADING-COMMA', + 'SMUG-CLTE-SMUGGLED-GET-TE-CASE-MISMATCH','SMUG-TECL-SMUGGLED-GET','SMUG-TE-DUPLICATE-HEADERS-SMUGGLED-GET', + 'SMUG-DUPLICATE-CL-SMUGGLED-GET','SMUG-GET-CL-PREFIX-DESYNC', + '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-PIPELINE','SMUG-TECL-PIPELINE','SMUG-TE-HTTP10', diff --git a/docs/static/probe/render.js b/docs/static/probe/render.js index 2937af7..3fe44b5 100644 --- a/docs/static/probe/render.js +++ b/docs/static/probe/render.js @@ -357,14 +357,30 @@ window.ProbeRender = (function () { 'SMUG-CL-INTERNAL-SPACE': '/Http11Probe/docs/smuggling/cl-internal-space/', 'SMUG-CL-OCTAL': '/Http11Probe/docs/smuggling/cl-octal/', 'SMUG-CL-TRAILING-SPACE': '/Http11Probe/docs/smuggling/cl-trailing-space/', - 'SMUG-CLTE-CONN-CLOSE': '/Http11Probe/docs/smuggling/clte-conn-close/', - 'SMUG-CLTE-DESYNC': '/Http11Probe/docs/smuggling/clte-desync/', - 'SMUG-CLTE-KEEPALIVE': '/Http11Probe/docs/smuggling/clte-keepalive/', - 'SMUG-CLTE-PIPELINE': '/Http11Probe/docs/smuggling/clte-pipeline/', - 'SMUG-EXPECT-100-CL': '/Http11Probe/docs/smuggling/expect-100-cl/', - 'SMUG-HEAD-CL-BODY': '/Http11Probe/docs/smuggling/head-cl-body/', - 'SMUG-OPTIONS-CL-BODY': '/Http11Probe/docs/smuggling/options-cl-body/', - 'SMUG-TE-CASE-MISMATCH': '/Http11Probe/docs/smuggling/te-case-mismatch/', + 'SMUG-CLTE-CONN-CLOSE': '/Http11Probe/docs/smuggling/clte-conn-close/', + 'SMUG-CLTE-DESYNC': '/Http11Probe/docs/smuggling/clte-desync/', + 'SMUG-CLTE-SMUGGLED-GET': '/Http11Probe/docs/smuggling/clte-smuggled-get/', + 'SMUG-CLTE-SMUGGLED-HEAD': '/Http11Probe/docs/smuggling/clte-smuggled-head/', + 'SMUG-TECL-SMUGGLED-GET': '/Http11Probe/docs/smuggling/tecl-smuggled-get/', + 'SMUG-TE-DUPLICATE-HEADERS-SMUGGLED-GET': '/Http11Probe/docs/smuggling/te-duplicate-headers-smuggled-get/', + 'SMUG-DUPLICATE-CL-SMUGGLED-GET': '/Http11Probe/docs/smuggling/duplicate-cl-smuggled-get/', + 'SMUG-CLTE-SMUGGLED-GET-CL-PLUS': '/Http11Probe/docs/smuggling/clte-smuggled-get-cl-plus/', + 'SMUG-CLTE-SMUGGLED-GET-CL-NON-NUMERIC': '/Http11Probe/docs/smuggling/clte-smuggled-get-cl-non-numeric/', + 'SMUG-CLTE-SMUGGLED-GET-TE-OBS-FOLD': '/Http11Probe/docs/smuggling/clte-smuggled-get-te-obs-fold/', + 'SMUG-CLTE-SMUGGLED-GET-TE-TRAILING-SPACE': '/Http11Probe/docs/smuggling/clte-smuggled-get-te-trailing-space/', + 'SMUG-CLTE-SMUGGLED-GET-TE-LEADING-COMMA': '/Http11Probe/docs/smuggling/clte-smuggled-get-te-leading-comma/', + 'SMUG-CLTE-SMUGGLED-GET-TE-CASE-MISMATCH': '/Http11Probe/docs/smuggling/clte-smuggled-get-te-case-mismatch/', + 'SMUG-CLTE-PIPELINE': '/Http11Probe/docs/smuggling/clte-pipeline/', + 'SMUG-CL0-BODY-POISON': '/Http11Probe/docs/smuggling/cl0-body-poison/', + 'SMUG-EXPECT-100-CL': '/Http11Probe/docs/smuggling/expect-100-cl/', + 'SMUG-EXPECT-100-CL-DESYNC': '/Http11Probe/docs/smuggling/expect-100-cl-desync/', + 'SMUG-GET-CL-BODY-DESYNC': '/Http11Probe/docs/smuggling/get-cl-body-desync/', + 'SMUG-GET-CL-PREFIX-DESYNC': '/Http11Probe/docs/smuggling/get-cl-prefix-desync/', + 'SMUG-HEAD-CL-BODY': '/Http11Probe/docs/smuggling/head-cl-body/', + 'SMUG-OPTIONS-CL-BODY': '/Http11Probe/docs/smuggling/options-cl-body/', + 'SMUG-OPTIONS-CL-BODY-DESYNC': '/Http11Probe/docs/smuggling/options-cl-body-desync/', + 'SMUG-OPTIONS-TE-OBS-FOLD': '/Http11Probe/docs/smuggling/options-te-obs-fold/', + 'SMUG-TE-CASE-MISMATCH': '/Http11Probe/docs/smuggling/te-case-mismatch/', 'SMUG-TE-DOUBLE-CHUNKED': '/Http11Probe/docs/smuggling/te-double-chunked/', 'SMUG-TE-DUPLICATE-HEADERS': '/Http11Probe/docs/smuggling/te-duplicate-headers/', 'SMUG-TE-EMPTY-VALUE': '/Http11Probe/docs/smuggling/te-empty-value/', @@ -413,6 +429,7 @@ window.ProbeRender = (function () { 'MAL-URL-PERCENT-NULL': '/Http11Probe/docs/malformed-input/url-percent-null/', 'SMUG-ABSOLUTE-URI-HOST-MISMATCH': '/Http11Probe/docs/smuggling/absolute-uri-host-mismatch/', 'SMUG-CHUNK-BARE-CR-TERM': '/Http11Probe/docs/smuggling/chunk-bare-cr-term/', + 'SMUG-CHUNK-INVALID-SIZE-DESYNC': '/Http11Probe/docs/smuggling/chunk-invalid-size-desync/', 'SMUG-CL-DOUBLE-ZERO': '/Http11Probe/docs/smuggling/cl-double-zero/', 'SMUG-CL-LEADING-ZEROS-OCTAL': '/Http11Probe/docs/smuggling/cl-leading-zeros-octal/', 'SMUG-CL-NEGATIVE-ZERO': '/Http11Probe/docs/smuggling/cl-negative-zero/', diff --git a/scripts/probe-local.sh b/scripts/probe-local.sh new file mode 100755 index 0000000..5f395a5 --- /dev/null +++ b/scripts/probe-local.sh @@ -0,0 +1,406 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + scripts/probe-local.sh --all + scripts/probe-local.sh --server + +Options: + --all Run against all servers under src/Servers/*/probe.json + --server Run against a single server directory (e.g. NginxServer) + --port Target port (default: 8080) + --skip-build Skip 'dotnet build' (assumes Release build already exists) + --verbose Pass --verbose to the CLI (still writes JSON output) + --docker-sudo Run docker commands via sudo (lets you run the script without sudo) + -h, --help Show help + +This mirrors the GitHub Actions workflow in .github/workflows/probe.yml and produces: + - probe-.json (one per server) + - probe-data.js (window.PROBE_DATA = ...) + - docs/static/probe/data.js (copied from probe-data.js for local Hugo rendering) + +Environment: + DOTNET=/path/to/dotnet Override which dotnet binary to use (useful if PATH points to an older SDK). +EOF +} + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" >&2 + exit 1 + fi +} + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +REPO_USER="" +if [[ "$(id -u)" -eq 0 && -n "${SUDO_USER:-}" && "${SUDO_USER:-}" != "root" ]]; then + # If invoked via sudo, run dotnet + file writes as the original user to avoid picking up + # a different (often older) dotnet install, and to keep generated files user-owned. + REPO_USER="$SUDO_USER" +fi + +run_as_repo_user() { + if [[ -n "$REPO_USER" ]]; then + sudo -u "$REPO_USER" -H "$@" + else + "$@" + fi +} + +MODE="" +SINGLE_SERVER="" +PROBE_PORT=8080 +SKIP_BUILD=0 +VERBOSE=0 +DOCKER_SUDO=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --all) + MODE="all" + shift + ;; + --server) + MODE="single" + SINGLE_SERVER="${2:-}" + shift 2 + ;; + --port) + PROBE_PORT="${2:-}" + shift 2 + ;; + --skip-build) + SKIP_BUILD=1 + shift + ;; + --verbose) + VERBOSE=1 + shift + ;; + --docker-sudo) + DOCKER_SUDO=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 2 + ;; + esac +done + +if [[ -z "$MODE" ]]; then + echo "Must specify either --all or --server ." >&2 + usage + exit 2 +fi + +require_cmd jq +require_cmd docker +require_cmd base64 +require_cmd curl +require_cmd python3 + +docker_cmd() { + if [[ "$(id -u)" -eq 0 ]]; then + docker "$@" + elif [[ "$DOCKER_SUDO" -eq 1 ]]; then + sudo docker "$@" + else + docker "$@" + fi +} + +DOTNET_BIN="" +if [[ -n "${DOTNET:-}" ]]; then + DOTNET_BIN="$DOTNET" +elif [[ -n "$REPO_USER" ]]; then + require_cmd sudo + DOTNET_BIN="$(sudo -u "$REPO_USER" -H bash -lc 'source ~/.profile >/dev/null 2>&1 || true; source ~/.bashrc >/dev/null 2>&1 || true; command -v dotnet' 2>/dev/null || true)" +fi +if [[ -z "$DOTNET_BIN" ]]; then + DOTNET_BIN="$(command -v dotnet 2>/dev/null || true)" +fi +if [[ -z "$DOTNET_BIN" ]]; then + echo "Missing required command: dotnet" >&2 + exit 1 +fi +if [[ ! -x "$DOTNET_BIN" ]]; then + echo "DOTNET='$DOTNET_BIN' is not executable." >&2 + exit 1 +fi + +dotnet_cmd() { + if [[ -n "$REPO_USER" ]]; then + sudo -u "$REPO_USER" -H "$DOTNET_BIN" "$@" + else + "$DOTNET_BIN" "$@" + fi +} + +DOTNET_VERSION="$(dotnet_cmd --version 2>/dev/null || true)" +if [[ -z "$DOTNET_VERSION" ]]; then + echo "dotnet is installed but not usable." >&2 + exit 1 +fi +DOTNET_MAJOR="${DOTNET_VERSION%%.*}" +if [[ "$DOTNET_MAJOR" -lt 10 ]]; then + echo "dotnet $DOTNET_VERSION detected; Http11Probe expects dotnet 10.x." >&2 + if [[ -n "$REPO_USER" ]]; then + echo "Hint: this script is running dotnet as '$REPO_USER' (from sudo). Install/activate dotnet 10 for that user." >&2 + else + echo "Hint: run 'dotnet --version' and ensure it reports 10.x." >&2 + fi + exit 1 +fi + +if [[ "$DOCKER_SUDO" -eq 1 && "$(id -u)" -ne 0 ]]; then + require_cmd sudo +fi + +if ! docker_cmd info >/dev/null 2>&1; then + echo "Docker is installed but not usable (cannot connect to the Docker daemon)." >&2 + echo "Fix: ensure you can run 'docker ps' (or re-run with --docker-sudo), and that the daemon is running." >&2 + exit 1 +fi + +cleanup() { + docker_cmd rm -f probe-target >/dev/null 2>&1 || true +} +trap cleanup EXIT + +if [[ "$SKIP_BUILD" -eq 0 ]]; then + dotnet_cmd build src/Http11Probe.Cli/Http11Probe.Cli.csproj -c Release +fi + +SERVERS='[]' + +if [[ "$MODE" == "single" ]]; then + if [[ -z "$SINGLE_SERVER" ]]; then + echo "--server requires a value (e.g. NginxServer)." >&2 + exit 2 + fi + if [[ ! -f "src/Servers/$SINGLE_SERVER/probe.json" ]]; then + echo "Server '$SINGLE_SERVER' not found (expected src/Servers/$SINGLE_SERVER/probe.json)." >&2 + echo "Available servers:" >&2 + ls src/Servers >&2 + exit 1 + fi + name="$(jq -r .name "src/Servers/$SINGLE_SERVER/probe.json")" + lang="$(jq -r '.language // ""' "src/Servers/$SINGLE_SERVER/probe.json")" + SERVERS="$(jq -c -n --arg d "$SINGLE_SERVER" --arg n "$name" --arg l "$lang" '[{"dir":$d,"name":$n,"language":$l}]')" +else + 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 +fi + +echo "Servers:" +echo "$SERVERS" | jq -r '.[].name' | sed 's/^/ - /' +echo + +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 "== $name ==" + + docker_cmd build -t "$tag" -f "src/Servers/$dir/Dockerfile" . + + cleanup + docker_cmd run -d --name probe-target --network host "$tag" >/dev/null + + # Wait up to 30s for server readiness. + ready=0 + for _ in $(seq 1 30); do + if curl -sf "http://localhost:${PROBE_PORT}/" >/dev/null 2>&1; then + ready=1 + break + fi + sleep 1 + done + if [[ "$ready" -ne 1 ]]; then + echo " WARN: server did not respond on http://localhost:${PROBE_PORT}/ after 30s" >&2 + fi + + cli_args=(--host localhost --port "$PROBE_PORT" --output "probe-${dir}.json") + if [[ "$VERBOSE" -eq 1 ]]; then + cli_args+=(--verbose) + fi + + dotnet_cmd run --no-build -c Release --project src/Http11Probe.Cli -- "${cli_args[@]}" || true + + cleanup + echo +done + +if [[ -n "$REPO_USER" ]]; then + sudo -u "$REPO_USER" -H env PROBE_SERVERS="$SERVERS" python3 <<'PYEOF' +import json, sys, os, subprocess, pathlib + +def evaluate(raw): + results = [] + for r in raw['results']: + status = r.get('statusCode') + conn = r.get('connectionState', '') + got = str(status) if status is not None else conn + expected = r.get('expected', '?') + verdict = r['verdict'] + scored = r.get('scored', True) + reason = r['description'] + if verdict == 'Fail': + reason = f"Expected {expected}, got {got} — {reason}" + + results.append({ + 'id': r['id'], 'description': r['description'], + 'category': r['category'], 'rfc': r.get('rfcReference'), + 'verdict': verdict, 'statusCode': status, + 'expected': expected, 'got': got, + 'connectionState': conn, 'reason': reason, + 'scored': scored, + 'rfcLevel': r.get('rfcLevel', 'Must'), + 'durationMs': r.get('durationMs', 0), + 'rawRequest': r.get('rawRequest'), + 'rawResponse': r.get('rawResponse'), + 'behavioralNote': r.get('behavioralNote'), + 'doubleFlush': r.get('doubleFlush'), + }) + + scored_results = [r for r in results if r['scored']] + scored_pass = sum(1 for r in scored_results if r['verdict'] == 'Pass') + scored_fail = sum(1 for r in scored_results if r['verdict'] == 'Fail') + scored_warn = sum(1 for r in scored_results if r['verdict'] == 'Warn') + unscored = sum(1 for r in results if not r['scored']) + return { + 'summary': {'total': len(results), 'scored': len(scored_results), 'passed': scored_pass, 'failed': scored_fail, 'warnings': scored_warn, 'unscored': unscored}, + 'results': results, + } + +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) + +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) + ';') +PYEOF +else + env PROBE_SERVERS="$SERVERS" python3 <<'PYEOF' +import json, sys, os, subprocess, pathlib + +def evaluate(raw): + results = [] + for r in raw['results']: + status = r.get('statusCode') + conn = r.get('connectionState', '') + got = str(status) if status is not None else conn + expected = r.get('expected', '?') + verdict = r['verdict'] + scored = r.get('scored', True) + reason = r['description'] + if verdict == 'Fail': + reason = f"Expected {expected}, got {got} — {reason}" + + results.append({ + 'id': r['id'], 'description': r['description'], + 'category': r['category'], 'rfc': r.get('rfcReference'), + 'verdict': verdict, 'statusCode': status, + 'expected': expected, 'got': got, + 'connectionState': conn, 'reason': reason, + 'scored': scored, + 'rfcLevel': r.get('rfcLevel', 'Must'), + 'durationMs': r.get('durationMs', 0), + 'rawRequest': r.get('rawRequest'), + 'rawResponse': r.get('rawResponse'), + 'behavioralNote': r.get('behavioralNote'), + 'doubleFlush': r.get('doubleFlush'), + }) + + scored_results = [r for r in results if r['scored']] + scored_pass = sum(1 for r in scored_results if r['verdict'] == 'Pass') + scored_fail = sum(1 for r in scored_results if r['verdict'] == 'Fail') + scored_warn = sum(1 for r in scored_results if r['verdict'] == 'Warn') + unscored = sum(1 for r in results if not r['scored']) + return { + 'summary': {'total': len(results), 'scored': len(scored_results), 'passed': scored_pass, 'failed': scored_fail, 'warnings': scored_warn, 'unscored': unscored}, + 'results': results, + } + +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) + +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) + ';') +PYEOF +fi + +run_as_repo_user mkdir -p docs/static/probe +run_as_repo_user cp probe-data.js docs/static/probe/data.js + +echo "Wrote:" +echo " - probe-data.js" +echo " - docs/static/probe/data.js" diff --git a/scripts/script.txt b/scripts/script.txt new file mode 100644 index 0000000..3484b89 --- /dev/null +++ b/scripts/script.txt @@ -0,0 +1,2 @@ +./scripts/probe-local.sh --all --docker-sudo + diff --git a/src/Http11Probe/Runner/TestRunner.cs b/src/Http11Probe/Runner/TestRunner.cs index 99eb0a5..7296325 100644 --- a/src/Http11Probe/Runner/TestRunner.cs +++ b/src/Http11Probe/Runner/TestRunner.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Net.Sockets; using System.Text; using Http11Probe.Client; using Http11Probe.Response; @@ -174,13 +175,80 @@ private async Task RunSequenceAsync(SequenceTestCase seq, TestContex continue; } - var payload = step.PayloadFactory(context); - var rawReq = payload.Length > 8192 - ? Encoding.ASCII.GetString(payload, 0, 8192) + "\n\n[Truncated]" - : Encoding.ASCII.GetString(payload); + var parts = step.SendPartsFactory?.Invoke(context); + if (parts is null) + { + if (step.PayloadFactory is null) + throw new InvalidOperationException($"Sequence step '{label}' has no payload factory."); + + parts = [new SequenceSendPart { PayloadFactory = step.PayloadFactory }]; + } + + var partPayloads = new List<(byte[] Bytes, TimeSpan DelayAfter, string? PartLabel)>(); + foreach (var part in parts) + { + var bytes = part.PayloadFactory(context); + partPayloads.Add((bytes, part.DelayAfter, part.Label)); + } + + string rawReq; + if (partPayloads.Count == 1 && partPayloads[0].DelayAfter == TimeSpan.Zero && string.IsNullOrWhiteSpace(partPayloads[0].PartLabel)) + { + var payload = partPayloads[0].Bytes; + rawReq = payload.Length > 8192 + ? Encoding.ASCII.GetString(payload, 0, 8192) + "\n\n[Truncated]" + : Encoding.ASCII.GetString(payload); + } + else + { + var sb = new StringBuilder(); + for (var pi = 0; pi < partPayloads.Count; pi++) + { + var (bytes, delayAfter, partLabel) = partPayloads[pi]; + var partHeader = partLabel is null ? $"Part {pi + 1}" : $"Part {pi + 1} — {partLabel}"; + var rawPart = bytes.Length > 8192 + ? Encoding.ASCII.GetString(bytes, 0, 8192) + "\n\n[Truncated]" + : Encoding.ASCII.GetString(bytes); + + sb.AppendLine($"[{partHeader}]"); + sb.AppendLine(rawPart); + + if (delayAfter > TimeSpan.Zero) + sb.AppendLine($"[Pause {delayAfter.TotalMilliseconds:0} ms]"); + } + + rawReq = sb.ToString().TrimEnd('\r', '\n'); + } rawRequestParts.Add($"── {label} ──\n{rawReq}"); - await client.SendAsync(payload); + foreach (var (bytes, delayAfter, _) in partPayloads) + { + try + { + await client.SendAsync(bytes); + } + catch (SocketException) + { + connectionState = ConnectionState.ClosedByServer; + break; + } + catch + { + connectionState = ConnectionState.Error; + break; + } + + if (delayAfter > TimeSpan.Zero) + { + await Task.Delay(delayAfter); + + if (client.CheckConnectionState() != ConnectionState.Open) + { + connectionState = ConnectionState.ClosedByServer; + break; + } + } + } var (data, length, readState, drain) = await client.ReadResponseAsync(); var response = ResponseParser.TryParse(data.AsSpan(), length); diff --git a/src/Http11Probe/TestCases/SequenceSendPart.cs b/src/Http11Probe/TestCases/SequenceSendPart.cs new file mode 100644 index 0000000..bf85bd0 --- /dev/null +++ b/src/Http11Probe/TestCases/SequenceSendPart.cs @@ -0,0 +1,12 @@ +namespace Http11Probe.TestCases; + +public sealed class SequenceSendPart +{ + public required Func PayloadFactory { get; init; } + + public TimeSpan DelayAfter { get; init; } = TimeSpan.Zero; + + // Optional label used for display in the raw request view. + public string? Label { get; init; } +} + diff --git a/src/Http11Probe/TestCases/SequenceStep.cs b/src/Http11Probe/TestCases/SequenceStep.cs index 6ea2306..a358bc5 100644 --- a/src/Http11Probe/TestCases/SequenceStep.cs +++ b/src/Http11Probe/TestCases/SequenceStep.cs @@ -2,6 +2,10 @@ namespace Http11Probe.TestCases; public sealed class SequenceStep { - public required Func PayloadFactory { get; init; } + // One-shot send (existing behavior) + public Func? PayloadFactory { get; init; } + + // Multi-part send with optional delays between parts (used for pause-based desync / partial sends). + public Func>? SendPartsFactory { get; init; } public string? Label { get; init; } } diff --git a/src/Http11Probe/TestCases/Suites/SmugglingSuite.cs b/src/Http11Probe/TestCases/Suites/SmugglingSuite.cs index 5ae7cc3..ab05ca6 100644 --- a/src/Http11Probe/TestCases/Suites/SmugglingSuite.cs +++ b/src/Http11Probe/TestCases/Suites/SmugglingSuite.cs @@ -19,8 +19,9 @@ public static class SmugglingSuite if (r is null || r.StatusCode is < 200 or >= 300) return null; var body = (r.Body ?? "").TrimEnd('\r', '\n'); if (IsStaticResponse(body)) return StaticNote; - if (body.Length == 0) return "Used TE (chunked 0-length → empty body)"; - if (body.Contains("0\r\n\r\n") || body == "0\r\n\r") return "Used CL (read 6 raw bytes including chunk terminator)"; + if (body == "hello-bananas") return "Used TE (decoded chunked body)"; + if (body.StartsWith("D\r\nhello-bananas", StringComparison.Ordinal)) return "Used CL (read raw chunked framing as body)"; + if (body.Length == 0) return "Empty body (server consumed no body)"; return $"Body: {Truncate(body)}"; } @@ -51,6 +52,42 @@ public static class SmugglingSuite private static string Truncate(string s) => s.Length > 40 ? s[..40] + "..." : s; + private static bool IsDigit(char c) => c is >= '0' and <= '9'; + + // Detect multiple HTTP responses in a single read buffer (e.g., when embedded request bytes are parsed as a second request). + // We intentionally match the status line prefix pattern rather than "HTTP/" anywhere to avoid + // false-positives from echoed request bodies like "GET / HTTP/1.1". + private static int CountHttpStatusLines(string raw) + { + if (string.IsNullOrEmpty(raw)) return 0; + + var count = 0; + var idx = 0; + + while (true) + { + idx = raw.IndexOf("HTTP/", idx, StringComparison.Ordinal); + if (idx < 0) break; + + // Minimal pattern: HTTP/x.y SSS + if (idx + 12 <= raw.Length + && IsDigit(raw[idx + 5]) + && raw[idx + 6] == '.' + && IsDigit(raw[idx + 7]) + && raw[idx + 8] == ' ' + && IsDigit(raw[idx + 9]) + && IsDigit(raw[idx + 10]) + && IsDigit(raw[idx + 11])) + { + count++; + } + + idx += 5; + } + + return count; + } + public static IEnumerable GetTestCases() { yield return new TestCase @@ -60,8 +97,14 @@ public static IEnumerable GetTestCases() Category = TestCategory.Smuggling, RfcLevel = RfcLevel.OughtTo, RfcReference = "RFC 9112 §6.1", - PayloadFactory = ctx => MakeRequest( - $"POST / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nContent-Length: 6\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n"), + PayloadFactory = ctx => + { + // Non-empty chunked body so echo-capable servers reveal whether they decoded TE or used CL. + const string chunkedBody = "D\r\nhello-bananas\r\n0\r\n\r\n"; + var cl = Encoding.ASCII.GetByteCount(chunkedBody); + return MakeRequest( + $"POST / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nContent-Length: {cl}\r\nTransfer-Encoding: chunked\r\n\r\n{chunkedBody}"); + }, BehavioralAnalyzer = AnalyzeClTeBoth, Expected = new ExpectedBehavior { @@ -1596,66 +1639,6 @@ public static IEnumerable GetSequenceTestCases() } }; - // ── CLTE-KEEPALIVE ────────────────────────────────────────── - // CL+TE conflict with explicit Connection: keep-alive. - // MUST-close overrides the keep-alive request. - yield return new SequenceTestCase - { - Id = "SMUG-CLTE-KEEPALIVE", - Description = "CL+TE conflict with Connection: keep-alive — MUST-close still applies", - Category = TestCategory.Smuggling, - RfcReference = "RFC 9112 §6.1", - Expected = new ExpectedBehavior - { - Description = "400, or 2xx + close" - }, - Steps = - [ - new SequenceStep - { - Label = "Ambiguous POST (CL+TE+keep-alive)", - PayloadFactory = ctx => MakeRequest( - $"POST / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nConnection: keep-alive\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n") - }, - 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 ambiguous request with 400"; - if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) - return "Connection closed — safe (MUST-close honored despite keep-alive)"; - if (step1.Response?.StatusCode is >= 200 and < 300) - { - if (!step2.Executed) - return $"Accepted with {step1.Response.StatusCode}, then closed connection (MUST-close honored despite keep-alive)"; - return $"Accepted with {step1.Response.StatusCode} and honored keep-alive — MUST-close violated"; - } - return $"Unexpected response: {step1.Response?.StatusCode.ToString() ?? "none"}"; - } - }; - // ── CLTE-DESYNC ───────────────────────────────────────────── // Classic CL.TE desync: CL declares a small body (6 bytes), // but the chunked stream includes extra data after CL's boundary. @@ -1678,7 +1661,7 @@ public static IEnumerable GetSequenceTestCases() yield return new SequenceTestCase { Id = "SMUG-CLTE-DESYNC", - Description = "CL.TE desync — leftover bytes after CL boundary may become a smuggled request", + Description = "CL.TE desync — leftover bytes after the body boundary may be interpreted as the next request", Category = TestCategory.Smuggling, RfcReference = "RFC 9112 §6.1", Expected = new ExpectedBehavior @@ -1753,6 +1736,757 @@ public static IEnumerable GetSequenceTestCases() } }; + // ── CLTE-SMUGGLED-GET ──────────────────────────────────────── + // Same root cause as CLTE-DESYNC, but the "poison" is a full HTTP request. + // If the server uses TE framing and fails to close the connection, it may execute + // the embedded GET as a second request and send two responses back-to-back. + yield return new SequenceTestCase + { + Id = "SMUG-CLTE-SMUGGLED-GET", + Description = "CL.TE desync — embedded GET in body; multiple responses indicate request boundary confusion", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9112 §6.1", + Expected = new ExpectedBehavior + { + Description = "400, or close (no extra response)" + }, + Steps = + [ + new SequenceStep + { + Label = "Poison POST with embedded GET", + PayloadFactory = ctx => + { + var smuggled = $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n"; + var body = "0\r\n\r\n" + smuggled; + var cl = Encoding.ASCII.GetByteCount(body); + return MakeRequest( + $"POST / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nContent-Length: {cl}\r\nTransfer-Encoding: chunked\r\n\r\n{body}"); + } + } + ], + Validator = steps => + { + var step1 = steps[0]; + var statusLines = CountHttpStatusLines(step1.Response?.RawResponse ?? ""); + + // Multiple status lines in one read means the server executed a second request + // that the client never "sent" as a separate message. + if (statusLines >= 2) + return TestVerdict.Fail; + + if (step1.Response?.StatusCode == 400) + return TestVerdict.Pass; + + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return TestVerdict.Pass; + + // Server accepted an ambiguous CL+TE request and kept the connection open. + // RFC 9112 §6.1 says it MUST close the connection after responding. + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + var statusLines = CountHttpStatusLines(step1.Response?.RawResponse ?? ""); + + if (statusLines >= 2) + return $"MULTIPLE RESPONSES: observed {statusLines} HTTP status lines (embedded GET likely executed)"; + + if (step1.Response?.StatusCode == 400) + return "Rejected ambiguous CL+TE request with 400"; + + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return "Connection closed — safe"; + + if (step1.Response?.StatusCode is >= 200 and < 300) + return $"Accepted with {step1.Response.StatusCode} but kept connection open — MUST-close violated (no extra response observed)"; + + return $"Unexpected response: {step1.Response?.StatusCode.ToString() ?? "none"}"; + } + }; + + // ── CLTE-SMUGGLED-GET Variants (Malformed CL/TE) ─────────── + // Same as CLTE-SMUGGLED-GET, but with malformed framing headers. These are real-world vectors + // for front-end/back-end parsing disagreements. + + yield return new SequenceTestCase + { + Id = "SMUG-CLTE-SMUGGLED-GET-CL-PLUS", + Description = "CL.TE desync with malformed Content-Length (+N) — multiple responses indicate request boundary confusion", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9112 §6.1", + Expected = new ExpectedBehavior + { + Description = "400, or close (no extra response)" + }, + Steps = + [ + new SequenceStep + { + Label = "Poison POST with embedded GET (Content-Length:+N)", + PayloadFactory = ctx => + { + var smuggled = $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n"; + var body = "0\r\n\r\n" + smuggled; + var cl = Encoding.ASCII.GetByteCount(body); + return MakeRequest( + $"POST / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nContent-Length: +{cl}\r\nTransfer-Encoding: chunked\r\n\r\n{body}"); + } + } + ], + Validator = steps => + { + var step1 = steps[0]; + var statusLines = CountHttpStatusLines(step1.Response?.RawResponse ?? ""); + + if (statusLines >= 2) + return TestVerdict.Fail; + if (step1.Response?.StatusCode == 400) + return TestVerdict.Pass; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return TestVerdict.Pass; + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + var statusLines = CountHttpStatusLines(step1.Response?.RawResponse ?? ""); + + if (statusLines >= 2) + return $"MULTIPLE RESPONSES: observed {statusLines} HTTP status lines (embedded GET likely executed)"; + if (step1.Response?.StatusCode == 400) + return "Rejected malformed Content-Length (+N) CL+TE request with 400"; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return "Connection closed — safe"; + if (step1.Response?.StatusCode is >= 200 and < 300) + return $"Accepted with {step1.Response.StatusCode} but kept connection open — MUST-close violated (no extra response observed)"; + return $"Unexpected response: {step1.Response?.StatusCode.ToString() ?? "none"}"; + } + }; + + yield return new SequenceTestCase + { + Id = "SMUG-CLTE-SMUGGLED-GET-CL-NON-NUMERIC", + Description = "CL.TE desync with non-numeric Content-Length (N) — multiple responses indicate request boundary confusion", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9112 §6.1", + Expected = new ExpectedBehavior + { + Description = "400, or close (no extra response)" + }, + Steps = + [ + new SequenceStep + { + Label = "Poison POST with embedded GET (Content-Length:N)", + PayloadFactory = ctx => + { + var smuggled = $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n"; + var body = "0\r\n\r\n" + smuggled; + var cl = Encoding.ASCII.GetByteCount(body); + return MakeRequest( + $"POST / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nContent-Length: {cl}x\r\nTransfer-Encoding: chunked\r\n\r\n{body}"); + } + } + ], + Validator = steps => + { + var step1 = steps[0]; + var statusLines = CountHttpStatusLines(step1.Response?.RawResponse ?? ""); + + if (statusLines >= 2) + return TestVerdict.Fail; + if (step1.Response?.StatusCode == 400) + return TestVerdict.Pass; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return TestVerdict.Pass; + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + var statusLines = CountHttpStatusLines(step1.Response?.RawResponse ?? ""); + + if (statusLines >= 2) + return $"MULTIPLE RESPONSES: observed {statusLines} HTTP status lines (embedded GET likely executed)"; + if (step1.Response?.StatusCode == 400) + return "Rejected non-numeric Content-Length (N) CL+TE request with 400"; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return "Connection closed — safe"; + if (step1.Response?.StatusCode is >= 200 and < 300) + return $"Accepted with {step1.Response.StatusCode} but kept connection open — MUST-close violated (no extra response observed)"; + return $"Unexpected response: {step1.Response?.StatusCode.ToString() ?? "none"}"; + } + }; + + yield return new SequenceTestCase + { + Id = "SMUG-CLTE-SMUGGLED-GET-TE-OBS-FOLD", + Description = "CL.TE desync with obs-folded Transfer-Encoding — multiple responses indicate request boundary confusion", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9112 §5.2", + Expected = new ExpectedBehavior + { + Description = "400, or close (no extra response)" + }, + Steps = + [ + new SequenceStep + { + Label = "Poison POST with embedded GET (folded TE)", + PayloadFactory = ctx => + { + var smuggled = $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n"; + var body = "0\r\n\r\n" + smuggled; + var cl = Encoding.ASCII.GetByteCount(body); + return MakeRequest( + $"POST / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nTransfer-Encoding:\r\n chunked\r\nContent-Length: {cl}\r\n\r\n{body}"); + } + } + ], + Validator = steps => + { + var step1 = steps[0]; + var statusLines = CountHttpStatusLines(step1.Response?.RawResponse ?? ""); + + if (statusLines >= 2) + return TestVerdict.Fail; + if (step1.Response?.StatusCode == 400) + return TestVerdict.Pass; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return TestVerdict.Pass; + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + var statusLines = CountHttpStatusLines(step1.Response?.RawResponse ?? ""); + + if (statusLines >= 2) + return $"MULTIPLE RESPONSES: observed {statusLines} HTTP status lines (embedded GET likely executed)"; + if (step1.Response?.StatusCode == 400) + return "Rejected folded Transfer-Encoding CL+TE request with 400"; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return "Connection closed — safe"; + if (step1.Response?.StatusCode is >= 200 and < 300) + return $"Accepted with {step1.Response.StatusCode} but kept connection open — MUST-close violated (no extra response observed)"; + return $"Unexpected response: {step1.Response?.StatusCode.ToString() ?? "none"}"; + } + }; + + // ── Request Tunneling Confirmation (HEAD) ────────────────── + // Same as CLTE-SMUGGLED-GET, but the embedded request is HEAD. + yield return new SequenceTestCase + { + Id = "SMUG-CLTE-SMUGGLED-HEAD", + Description = "CL.TE desync — embedded HEAD in body; multiple responses indicate request boundary confusion", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9112 §6.1", + Expected = new ExpectedBehavior + { + Description = "400, or close (no extra response)" + }, + Steps = + [ + new SequenceStep + { + Label = "Poison POST with embedded HEAD", + PayloadFactory = ctx => + { + var smuggled = $"HEAD / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n"; + var body = "0\r\n\r\n" + smuggled; + var cl = Encoding.ASCII.GetByteCount(body); + return MakeRequest( + $"POST / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nContent-Length: {cl}\r\nTransfer-Encoding: chunked\r\n\r\n{body}"); + } + } + ], + Validator = steps => + { + var step1 = steps[0]; + var statusLines = CountHttpStatusLines(step1.Response?.RawResponse ?? ""); + + if (statusLines >= 2) + return TestVerdict.Fail; + + if (step1.Response?.StatusCode == 400) + return TestVerdict.Pass; + + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return TestVerdict.Pass; + + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + var statusLines = CountHttpStatusLines(step1.Response?.RawResponse ?? ""); + + if (statusLines >= 2) + return $"MULTIPLE RESPONSES: observed {statusLines} HTTP status lines (embedded HEAD likely executed)"; + + if (step1.Response?.StatusCode == 400) + return "Rejected ambiguous CL+TE request with 400"; + + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return "Connection closed — safe"; + + if (step1.Response?.StatusCode is >= 200 and < 300) + return $"Accepted with {step1.Response.StatusCode} but kept connection open — MUST-close violated (no extra response observed)"; + + return $"Unexpected response: {step1.Response?.StatusCode.ToString() ?? "none"}"; + } + }; + + // ── CLTE-SMUGGLED-GET Variants (Obfuscated TE) ───────────── + // TE parsing differentials are a common real-world smuggling vector. + + yield return new SequenceTestCase + { + Id = "SMUG-CLTE-SMUGGLED-GET-TE-TRAILING-SPACE", + Description = "CL.TE desync with TE trailing space — multiple responses indicate request boundary confusion", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9112 §6.1", + Expected = new ExpectedBehavior + { + Description = "400, or close (no extra response)" + }, + Steps = + [ + new SequenceStep + { + Label = "Poison POST with embedded GET (TE: chunked)", + PayloadFactory = ctx => + { + var smuggled = $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n"; + var body = "0\r\n\r\n" + smuggled; + var cl = Encoding.ASCII.GetByteCount(body); + return MakeRequest( + $"POST / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nContent-Length: {cl}\r\nTransfer-Encoding: chunked \r\n\r\n{body}"); + } + } + ], + Validator = steps => + { + var step1 = steps[0]; + var statusLines = CountHttpStatusLines(step1.Response?.RawResponse ?? ""); + + if (statusLines >= 2) + return TestVerdict.Fail; + if (step1.Response?.StatusCode == 400) + return TestVerdict.Pass; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return TestVerdict.Pass; + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + var statusLines = CountHttpStatusLines(step1.Response?.RawResponse ?? ""); + + if (statusLines >= 2) + return $"MULTIPLE RESPONSES: observed {statusLines} HTTP status lines (embedded GET likely executed)"; + if (step1.Response?.StatusCode == 400) + return "Rejected TE trailing-space CL+TE request with 400"; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return "Connection closed — safe"; + if (step1.Response?.StatusCode is >= 200 and < 300) + return $"Accepted with {step1.Response.StatusCode} but kept connection open — MUST-close violated (no extra response observed)"; + return $"Unexpected response: {step1.Response?.StatusCode.ToString() ?? "none"}"; + } + }; + + yield return new SequenceTestCase + { + Id = "SMUG-CLTE-SMUGGLED-GET-TE-LEADING-COMMA", + Description = "CL.TE desync with TE leading comma — multiple responses indicate request boundary confusion", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9112 §6.1", + Expected = new ExpectedBehavior + { + Description = "400, or close (no extra response)" + }, + Steps = + [ + new SequenceStep + { + Label = "Poison POST with embedded GET (TE: , chunked)", + PayloadFactory = ctx => + { + var smuggled = $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n"; + var body = "0\r\n\r\n" + smuggled; + var cl = Encoding.ASCII.GetByteCount(body); + return MakeRequest( + $"POST / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nContent-Length: {cl}\r\nTransfer-Encoding: , chunked\r\n\r\n{body}"); + } + } + ], + Validator = steps => + { + var step1 = steps[0]; + var statusLines = CountHttpStatusLines(step1.Response?.RawResponse ?? ""); + + if (statusLines >= 2) + return TestVerdict.Fail; + if (step1.Response?.StatusCode == 400) + return TestVerdict.Pass; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return TestVerdict.Pass; + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + var statusLines = CountHttpStatusLines(step1.Response?.RawResponse ?? ""); + + if (statusLines >= 2) + return $"MULTIPLE RESPONSES: observed {statusLines} HTTP status lines (embedded GET likely executed)"; + if (step1.Response?.StatusCode == 400) + return "Rejected TE leading-comma CL+TE request with 400"; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return "Connection closed — safe"; + if (step1.Response?.StatusCode is >= 200 and < 300) + return $"Accepted with {step1.Response.StatusCode} but kept connection open — MUST-close violated (no extra response observed)"; + return $"Unexpected response: {step1.Response?.StatusCode.ToString() ?? "none"}"; + } + }; + + yield return new SequenceTestCase + { + Id = "SMUG-CLTE-SMUGGLED-GET-TE-CASE-MISMATCH", + Description = "CL.TE desync with TE case mismatch — multiple responses indicate request boundary confusion", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9112 §6.1", + Expected = new ExpectedBehavior + { + Description = "400, or close (no extra response)" + }, + Steps = + [ + new SequenceStep + { + Label = "Poison POST with embedded GET (TE: Chunked)", + PayloadFactory = ctx => + { + var smuggled = $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n"; + var body = "0\r\n\r\n" + smuggled; + var cl = Encoding.ASCII.GetByteCount(body); + return MakeRequest( + $"POST / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nContent-Length: {cl}\r\nTransfer-Encoding: Chunked\r\n\r\n{body}"); + } + } + ], + Validator = steps => + { + var step1 = steps[0]; + var statusLines = CountHttpStatusLines(step1.Response?.RawResponse ?? ""); + + if (statusLines >= 2) + return TestVerdict.Fail; + if (step1.Response?.StatusCode == 400) + return TestVerdict.Pass; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return TestVerdict.Pass; + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + var statusLines = CountHttpStatusLines(step1.Response?.RawResponse ?? ""); + + if (statusLines >= 2) + return $"MULTIPLE RESPONSES: observed {statusLines} HTTP status lines (embedded GET likely executed)"; + if (step1.Response?.StatusCode == 400) + return "Rejected TE case-mismatch CL+TE request with 400"; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return "Connection closed — safe"; + if (step1.Response?.StatusCode is >= 200 and < 300) + return $"Accepted with {step1.Response.StatusCode} but kept connection open — MUST-close violated (no extra response observed)"; + return $"Unexpected response: {step1.Response?.StatusCode.ToString() ?? "none"}"; + } + }; + + // Duplicate/conflicting Transfer-Encoding header fields + CL, with an embedded request confirmation. + yield return new SequenceTestCase + { + Id = "SMUG-TE-DUPLICATE-HEADERS-SMUGGLED-GET", + Description = "TE.TE + CL ambiguity with embedded GET — multiple responses indicate request boundary confusion", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9112 §6.1", + Expected = new ExpectedBehavior + { + Description = "400, or close (no extra response)" + }, + Steps = + [ + new SequenceStep + { + Label = "Poison POST with duplicate TE + embedded GET", + PayloadFactory = ctx => + { + var smuggled = $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n"; + var body = "0\r\n\r\n" + smuggled; + var cl = Encoding.ASCII.GetByteCount(body); + return MakeRequest( + $"POST / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nTransfer-Encoding: chunked\r\nTransfer-Encoding: identity\r\nContent-Length: {cl}\r\n\r\n{body}"); + } + } + ], + Validator = steps => + { + var step1 = steps[0]; + var statusLines = CountHttpStatusLines(step1.Response?.RawResponse ?? ""); + + if (statusLines >= 2) + return TestVerdict.Fail; + + if (step1.Response?.StatusCode == 400) + return TestVerdict.Pass; + + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return TestVerdict.Pass; + + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + var statusLines = CountHttpStatusLines(step1.Response?.RawResponse ?? ""); + + if (statusLines >= 2) + return $"MULTIPLE RESPONSES: observed {statusLines} HTTP status lines (embedded GET likely executed)"; + + if (step1.Response?.StatusCode == 400) + return "Rejected duplicate Transfer-Encoding request with 400"; + + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return "Connection closed — safe"; + + if (step1.Response?.StatusCode is >= 200 and < 300) + return $"Accepted with {step1.Response.StatusCode} but kept connection open — MUST-close violated (no extra response observed)"; + + return $"Unexpected response: {step1.Response?.StatusCode.ToString() ?? "none"}"; + } + }; + + // ── TECL-SMUGGLED-GET ─────────────────────────────────────── + // TE.CL smuggling confirmation: the body starts with a valid chunk-size line. + // If the server incorrectly uses Content-Length framing, it will read only the + // chunk-size prefix and then interpret the chunk-data (which starts with a full + // GET request) as the next request on the connection. + yield return new SequenceTestCase + { + Id = "SMUG-TECL-SMUGGLED-GET", + Description = "TE.CL desync via chunk-size prefix trick — multiple responses indicate request boundary confusion", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9112 §6.1", + Expected = new ExpectedBehavior + { + Description = "400, or close (no extra response)" + }, + Steps = + [ + new SequenceStep + { + Label = "Poison POST (TE=chunked, CL=prefix, chunk-data begins with GET)", + PayloadFactory = ctx => + { + // Give the smuggled request a short body so that the remaining chunked framing bytes + // are consumed as its body if the server parses it as a second request. + var smuggled = + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nContent-Length: 7\r\n\r\n"; + + var chunkSize = Encoding.ASCII.GetByteCount(smuggled); + var hex = chunkSize.ToString("x"); + + // If a parser uses CL framing, read only the "{hex}\r\n" prefix and leave the chunk-data + // (which starts with the smuggled GET) on the wire as the next request. + var cl = hex.Length + 2; + + var body = $"{hex}\r\n{smuggled}\r\n0\r\n\r\n"; + + return MakeRequest( + $"POST / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nTransfer-Encoding: chunked\r\nContent-Length: {cl}\r\n\r\n{body}"); + } + } + ], + Validator = steps => + { + var step1 = steps[0]; + var statusLines = CountHttpStatusLines(step1.Response?.RawResponse ?? ""); + + if (statusLines >= 2) + return TestVerdict.Fail; + if (step1.Response?.StatusCode == 400) + return TestVerdict.Pass; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return TestVerdict.Pass; + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + var statusLines = CountHttpStatusLines(step1.Response?.RawResponse ?? ""); + + if (statusLines >= 2) + return $"MULTIPLE RESPONSES: observed {statusLines} HTTP status lines (embedded GET likely executed)"; + if (step1.Response?.StatusCode == 400) + return "Rejected ambiguous TE+CL request with 400"; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return "Connection closed — safe"; + if (step1.Response?.StatusCode is >= 200 and < 300) + return $"Accepted with {step1.Response.StatusCode} but kept connection open — MUST-close violated (no extra response observed)"; + return $"Unexpected response: {step1.Response?.StatusCode.ToString() ?? "none"}"; + } + }; + + // ── DUPLICATE-CL-SMUGGLED-GET ─────────────────────────────── + // CL.CL confirmation: two different Content-Length values. If a server chooses + // the shorter CL, the remainder begins with a valid GET request. + yield return new SequenceTestCase + { + Id = "SMUG-DUPLICATE-CL-SMUGGLED-GET", + Description = "CL.CL ambiguity with embedded GET — multiple responses indicate request boundary confusion", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9110 §8.6", + Expected = new ExpectedBehavior + { + Description = "400, or close (no extra response)" + }, + Steps = + [ + new SequenceStep + { + Label = "Poison POST with duplicate Content-Length + embedded GET", + PayloadFactory = ctx => + { + var smuggled = $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n"; + var body = "PING" + smuggled; + var cl = Encoding.ASCII.GetByteCount(body); + return MakeRequest( + $"POST / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nContent-Length: 4\r\nContent-Length: {cl}\r\n\r\n{body}"); + } + } + ], + Validator = steps => + { + var step1 = steps[0]; + var statusLines = CountHttpStatusLines(step1.Response?.RawResponse ?? ""); + + if (statusLines >= 2) + return TestVerdict.Fail; + if (step1.Response?.StatusCode == 400) + return TestVerdict.Pass; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return TestVerdict.Pass; + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + var statusLines = CountHttpStatusLines(step1.Response?.RawResponse ?? ""); + + if (statusLines >= 2) + return $"MULTIPLE RESPONSES: observed {statusLines} HTTP status lines (embedded GET likely executed)"; + if (step1.Response?.StatusCode == 400) + return "Rejected duplicate Content-Length request with 400"; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return "Connection closed — safe"; + if (step1.Response?.StatusCode is >= 200 and < 300) + return $"Accepted with {step1.Response.StatusCode} — duplicate Content-Length not rejected"; + return $"Unexpected response: {step1.Response?.StatusCode.ToString() ?? "none"}"; + } + }; + + // ── Ignored Content-Length / Unread-Body Desync ──────────── + // Some servers ignore request bodies on methods like GET and leave bytes on the connection. + // This test uses an incomplete request prefix in the body and then completes it on the next write. + yield return new SequenceTestCase + { + Id = "SMUG-GET-CL-PREFIX-DESYNC", + Description = "GET with Content-Length body containing an incomplete request prefix — follow-up completes it if body was left unread", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9110 §9.3.1", + RfcLevel = RfcLevel.May, + Scored = false, + Expected = new ExpectedBehavior + { + Description = "400/close preferred; extra response on step 2 = warn" + }, + Steps = + [ + new SequenceStep + { + Label = "GET with CL body (request prefix, no blank line)", + PayloadFactory = ctx => + { + var prefix = $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n"; + var cl = Encoding.ASCII.GetByteCount(prefix); + return MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nContent-Length: {cl}\r\n\r\n{prefix}"); + } + }, + new SequenceStep + { + Label = "Complete prefix then send follow-up GET", + PayloadFactory = ctx => MakeRequest( + $"\r\nGET / 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; + + var statusLines2 = CountHttpStatusLines(step2.Response?.RawResponse ?? ""); + if (statusLines2 >= 2) + return TestVerdict.Warn; + + if (step2.Response?.StatusCode == 400) + return TestVerdict.Pass; + 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 == 400) + return "Rejected GET with request-prefix body"; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return "Connection closed — safe"; + if (step1.Response?.StatusCode is >= 200 and < 300) + { + if (!step2.Executed) + return $"Accepted with {step1.Response.StatusCode}, then closed connection"; + + var statusLines2 = CountHttpStatusLines(step2.Response?.RawResponse ?? ""); + if (statusLines2 >= 2) + return $"MULTIPLE RESPONSES: observed {statusLines2} HTTP status lines on step 2 (unread prefix likely executed)"; + + return $"Step 1: {step1.Response.StatusCode}, step 2: {step2.Response?.StatusCode.ToString() ?? "no response"}"; + } + + return $"Unexpected response: {step1.Response?.StatusCode.ToString() ?? "none"}"; + } + }; + // ── TECL-DESYNC ───────────────────────────────────────────── // Reverse desync: TE terminates early (0\r\n\r\n), but CL claims a // larger body. If the server uses TE, it stops at the terminator