Skip to content

Commit c11883b

Browse files
committed
Header normalization section
1 parent 4ad36c3 commit c11883b

101 files changed

Lines changed: 1736 additions & 52 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/content/docs/_index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@ Reference documentation for every test in Http11Probe, organized by topic. Each
2020
{{< card link="smuggling" title="Request Smuggling" subtitle="CL+TE conflicts, TE obfuscation, pipeline injection, and why ambiguous framing is dangerous." icon="shield-exclamation" >}}
2121
{{< card link="malformed-input" title="Malformed Input" subtitle="Binary garbage, oversized fields, control characters, incomplete requests." icon="lightning-bolt" >}}
2222
{{< card link="upgrade" title="Upgrade / WebSocket" subtitle="Protocol upgrade validation, WebSocket handshake method and version checks." icon="arrow-up" >}}
23+
{{< card link="normalization" title="Header Normalization" subtitle="Echo-based tests checking if servers normalize malformed header names (underscore, tab, casing)." icon="adjustments" >}}
2324
{{< /cards >}}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
title: Header Normalization
3+
description: "Header Normalization — Http11Probe documentation"
4+
weight: 8
5+
sidebar:
6+
open: false
7+
---
8+
9+
Header normalization tests examine how servers transform malformed header names when they accept them rather than rejecting. A server that silently converts `Content_Length` to `Content-Length` creates a smuggling vector: an upstream proxy might pass the underscore form through without acting on it, while the back-end treats it as a real Content-Length.
10+
11+
## How the Echo Endpoint Works
12+
13+
Each normalization test sends a `POST /echo` request with a valid `Content-Length` for body framing, plus an additional malformed header. The `/echo` endpoint reflects all received headers back in the response body, one per line:
14+
15+
```
16+
Host: localhost:8080
17+
Content-Length: 11
18+
Content_Length: 99
19+
```
20+
21+
Http11Probe then parses the echo response to determine what happened to the malformed header name.
22+
23+
## Verdict Logic
24+
25+
| Echo Result | Verdict | Meaning |
26+
|---|---|---|
27+
| Standard header name with probe value | **Fail** | Server normalized the name (smuggling risk) |
28+
| Original malformed name with probe value | **Warn** | Server preserved the name (mild proxy-chain risk) |
29+
| Neither found | **Pass** | Server dropped or rejected the header |
30+
| 400 / 4xx / 5xx | **Pass** | Server rejected the request |
31+
| Connection closed | **Pass** | Server refused the connection |
32+
33+
## Tests
34+
35+
### Scored
36+
37+
{{< cards >}}
38+
{{< card link="underscore-cl" title="UNDERSCORE-CL" subtitle="Content_Length with underscore instead of hyphen." >}}
39+
{{< card link="sp-before-colon-cl" title="SP-BEFORE-COLON-CL" subtitle="Space before colon in Content-Length header." >}}
40+
{{< card link="tab-in-name" title="TAB-IN-NAME" subtitle="Tab character embedded in header name." >}}
41+
{{< card link="underscore-te" title="UNDERSCORE-TE" subtitle="Transfer_Encoding with underscore instead of hyphen." >}}
42+
{{< /cards >}}
43+
44+
### Unscored
45+
46+
{{< cards >}}
47+
{{< card link="case-te" title="CASE-TE" subtitle="All-uppercase TRANSFER-ENCODING — case normalization check." >}}
48+
{{< /cards >}}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
title: "CASE-TE"
3+
description: "CASE-TE test documentation"
4+
weight: 4
5+
---
6+
7+
| | |
8+
|---|---|
9+
| **Test ID** | `NORM-CASE-TE` |
10+
| **Category** | Normalization |
11+
| **Scored** | No |
12+
| **Expected** | Reject/drop (pass), normalize casing (fail), preserve (warn) |
13+
14+
## What it sends
15+
16+
A POST request to `/echo` with a valid `Content-Length: 11` for body framing, plus an all-uppercase `TRANSFER-ENCODING: chunked` header.
17+
18+
```http
19+
POST /echo HTTP/1.1\r\n
20+
Host: localhost:8080\r\n
21+
Content-Length: 11\r\n
22+
TRANSFER-ENCODING: chunked\r\n
23+
\r\n
24+
hello world
25+
```
26+
27+
## What the RFC says
28+
29+
RFC 9110 Section 5.1:
30+
31+
> "Each field name [...] is case-insensitive."
32+
33+
This means `TRANSFER-ENCODING` and `Transfer-Encoding` are semantically identical. Servers are required to treat them the same way.
34+
35+
## Pass / Fail / Warn
36+
37+
**Pass:** Server rejects (CL/TE conflict) or drops the header.
38+
**Fail:** Server normalizes casing to `Transfer-Encoding` or `transfer-encoding` in the echo output.
39+
**Warn:** Server preserves the original `TRANSFER-ENCODING` casing.
40+
41+
## Why it matters
42+
43+
This test is **unscored** because case normalization of header names is RFC-compliant and common. It provides visibility into how the server processes header name casing, which is informational for understanding proxy-chain behavior.
44+
45+
If the server processes `TRANSFER-ENCODING: chunked` as a real Transfer-Encoding header, the CL/TE conflict would cause the request to be rejected (which is Pass). The interesting case is when the echo reveals casing transformation without the server acting on the value.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
title: "SP-BEFORE-COLON-CL"
3+
description: "SP-BEFORE-COLON-CL test documentation"
4+
weight: 2
5+
---
6+
7+
| | |
8+
|---|---|
9+
| **Test ID** | `NORM-SP-BEFORE-COLON-CL` |
10+
| **Category** | Normalization |
11+
| **RFC** | [RFC 9112 §5](https://www.rfc-editor.org/rfc/rfc9112#section-5) |
12+
| **Requirement** | MUST reject |
13+
| **Expected** | Reject/drop (pass), normalize (fail), preserve (warn) |
14+
15+
## What it sends
16+
17+
A POST request to `/echo` with a valid `Content-Length: 11` for body framing, plus a malformed `Content-Length : 5` header with a space before the colon.
18+
19+
```http
20+
POST /echo HTTP/1.1\r\n
21+
Host: localhost:8080\r\n
22+
Content-Length: 11\r\n
23+
Content-Length : 5\r\n
24+
\r\n
25+
hello world
26+
```
27+
28+
## What the RFC says
29+
30+
RFC 9112 Section 5 states:
31+
32+
> "No whitespace is allowed between the field name and colon. [...] A server MUST reject, with a response status code of 400 (Bad Request), any received request message that contains whitespace between a header field name and colon."
33+
34+
## Pass / Fail / Warn
35+
36+
**Pass:** Server rejects the request (`400`) or drops the malformed header.
37+
**Fail:** Server strips the whitespace and normalizes to `Content-Length: 5` — the echo shows a Content-Length header with value `5`, overriding the valid value of `11`.
38+
**Warn:** Server preserves the header with the trailing space in the name.
39+
40+
## Why it matters
41+
42+
If a server normalizes `Content-Length : 5` by stripping the whitespace, the request now has two Content-Length values (11 and 5). This creates a framing disagreement that can enable request smuggling. The RFC explicitly mandates rejection with 400 for this reason.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
title: "TAB-IN-NAME"
3+
description: "TAB-IN-NAME test documentation"
4+
weight: 3
5+
---
6+
7+
| | |
8+
|---|---|
9+
| **Test ID** | `NORM-TAB-IN-NAME` |
10+
| **Category** | Normalization |
11+
| **Requirement** | MUST reject (invalid token character) |
12+
| **Expected** | Reject/drop (pass), normalize (fail), preserve (warn) |
13+
14+
## What it sends
15+
16+
A POST request to `/echo` with a valid `Content-Length: 11` for body framing, plus a header containing a tab character in the name: `Content\tLength: 99`.
17+
18+
```http
19+
POST /echo HTTP/1.1\r\n
20+
Host: localhost:8080\r\n
21+
Content-Length: 11\r\n
22+
Content[TAB]Length: 99\r\n
23+
\r\n
24+
hello world
25+
```
26+
27+
## What the RFC says
28+
29+
RFC 9110 Section 5.1 defines field names using the `token` production:
30+
31+
> `token = 1*tchar`
32+
> `tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA`
33+
34+
The horizontal tab character (0x09) is not a valid `tchar`, so `Content\tLength` is not a valid header name.
35+
36+
## Pass / Fail / Warn
37+
38+
**Pass:** Server rejects the request (`400`) or drops the malformed header.
39+
**Fail:** Server normalizes `Content\tLength` to `Content-Length` — the echo shows `Content-Length: 99`.
40+
**Warn:** Server preserves the original name with the tab character.
41+
42+
## Why it matters
43+
44+
A server that converts a tab to a hyphen (or strips it) silently transforms an invalid header name into a real Content-Length header. Proxies that pass the tab-containing name through without recognizing it create a smuggling vector when the back-end normalizes.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
title: "UNDERSCORE-CL"
3+
description: "UNDERSCORE-CL test documentation"
4+
weight: 1
5+
---
6+
7+
| | |
8+
|---|---|
9+
| **Test ID** | `NORM-UNDERSCORE-CL` |
10+
| **Category** | Normalization |
11+
| **Requirement** | Drop or reject |
12+
| **Expected** | Reject/drop (pass), normalize (fail), preserve (warn) |
13+
14+
## What it sends
15+
16+
A POST request to `/echo` with a valid `Content-Length: 11` for body framing, plus a malformed `Content_Length: 99` header using an underscore instead of a hyphen.
17+
18+
```http
19+
POST /echo HTTP/1.1\r\n
20+
Host: localhost:8080\r\n
21+
Content-Length: 11\r\n
22+
Content_Length: 99\r\n
23+
\r\n
24+
hello world
25+
```
26+
27+
## What the RFC says
28+
29+
RFC 9110 defines header field names using the `token` production (RFC 9110 Section 5.1), which includes hyphens (`-`) but also underscores (`_`). So `Content_Length` is technically a valid header *name* per the grammar, but it is not the standard `Content-Length` header.
30+
31+
The security concern is whether the server silently maps `Content_Length` to `Content-Length`. If it does, the malformed name becomes a real framing header that upstream proxies may not have recognized.
32+
33+
## Pass / Fail / Warn
34+
35+
**Pass:** Server rejects the request (`400`) or drops the `Content_Length` header (echo does not contain it).
36+
**Fail:** Server normalizes `Content_Length` to `Content-Length` — the echo shows `Content-Length: 99`.
37+
**Warn:** Server preserves the original name — the echo shows `Content_Length: 99`.
38+
39+
## Why it matters
40+
41+
In a proxy chain, an upstream server may pass `Content_Length: 99` through as an unknown header. If the back-end normalizes it to `Content-Length: 99`, the request now has conflicting Content-Length values (11 vs 99), creating a classic request smuggling vector.
42+
43+
This is the same class of attack tested by `SMUG-TRANSFER_ENCODING`, but applied to Content-Length instead of Transfer-Encoding.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
title: "UNDERSCORE-TE"
3+
description: "UNDERSCORE-TE test documentation"
4+
weight: 5
5+
---
6+
7+
| | |
8+
|---|---|
9+
| **Test ID** | `NORM-UNDERSCORE-TE` |
10+
| **Category** | Normalization |
11+
| **Requirement** | Drop or reject |
12+
| **Expected** | Reject/drop (pass), normalize (fail), preserve (warn) |
13+
14+
## What it sends
15+
16+
A POST request to `/echo` with a valid `Content-Length: 11` for body framing, plus a malformed `Transfer_Encoding: chunked` header using an underscore instead of a hyphen.
17+
18+
```http
19+
POST /echo HTTP/1.1\r\n
20+
Host: localhost:8080\r\n
21+
Content-Length: 11\r\n
22+
Transfer_Encoding: chunked\r\n
23+
\r\n
24+
hello world
25+
```
26+
27+
## What the RFC says
28+
29+
Like `Content_Length`, the name `Transfer_Encoding` is a valid token per the `tchar` production but is not the standard `Transfer-Encoding` header. The security concern is whether the server maps the underscore variant to the real Transfer-Encoding header.
30+
31+
## Pass / Fail / Warn
32+
33+
**Pass:** Server rejects the request (`400`) or drops the `Transfer_Encoding` header.
34+
**Fail:** Server normalizes `Transfer_Encoding` to `Transfer-Encoding` — this creates a CL/TE conflict.
35+
**Warn:** Server preserves the original name — the echo shows `Transfer_Encoding: chunked`.
36+
37+
## Why it matters
38+
39+
This is the Transfer-Encoding counterpart to `NORM-UNDERSCORE-CL` and closely related to `SMUG-TRANSFER_ENCODING`. If a proxy passes `Transfer_Encoding: chunked` through without recognizing it, but the back-end normalizes it to `Transfer-Encoding: chunked`, the back-end will use chunked framing while the proxy used Content-Length. This is a textbook CL.TE smuggling vector.
40+
41+
The existing `SMUG-TRANSFER_ENCODING` test checks if the server *processes* the underscore form. This normalization test additionally checks whether the *name itself* appears normalized in the echo output, regardless of whether the server acted on the value.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
title: Normalization
3+
layout: wide
4+
toc: false
5+
---
6+
7+
## Header Normalization
8+
9+
Header normalization tests check what happens when a server *accepts* a malformed header rather than rejecting it. The `/echo` endpoint reflects received headers back in the response body, letting Http11Probe see whether the server:
10+
11+
- **Normalized** the header name to its standard form (smuggling risk &mdash; a proxy chain member may interpret it differently)
12+
- **Preserved** the original malformed name (mild proxy-chain risk)
13+
- **Dropped** the header entirely (safe)
14+
15+
{{< callout type="warning" >}}
16+
Some tests are **unscored** (marked with `*`). These cover behaviors like case normalization that are RFC-compliant and common across servers.
17+
{{< /callout >}}
18+
19+
{{< callout type="info" >}}
20+
Click a **server name** to view its Dockerfile and source code. Click a **result cell** to see the full HTTP request and response.
21+
{{< /callout >}}
22+
23+
<div id="lang-filter"></div>
24+
<div id="table-normalization"><p><em>Loading...</em></p></div>
25+
26+
<script src="/Http11Probe/probe/data.js"></script>
27+
<script src="/Http11Probe/probe/render.js"></script>
28+
<script>
29+
(function () {
30+
if (!window.PROBE_DATA) {
31+
document.getElementById('table-normalization').innerHTML = '<p><em>No probe data available yet. Run the Probe workflow manually on <code>main</code> to generate results.</em></p>';
32+
return;
33+
}
34+
function render(data) {
35+
var ctx = ProbeRender.buildLookups(data.servers);
36+
ProbeRender.renderTable('table-normalization', 'Normalization', ctx);
37+
}
38+
render(window.PROBE_DATA);
39+
ProbeRender.renderLanguageFilter('lang-filter', window.PROBE_DATA, render);
40+
})();
41+
</script>

docs/content/servers/actix.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,15 @@ ENTRYPOINT ["actix-server", "8080"]
2727
## Source — `src/main.rs`
2828

2929
```rust
30-
use actix_web::{web, App, HttpServer, HttpRequest, HttpResponse};
30+
use actix_web::{web, App, HttpServer, HttpRequest, HttpResponse, Responder};
31+
32+
async fn echo(req: HttpRequest) -> impl Responder {
33+
let mut body = String::new();
34+
for (name, value) in req.headers() {
35+
body.push_str(&format!("{}: {}\n", name, value.to_str().unwrap_or("")));
36+
}
37+
HttpResponse::Ok().content_type("text/plain").body(body)
38+
}
3139

3240
async fn handler(req: HttpRequest, body: web::Bytes) -> HttpResponse {
3341
if req.method() == actix_web::http::Method::POST {
@@ -49,7 +57,9 @@ async fn main() -> std::io::Result<()> {
4957
.unwrap_or(8080);
5058

5159
HttpServer::new(|| {
52-
App::new().default_service(web::to(handler))
60+
App::new()
61+
.route("/echo", web::to(echo))
62+
.default_service(web::to(handler))
5363
})
5464
.bind(("0.0.0.0", port))?
5565
.run()

docs/content/servers/apache.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,22 @@ FROM httpd:2.4
1313

1414
COPY src/Servers/ApacheServer/httpd-probe.conf /usr/local/apache2/conf/httpd.conf
1515
RUN echo "OK" > /usr/local/apache2/htdocs/index.html
16+
COPY src/Servers/ApacheServer/echo.cgi /usr/local/apache2/cgi-bin/echo.cgi
17+
RUN chmod +x /usr/local/apache2/cgi-bin/echo.cgi
1618
```
1719

1820
## Source — `httpd-probe.conf`
1921

20-
```apacheconf
22+
```apache
2123
ServerRoot "/usr/local/apache2"
2224
Listen 8080
2325
2426
LoadModule mpm_event_module modules/mod_mpm_event.so
2527
LoadModule dir_module modules/mod_dir.so
2628
LoadModule unixd_module modules/mod_unixd.so
2729
LoadModule authz_core_module modules/mod_authz_core.so
30+
LoadModule cgi_module modules/mod_cgi.so
31+
LoadModule alias_module modules/mod_alias.so
2832
2933
ErrorLog /proc/self/fd/2
3034
LogLevel warn
@@ -34,4 +38,27 @@ DocumentRoot "/usr/local/apache2/htdocs"
3438
<Directory "/usr/local/apache2/htdocs">
3539
Require all granted
3640
</Directory>
41+
42+
ScriptAlias /echo /usr/local/apache2/cgi-bin/echo.cgi
43+
44+
<Directory "/usr/local/apache2/cgi-bin">
45+
Require all granted
46+
</Directory>
47+
```
48+
49+
## Source — `echo.cgi`
50+
51+
```bash
52+
#!/bin/sh
53+
printf 'Content-Type: text/plain\r\n\r\n'
54+
env | grep '^HTTP_' | while IFS='=' read -r key value; do
55+
name=$(echo "$key" | sed 's/^HTTP_//;s/_/-/g')
56+
printf '%s: %s\n' "$name" "$value"
57+
done
58+
if [ -n "$CONTENT_TYPE" ]; then
59+
printf 'Content-Type: %s\n' "$CONTENT_TYPE"
60+
fi
61+
if [ -n "$CONTENT_LENGTH" ]; then
62+
printf 'Content-Length: %s\n' "$CONTENT_LENGTH"
63+
fi
3764
```

0 commit comments

Comments
 (0)