Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/probe.yml
Original file line number Diff line number Diff line change
Expand Up @@ -234,9 +234,9 @@ jobs:

import re
def short(tid):
return re.sub(r'^(RFC\d+-[\d.]+-|COMP-|SMUG-|MAL-)', '', tid)
return re.sub(r'^(RFC\d+-[\d.]+-|COMP-|SMUG-|MAL-|NORM-)', '', tid)

for cat_name, title in [('Compliance', 'Compliance'), ('Smuggling', 'Smuggling'), ('MalformedInput', 'Malformed Input')]:
for cat_name, title in [('Compliance', 'Compliance'), ('Smuggling', 'Smuggling'), ('MalformedInput', 'Malformed Input'), ('Normalization', 'Header Normalization')]:
cat_tests = [tid for tid in test_ids if lookup[names[0]][tid]['category'] == cat_name]
if not cat_tests:
continue
Expand Down
1 change: 1 addition & 0 deletions docs/content/docs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ Reference documentation for every test in Http11Probe, organized by topic. Each
{{< card link="smuggling" title="Request Smuggling" subtitle="CL+TE conflicts, TE obfuscation, pipeline injection, and why ambiguous framing is dangerous." icon="shield-exclamation" >}}
{{< card link="malformed-input" title="Malformed Input" subtitle="Binary garbage, oversized fields, control characters, incomplete requests." icon="lightning-bolt" >}}
{{< card link="upgrade" title="Upgrade / WebSocket" subtitle="Protocol upgrade validation, WebSocket handshake method and version checks." icon="arrow-up" >}}
{{< card link="normalization" title="Header Normalization" subtitle="Echo-based tests checking if servers normalize malformed header names (underscore, tab, casing)." icon="adjustments" >}}
{{< /cards >}}
48 changes: 48 additions & 0 deletions docs/content/docs/normalization/_index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
title: Header Normalization
description: "Header Normalization — Http11Probe documentation"
weight: 8
sidebar:
open: false
---

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.

## How the Echo Endpoint Works

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:

```
Host: localhost:8080
Content-Length: 11
Content_Length: 99
```

Http11Probe then parses the echo response to determine what happened to the malformed header name.

## Verdict Logic

| Echo Result | Verdict | Meaning |
|---|---|---|
| Standard header name with probe value | **Fail** | Server normalized the name (smuggling risk) |
| Original malformed name with probe value | **Warn** | Server preserved the name (mild proxy-chain risk) |
| Neither found | **Pass** | Server dropped or rejected the header |
| 400 / 4xx / 5xx | **Pass** | Server rejected the request |
| Connection closed | **Pass** | Server refused the connection |

## Tests

### Scored

{{< cards >}}
{{< card link="underscore-cl" title="UNDERSCORE-CL" subtitle="Content_Length with underscore instead of hyphen." >}}
{{< card link="sp-before-colon-cl" title="SP-BEFORE-COLON-CL" subtitle="Space before colon in Content-Length header." >}}
{{< card link="tab-in-name" title="TAB-IN-NAME" subtitle="Tab character embedded in header name." >}}
{{< card link="underscore-te" title="UNDERSCORE-TE" subtitle="Transfer_Encoding with underscore instead of hyphen." >}}
{{< /cards >}}

### Unscored

{{< cards >}}
{{< card link="case-te" title="CASE-TE" subtitle="All-uppercase TRANSFER-ENCODING — case normalization check." >}}
{{< /cards >}}
45 changes: 45 additions & 0 deletions docs/content/docs/normalization/case-te.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
title: "CASE-TE"
description: "CASE-TE test documentation"
weight: 4
---

| | |
|---|---|
| **Test ID** | `NORM-CASE-TE` |
| **Category** | Normalization |
| **Scored** | No |
| **Expected** | Reject/drop (pass), normalize casing (fail), preserve (warn) |

## What it sends

A POST request to `/echo` with a valid `Content-Length: 11` for body framing, plus an all-uppercase `TRANSFER-ENCODING: chunked` header.

```http
POST /echo HTTP/1.1\r\n
Host: localhost:8080\r\n
Content-Length: 11\r\n
TRANSFER-ENCODING: chunked\r\n
\r\n
hello world
```

## What the RFC says

RFC 9110 Section 5.1:

> "Each field name [...] is case-insensitive."

This means `TRANSFER-ENCODING` and `Transfer-Encoding` are semantically identical. Servers are required to treat them the same way.

## Pass / Fail / Warn

**Pass:** Server rejects (CL/TE conflict) or drops the header.
**Fail:** Server normalizes casing to `Transfer-Encoding` or `transfer-encoding` in the echo output.
**Warn:** Server preserves the original `TRANSFER-ENCODING` casing.

## Why it matters

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.

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.
42 changes: 42 additions & 0 deletions docs/content/docs/normalization/sp-before-colon-cl.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
title: "SP-BEFORE-COLON-CL"
description: "SP-BEFORE-COLON-CL test documentation"
weight: 2
---

