Skip to content

Commit b656f41

Browse files
committed
Reevaluate SMUG-CLTE-PIPELINE and SMUG-TECL-PIPELINE
1 parent 6342083 commit b656f41

12 files changed

Lines changed: 77 additions & 129 deletions

File tree

AGENTS.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,6 @@ yield return new TestCase
4545
RfcReference = "RFC 9112 §5.1", // Use § not "Section". Omit if no RFC applies.
4646
Scored = true, // Default true. Set false for MAY/informational tests.
4747
AllowConnectionClose = false, // On Expected. See validation rules below.
48-
FollowUpPayloadFactory = ctx => ..., // Second request on same connection (pipeline tests).
49-
RequiresConnectionReuse = false, // True if FollowUpPayloadFactory needs same TCP connection.
5048
BehavioralAnalyzer = (response) => ..., // Optional Func<HttpResponse?, string?> for analysis notes.
5149
};
5250
```

docs/content/docs/smuggling/clte-pipeline.md

Lines changed: 14 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ weight: 8
88
|---|---|
99
| **Test ID** | `SMUG-CLTE-PIPELINE` |
1010
| **Category** | Smuggling |
11+
| **Scored** | Yes |
1112
| **RFC** | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) |
12-
| **Requirement** | MUST close connection |
13-
| **Expected** | `400` or close |
13+
| **RFC Level** | MAY |
14+
| **Expected** | `400` or close preferred; `2xx` acceptable |
1415

1516
## What it sends
1617

17-
A full CL.TE smuggling payload — a POST request with both Content-Length and Transfer-Encoding headers, where the body contains a chunked `0` terminator followed by a smuggled second request.
18+
A request with both `Content-Length` and `Transfer-Encoding: chunked` — the classic CL.TE conflict pattern.
1819

1920
```http
2021
POST / HTTP/1.1\r\n
@@ -26,62 +27,27 @@ Transfer-Encoding: chunked\r\n
2627
\r\n
2728
```
2829

29-
Followed immediately on the same connection by:
30-
31-
```http
32-
GET / HTTP/1.1\r\n
33-
Host: localhost:8080\r\n
34-
\r\n
35-
```
36-
37-
A CL-only parser reads 4 bytes (`0\r\n\r`) as the body and sees the follow-up `GET`. A TE parser sees the `0` chunk as end-of-body and processes the `GET` as a separate request.
38-
30+
A CL-only parser reads 4 bytes (`0\r\n\r`) as the body. A TE parser sees the `0` chunk as end-of-body. The ambiguity is what makes this a smuggling vector in proxy chains.
3931

4032
## What the RFC says
4133

42-
> "If a message is received with both a Transfer-Encoding and a Content-Length header field, the Transfer-Encoding overrides the Content-Length. Such a message might indicate an attempt to perform request smuggling (Section 11.2) or response splitting (Section 11.1) and ought to be handled as an error." — RFC 9112 §6.3
43-
4434
> "A server MAY reject a request that contains both Content-Length and Transfer-Encoding or process such a request in accordance with the Transfer-Encoding alone. Regardless, the server MUST close the connection after responding to such a request to avoid the potential attacks." — RFC 9112 §6.1
4535
46-
## Why it matters
47-
48-
This is not a theoretical test — it's the actual attack payload. If the server processes the first request using CL and the second appears in the pipeline, the smuggling succeeded.
49-
50-
## Deep Analysis
51-
52-
### Relevant ABNF
53-
54-
From RFC 9112 Section 6, the message body length algorithm depends on these headers:
55-
56-
```
57-
Transfer-Encoding = #transfer-coding
58-
Content-Length = 1*DIGIT
59-
message-body = *OCTET
60-
```
61-
62-
When both headers are present, the specification defines a strict precedence rule that eliminates ambiguity.
63-
64-
### RFC Evidence
65-
66-
> "If a message is received with both a Transfer-Encoding and a Content-Length header field, the Transfer-Encoding overrides the Content-Length. Such a message might indicate an attempt to perform request smuggling or response splitting and ought to be handled as an error." -- RFC 9112 Section 6.3
67-
68-
> "A server MAY reject a request that contains both Content-Length and Transfer-Encoding or process such a request in accordance with the Transfer-Encoding alone. Regardless, the server MUST close the connection after responding to such a request to avoid the potential attacks." -- RFC 9112 Section 6.1
69-
70-
> "A sender MUST NOT send a Content-Length header field in any message that contains a Transfer-Encoding header field." -- RFC 9112 Section 6.2
71-
72-
### Chain of Reasoning
36+
> "If a message is received with both a Transfer-Encoding and a Content-Length header field, the Transfer-Encoding overrides the Content-Length. Such a message might indicate an attempt to perform request smuggling (Section 11.2) or response splitting (Section 11.1) and ought to be handled as an error." — RFC 9112 §6.3
7337
74-
1. **Dual framing headers create ambiguity by design.** The CL.TE payload sends `Content-Length: 4` and `Transfer-Encoding: chunked` simultaneously. A CL-only parser reads exactly 4 bytes (`0\r\n\r`) as the body, leaving the trailing `\n` and the pipelined `GET` request on the connection. A TE-compliant parser reads the `0` chunk terminator and considers the body complete at a different boundary.
38+
## Why it matters
7539

76-
2. **RFC 9112 Section 6.3 explicitly names this as a smuggling vector.** The specification does not merely discourage dual headers -- it calls out "request smuggling" by name and says the message "ought to be handled as an error." This is one of the rare cases where the RFC specifically names the attack it is trying to prevent.
40+
When both framing headers are present, different parsers in a proxy chain may disagree on where the body ends. A server that rejects the ambiguous request with `400` eliminates the risk entirely. A server that accepts it (processing via TE alone) is RFC-compliant but relies on connection closure to prevent exploitation.
7741

78-
3. **The MUST-close requirement in Section 6.1 is the critical defense.** Even if the server chooses to process the request (using TE alone, as permitted), it MUST close the connection afterward. This prevents any leftover bytes from being interpreted as a subsequent request. A server that keeps the connection open after processing a dual-header request is vulnerable regardless of which framing method it chose.
42+
## Verdicts
7943

80-
4. **Attack scenario.** An attacker sends the CL.TE payload to a proxy-origin pair. The proxy uses Content-Length (reads 4 bytes, forwards, then reads the `GET` as a separate pipelined request). The origin uses Transfer-Encoding (reads the `0` chunk, considers the POST done, then reads the `GET` -- but from the attacker's smuggled bytes, not from the proxy's pipeline). The origin now processes a request the proxy never authorized, potentially with attacker-controlled headers and path.
44+
- **Pass** — Server rejects with `400` or closes the connection (safest behavior)
45+
- **Warn** — Server responds with `2xx` (RFC-compliant if it processes via TE and closes the connection, but the lenient path)
46+
- **Fail** — Any other response
8147

82-
### Scored / Unscored Justification
48+
## Scored / Unscored Justification
8349

84-
This test is **scored** -- a `2xx` response results in a **Fail**. The RFC uses MUST-level language requiring connection closure after processing a dual CL+TE request, and the specification explicitly identifies this pattern as a smuggling vector. The test sends a pipelined follow-up `GET` on the same connection: if the server responds to it with `2xx`, it means the connection was kept alive after the ambiguous request, directly violating the MUST-close requirement and demonstrating exploitable behavior. There is no RFC-defensible reason for a server to keep the connection open in this scenario.
50+
This test is **scored**. Although the RFC uses MAY language, there is a clear preferred outcome: rejecting the ambiguous request is safer than accepting it. A `2xx` response counts as a warning rather than a pass, reflecting the security trade-off.
8551

8652
## Sources
8753

docs/content/docs/smuggling/tecl-pipeline.md

Lines changed: 14 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ weight: 9
88
|---|---|
99
| **Test ID** | `SMUG-TECL-PIPELINE` |
1010
| **Category** | Smuggling |
11+
| **Scored** | Yes |
1112
| **RFC** | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) |
12-
| **Requirement** | MUST close connection |
13-
| **Expected** | `400` or close |
13+
| **RFC Level** | MAY |
14+
| **Expected** | `400` or close preferred; `2xx` acceptable |
1415

1516
## What it sends
1617

17-
A full TE.CL smuggling payload — the reverse of CLTE. The front-end uses Transfer-Encoding and the body is crafted so the back-end (using Content-Length) sees a smuggled request.
18+
The reverse of CL.TE — a request with `Transfer-Encoding: chunked` listed first, plus a conflicting `Content-Length`.
1819

1920
```http
2021
POST / HTTP/1.1\r\n
@@ -26,62 +27,27 @@ Content-Length: 30\r\n
2627
\r\n
2728
```
2829

29-
Followed immediately on the same connection by:
30-
31-
```http
32-
GET / HTTP/1.1\r\n
33-
Host: localhost:8080\r\n
34-
\r\n
35-
```
36-
37-
A TE parser sees the `0` chunk as end-of-body. A CL-only parser tries to read 30 bytes and consumes the follow-up `GET` as body data.
38-
30+
A TE parser sees the `0` chunk as end-of-body (5 bytes consumed). A CL parser tries to read 30 bytes, consuming far more than the chunked body. The disagreement is what enables the TE.CL smuggling variant.
3931

4032
## What the RFC says
4133

42-
> "If a message is received with both a Transfer-Encoding and a Content-Length header field, the Transfer-Encoding overrides the Content-Length. Such a message might indicate an attempt to perform request smuggling (Section 11.2) or response splitting (Section 11.1) and ought to be handled as an error." — RFC 9112 §6.3
43-
4434
> "A server MAY reject a request that contains both Content-Length and Transfer-Encoding or process such a request in accordance with the Transfer-Encoding alone. Regardless, the server MUST close the connection after responding to such a request to avoid the potential attacks." — RFC 9112 §6.1
4535
46-
## Why it matters
47-
48-
The TE.CL variant is equally dangerous to CL.TE. Together, they cover both possible orderings of front-end/back-end preference.
49-
50-
## Deep Analysis
51-
52-
### Relevant ABNF
53-
54-
From RFC 9112 Section 6:
55-
56-
```
57-
Transfer-Encoding = #transfer-coding
58-
Content-Length = 1*DIGIT
59-
message-body = *OCTET
60-
```
61-
62-
The TE.CL variant reverses the header order compared to CL.TE, but the same precedence rule applies: Transfer-Encoding overrides Content-Length.
63-
64-
### RFC Evidence
65-
66-
> "If a message is received with both a Transfer-Encoding and a Content-Length header field, the Transfer-Encoding overrides the Content-Length. Such a message might indicate an attempt to perform request smuggling or response splitting and ought to be handled as an error." -- RFC 9112 Section 6.3
67-
68-
> "A server MAY reject a request that contains both Content-Length and Transfer-Encoding or process such a request in accordance with the Transfer-Encoding alone. Regardless, the server MUST close the connection after responding to such a request to avoid the potential attacks." -- RFC 9112 Section 6.1
69-
70-
> "A sender MUST NOT send a Content-Length header field in any message that contains a Transfer-Encoding header field." -- RFC 9112 Section 6.2
71-
72-
### Chain of Reasoning
36+
> "If a message is received with both a Transfer-Encoding and a Content-Length header field, the Transfer-Encoding overrides the Content-Length. Such a message might indicate an attempt to perform request smuggling (Section 11.2) or response splitting (Section 11.1) and ought to be handled as an error." — RFC 9112 §6.3
7337
74-
1. **TE.CL reverses the parser disagreement.** In this variant, the front-end uses Transfer-Encoding (reads the `0` chunk, considers the POST body complete) while the back-end uses Content-Length (tries to read 30 bytes, consuming the pipelined `GET` as part of the POST body). The fundamental issue is identical to CL.TE: two parsers in the same chain disagree on where the first request's body ends.
38+
## Why it matters
7539

76-
2. **The 30-byte Content-Length is carefully chosen.** The chunked body (`0\r\n\r\n`) is only 5 bytes. Content-Length says 30. A CL parser will attempt to read 25 more bytes from the connection, consuming part or all of the follow-up `GET` request. This means the back-end either hangs waiting for data, consumes the next request, or produces an error -- all of which indicate a desync.
40+
Together with CL.TE, this covers both orderings of the dual-header conflict. A proxy chain where one hop prefers TE and the other prefers CL is vulnerable to this variant. Rejecting the request outright is the safest defense.
7741

78-
3. **The RFC treats both variants identically.** Section 6.3 does not distinguish CL+TE from TE+CL header ordering. The rule is the same: TE overrides CL, the message ought to be treated as an error, and Section 6.1 mandates connection closure regardless of how the server processes the request. A compliant server handles both CLTE and TECL with the same defensive behavior.
42+
## Verdicts
7943

80-
4. **Attack scenario.** An attacker sends the TE.CL payload through a proxy. The proxy processes chunked encoding, sees the empty `0` terminator, and forwards the completed POST. The back-end, using Content-Length, reads 30 bytes -- consuming the chunked body plus the beginning of the next legitimate request from the proxy's pipeline. The back-end now has a corrupted view of the request stream, and the attacker can inject arbitrary request fragments that the proxy never inspected.
44+
- **Pass** — Server rejects with `400` or closes the connection (safest behavior)
45+
- **Warn** — Server responds with `2xx` (RFC-compliant if it processes via TE and closes the connection, but the lenient path)
46+
- **Fail** — Any other response
8147

82-
### Scored / Unscored Justification
48+
## Scored / Unscored Justification
8349

84-
This test is **scored** -- a `2xx` response results in a **Fail**. The reasoning mirrors CLTE-PIPELINE exactly: RFC 9112 Section 6.1 uses MUST-level language requiring connection closure after a dual CL+TE request. If the pipelined `GET` receives a `2xx` response, the server kept the connection open and is demonstrably vulnerable to the TE.CL smuggling variant. Both CL.TE and TE.CL are scored because the RFC requirement is the same for both, and both represent direct, exploitable attack payloads.
50+
This test is **scored**. Although the RFC uses MAY language, there is a clear preferred outcome: rejecting the ambiguous request is safer than accepting it. The reasoning mirrors CLTE-PIPELINE exactly.
8551

8652
## Sources
8753

src/Http11Probe/Client/RawTcpClient.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ public ConnectionState CheckConnectionState()
157157

158158
private static int FindHeaderTerminator(ReadOnlySpan<byte> data)
159159
{
160-
ReadOnlySpan<byte> terminator = [0x0D, 0x0A, 0x0D, 0x0A];
160+
ReadOnlySpan<byte> terminator = "\r\n\r\n"u8;
161161
return data.IndexOf(terminator);
162162
}
163163

src/Http11Probe/Response/HttpResponse.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,16 @@ namespace Http11Probe.Response;
33
public sealed class HttpResponse
44
{
55
public required int StatusCode { get; init; }
6+
67
public required string ReasonPhrase { get; init; }
8+
79
public required string HttpVersion { get; init; }
10+
811
public required IReadOnlyDictionary<string, string> Headers { get; init; }
12+
913
public bool IsEmpty { get; init; }
14+
1015
public string? RawResponse { get; init; }
16+
1117
public string? Body { get; init; }
1218
}

src/Http11Probe/Runner/TestRunOptions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,14 @@ namespace Http11Probe.Runner;
55
public sealed class TestRunOptions
66
{
77
public required string Host { get; init; }
8+
89
public required int Port { get; init; }
10+
911
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(5);
12+
1013
public TimeSpan ReadTimeout { get; init; } = TimeSpan.FromSeconds(5);
14+
1115
public TestCategory? CategoryFilter { get; init; }
16+
1217
public HashSet<string>? TestIdFilter { get; init; }
1318
}

src/Http11Probe/Runner/TestRunReport.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,21 @@ namespace Http11Probe.Runner;
55
public sealed class TestRunReport
66
{
77
public required IReadOnlyList<TestResult> Results { get; init; }
8+
89
public required TimeSpan TotalDuration { get; init; }
10+
911

1012
public int PassCount => Results.Count(r => r.TestCase.Scored && r.Verdict == TestVerdict.Pass);
13+
1114
public int FailCount => Results.Count(r => r.TestCase.Scored && r.Verdict == TestVerdict.Fail);
15+
1216
public int WarnCount => Results.Count(r => r.TestCase.Scored && r.Verdict == TestVerdict.Warn);
17+
1318
public int ScoredCount => PassCount + FailCount + WarnCount;
19+
1420
public int SkipCount => Results.Count(r => r.Verdict == TestVerdict.Skip);
21+
1522
public int ErrorCount => Results.Count(r => r.Verdict == TestVerdict.Error);
23+
1624
public int UnscoredCount => Results.Count(r => !r.TestCase.Scored && r.Verdict != TestVerdict.Skip);
1725
}

src/Http11Probe/Runner/TestRunner.cs

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -88,20 +88,9 @@ private async Task<TestResult> RunSingleAsync(TestCase testCase, TestContext con
8888
var (data, length, readState, drainCaughtData) = await client.ReadResponseAsync();
8989
var response = ResponseParser.TryParse(data.AsSpan(), length);
9090

91-
HttpResponse? followUpResponse = null;
9291
var connectionState = readState;
9392

94-
// If follow-up is needed and connection is still open
95-
if (testCase.FollowUpPayloadFactory is not null && connectionState == ConnectionState.Open)
96-
{
97-
var followUpPayload = testCase.FollowUpPayloadFactory(context);
98-
await client.SendAsync(followUpPayload);
99-
100-
var (fuData, fuLength, fuState, _) = await client.ReadResponseAsync();
101-
followUpResponse = ResponseParser.TryParse(fuData.AsSpan(), fuLength);
102-
connectionState = fuState;
103-
}
104-
else if (connectionState == ConnectionState.Open && !testCase.RequiresConnectionReuse)
93+
if (connectionState == ConnectionState.Open)
10594
{
10695
// Brief pause then check if server closed the connection
10796
await Task.Delay(50);
@@ -116,7 +105,6 @@ private async Task<TestResult> RunSingleAsync(TestCase testCase, TestContext con
116105
TestCase = testCase,
117106
Verdict = verdict,
118107
Response = response,
119-
FollowUpResponse = followUpResponse,
120108
ConnectionState = connectionState,
121109
BehavioralNote = behavioralNote,
122110
RawRequest = rawRequest,

0 commit comments

Comments
 (0)