| | |
|---|---|
| **Test ID** | `NORM-SP-BEFORE-COLON-CL` |
| **Category** | Normalization |
| **RFC** | [RFC 9112 §5](https://www.rfc-editor.org/rfc/rfc9112#section-5) |
| **Requirement** | MUST reject |
| **Expected** | Reject/drop (pass), normalize (fail), preserve (warn) |

## What it sends

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.

```http
POST /echo HTTP/1.1\r\n
Host: localhost:8080\r\n
Content-Length: 11\r\n
Content-Length : 5\r\n
\r\n
hello world
```

## What the RFC says

RFC 9112 Section 5 states:

> "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."

## Pass / Fail / Warn

**Pass:** Server rejects the request (`400`) or drops the malformed header.
**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`.
**Warn:** Server preserves the header with the trailing space in the name.

## Why it matters

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.
44 changes: 44 additions & 0 deletions docs/content/docs/normalization/tab-in-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
title: "TAB-IN-NAME"
description: "TAB-IN-NAME test documentation"
weight: 3
---

| | |
|---|---|
| **Test ID** | `NORM-TAB-IN-NAME` |
| **Category** | Normalization |
| **Requirement** | MUST reject (invalid token character) |
| **Expected** | Reject/drop (pass), normalize (fail), preserve (warn) |

## What it sends

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`.

```http
POST /echo HTTP/1.1\r\n
Host: localhost:8080\r\n
Content-Length: 11\r\n
Content[TAB]Length: 99\r\n
\r\n
hello world
```

## What the RFC says

RFC 9110 Section 5.1 defines field names using the `token` production:

> `token = 1*tchar`
> `tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA`

The horizontal tab character (0x09) is not a valid `tchar`, so `Content\tLength` is not a valid header name.

## Pass / Fail / Warn

**Pass:** Server rejects the request (`400`) or drops the malformed header.
**Fail:** Server normalizes `Content\tLength` to `Content-Length` — the echo shows `Content-Length: 99`.
**Warn:** Server preserves the original name with the tab character.

## Why it matters

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.
43 changes: 43 additions & 0 deletions docs/content/docs/normalization/underscore-cl.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
title: "UNDERSCORE-CL"
description: "UNDERSCORE-CL test documentation"
weight: 1
---

| | |
|---|---|
| **Test ID** | `NORM-UNDERSCORE-CL` |
| **Category** | Normalization |
| **Requirement** | Drop or reject |
| **Expected** | Reject/drop (pass), normalize (fail), preserve (warn) |

## What it sends

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.

```http
POST /echo HTTP/1.1\r\n
Host: localhost:8080\r\n
Content-Length: 11\r\n
Content_Length: 99\r\n
\r\n
hello world
```

## What the RFC says

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.

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.

## Pass / Fail / Warn

**Pass:** Server rejects the request (`400`) or drops the `Content_Length` header (echo does not contain it).
**Fail:** Server normalizes `Content_Length` to `Content-Length` — the echo shows `Content-Length: 99`.
**Warn:** Server preserves the original name — the echo shows `Content_Length: 99`.

## Why it matters

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.

This is the same class of attack tested by `SMUG-TRANSFER_ENCODING`, but applied to Content-Length instead of Transfer-Encoding.
41 changes: 41 additions & 0 deletions docs/content/docs/normalization/underscore-te.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
title: "UNDERSCORE-TE"
description: "UNDERSCORE-TE test documentation"
weight: 5
---

| | |
|---|---|
| **Test ID** | `NORM-UNDERSCORE-TE` |
| **Category** | Normalization |
| **Requirement** | Drop or reject |
| **Expected** | Reject/drop (pass), normalize (fail), preserve (warn) |

## What it sends

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.

```http
POST /echo HTTP/1.1\r\n
Host: localhost:8080\r\n
Content-Length: 11\r\n
Transfer_Encoding: chunked\r\n
\r\n
hello world
```

## What the RFC says

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.

## Pass / Fail / Warn

**Pass:** Server rejects the request (`400`) or drops the `Transfer_Encoding` header.
**Fail:** Server normalizes `Transfer_Encoding` to `Transfer-Encoding` — this creates a CL/TE conflict.
**Warn:** Server preserves the original name — the echo shows `Transfer_Encoding: chunked`.

## Why it matters

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.

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.
41 changes: 41 additions & 0 deletions docs/content/normalization/_index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
title: Normalization
layout: wide
toc: false
---

## Header Normalization

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:

- **Normalized** the header name to its standard form (smuggling risk &mdash; a proxy chain member may interpret it differently)
- **Preserved** the original malformed name (mild proxy-chain risk)
- **Dropped** the header entirely (safe)

{{< callout type="warning" >}}
Some tests are **unscored** (marked with `*`). These cover behaviors like case normalization that are RFC-compliant and common across servers.
{{< /callout >}}

{{< callout type="info" >}}
Click a **server name** to view its Dockerfile and source code. Click a **result cell** to see the full HTTP request and response.
{{< /callout >}}

<div id="lang-filter"></div>
<div id="table-normalization"><p><em>Loading...</em></p></div>

<script src="/Http11Probe/probe/data.js"></script>
<script src="/Http11Probe/probe/render.js"></script>
<script>
(function () {
if (!window.PROBE_DATA) {
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>';
return;
}
function render(data) {
var ctx = ProbeRender.buildLookups(data.servers);
ProbeRender.renderTable('table-normalization', 'Normalization', ctx);
}
render(window.PROBE_DATA);
ProbeRender.renderLanguageFilter('lang-filter', window.PROBE_DATA, render);
})();
</script>
14 changes: 12 additions & 2 deletions docs/content/servers/actix.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,15 @@ ENTRYPOINT ["actix-server", "8080"]
## Source — `src/main.rs`

```rust
use actix_web::{web, App, HttpServer, HttpRequest, HttpResponse};
use actix_web::{web, App, HttpServer, HttpRequest, HttpResponse, Responder};

async fn echo(req: HttpRequest) -> impl Responder {
let mut body = String::new();
for (name, value) in req.headers() {
body.push_str(&format!("{}: {}\n", name, value.to_str().unwrap_or("")));
}
HttpResponse::Ok().content_type("text/plain").body(body)
}

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

HttpServer::new(|| {
App::new().default_service(web::to(handler))
App::new()
.route("/echo", web::to(echo))
.default_service(web::to(handler))
})
.bind(("0.0.0.0", port))?
.run()
Expand Down
Loading
Loading