From f06b473155f1d432af880592da09ca6c6fcf689c Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 16 Feb 2026 18:49:23 +0000 Subject: [PATCH 1/3] Cokies suite --- docs/content/cookies/_index.md | 64 +++ docs/content/docs/cookies/_index.md | 45 ++ docs/content/docs/cookies/control-chars.md | 45 ++ docs/content/docs/cookies/echo.md | 42 ++ docs/content/docs/cookies/empty.md | 42 ++ docs/content/docs/cookies/malformed.md | 44 ++ docs/content/docs/cookies/many-pairs.md | 48 ++ docs/content/docs/cookies/multi-header.md | 47 ++ docs/content/docs/cookies/nul.md | 46 ++ docs/content/docs/cookies/oversized.md | 45 ++ docs/content/docs/cookies/parsed-basic.md | 43 ++ docs/content/docs/cookies/parsed-empty-val.md | 43 ++ docs/content/docs/cookies/parsed-multi.md | 43 ++ docs/content/docs/cookies/parsed-special.md | 47 ++ .../content/docs/rfc-requirement-dashboard.md | 27 +- docs/hugo.yaml | 5 + src/Http11Probe.Cli/Program.cs | 1 + .../TestCases/Suites/CookieSuite.cs | 473 ++++++++++++++++++ src/Http11Probe/TestCases/TestCategory.cs | 3 +- src/Servers/AspNetMinimal/Program.cs | 8 + 20 files changed, 1156 insertions(+), 5 deletions(-) create mode 100644 docs/content/cookies/_index.md create mode 100644 docs/content/docs/cookies/_index.md create mode 100644 docs/content/docs/cookies/control-chars.md create mode 100644 docs/content/docs/cookies/echo.md create mode 100644 docs/content/docs/cookies/empty.md create mode 100644 docs/content/docs/cookies/malformed.md create mode 100644 docs/content/docs/cookies/many-pairs.md create mode 100644 docs/content/docs/cookies/multi-header.md create mode 100644 docs/content/docs/cookies/nul.md create mode 100644 docs/content/docs/cookies/oversized.md create mode 100644 docs/content/docs/cookies/parsed-basic.md create mode 100644 docs/content/docs/cookies/parsed-empty-val.md create mode 100644 docs/content/docs/cookies/parsed-multi.md create mode 100644 docs/content/docs/cookies/parsed-special.md create mode 100644 src/Http11Probe/TestCases/Suites/CookieSuite.cs diff --git a/docs/content/cookies/_index.md b/docs/content/cookies/_index.md new file mode 100644 index 0000000..7fff443 --- /dev/null +++ b/docs/content/cookies/_index.md @@ -0,0 +1,64 @@ +--- +title: Cookies +layout: wide +toc: false +--- + +## Cookie Handling + +These tests check how servers and frameworks handle adversarial `Cookie` headers. Cookie parsing is done at the framework level, not by application code, so malformed cookies can crash parsers or produce mangled values before your code ever runs. All cookie tests are **unscored** since cookies are governed by RFC 6265, not RFC 9110/9112. + + +
+
Server Name
Click to view Dockerfile and source code
+
Table Row
Click to expand all results for that server
+
Result Cell
Click to see the full HTTP request and response
+
+ +
+
+
+
+
+

Loading...

+ + + + diff --git a/docs/content/docs/cookies/_index.md b/docs/content/docs/cookies/_index.md new file mode 100644 index 0000000..0acf4ab --- /dev/null +++ b/docs/content/docs/cookies/_index.md @@ -0,0 +1,45 @@ +--- +title: Cookies +description: "Cookies — Http11Probe documentation" +weight: 13 +sidebar: + open: false +--- + +Cookie parsing is handled by framework-level parsers that run automatically on every request. Malformed `Cookie` headers can crash these parsers, cause memory issues, or produce mangled values. These tests check whether servers and frameworks survive adversarial cookie input. + +Cookies are defined by [RFC 6265](https://www.rfc-editor.org/rfc/rfc6265) (not RFC 9110/9112), so all tests are **unscored**. + +## Scoring + +All cookie tests are **unscored**: + +- **Pass** — Server handled the cookie input safely +- **Warn** — Endpoint not available or non-ideal but non-dangerous behavior +- **Fail** — Server crashed (500), preserved dangerous bytes, or lost data it should have parsed + +## Echo-Based Tests + +These tests target `/echo` and work on all servers. They check whether the server survives adversarial cookie headers without crashing. + +{{< cards >}} + {{< card link="echo" title="ECHO" subtitle="Basic Cookie header echoed back." >}} + {{< card link="oversized" title="OVERSIZED" subtitle="64KB Cookie header." >}} + {{< card link="empty" title="EMPTY" subtitle="Empty Cookie header value." >}} + {{< card link="nul" title="NUL" subtitle="NUL byte in cookie value." >}} + {{< card link="control-chars" title="CONTROL-CHARS" subtitle="Control characters in cookie value." >}} + {{< card link="many-pairs" title="MANY-PAIRS" subtitle="1000 cookie key=value pairs." >}} + {{< card link="malformed" title="MALFORMED" subtitle="Completely malformed cookie syntax." >}} + {{< card link="multi-header" title="MULTI-HEADER" subtitle="Two separate Cookie headers." >}} +{{< /cards >}} + +## Parsed-Cookie Tests + +These tests target `/cookie` and check whether the framework's cookie parser correctly extracts key=value pairs. Servers without a `/cookie` endpoint return 404 (Warn). + +{{< cards >}} + {{< card link="parsed-basic" title="PARSED-BASIC" subtitle="Single foo=bar cookie parsed." >}} + {{< card link="parsed-multi" title="PARSED-MULTI" subtitle="Three cookies parsed from one header." >}} + {{< card link="parsed-empty-val" title="PARSED-EMPTY-VAL" subtitle="Cookie with empty value." >}} + {{< card link="parsed-special" title="PARSED-SPECIAL" subtitle="Spaces and = in cookie values." >}} +{{< /cards >}} diff --git a/docs/content/docs/cookies/control-chars.md b/docs/content/docs/cookies/control-chars.md new file mode 100644 index 0000000..de6f9f5 --- /dev/null +++ b/docs/content/docs/cookies/control-chars.md @@ -0,0 +1,45 @@ +--- +title: "CONTROL-CHARS" +description: "COOK-CONTROL-CHARS test documentation" +weight: 5 +--- + +| | | +|---|---| +| **Test ID** | `COOK-CONTROL-CHARS` | +| **Category** | Cookies | +| **Scored** | No | +| **Expected** | `400` (rejected) or `2xx` without control chars | + +## What it sends + +```http +GET /echo HTTP/1.1\r\n +Host: localhost:8080\r\n +Cookie: foo=\x01\x02\x03\r\n +\r\n +``` + +A `Cookie` header containing control characters SOH (`0x01`), STX (`0x02`), and ETX (`0x03`) as the cookie value. + +## What the RFC says + +> "cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E" — RFC 6265 §4.1.1 + +Control characters (`0x00-0x1F`) are explicitly excluded from the `cookie-octet` production. They are not valid in cookie values. + +## Why it matters + +Control characters in cookie values can cause: +- **Log injection** — if the bytes reach log files, they may corrupt formatting or inject terminal escape sequences +- **Parser confusion** — some parsers may interpret control characters as delimiters +- **Security filter bypass** — WAFs may not inspect or sanitize non-printable bytes + +## Verdicts + +- **Pass** — `400` (rejected) or `2xx` with control characters stripped/cookie dropped +- **Fail** — `2xx` with control characters preserved in the response body + +## Sources + +- [RFC 6265 §4.1.1](https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1) — cookie-octet definition diff --git a/docs/content/docs/cookies/echo.md b/docs/content/docs/cookies/echo.md new file mode 100644 index 0000000..b7a77ef --- /dev/null +++ b/docs/content/docs/cookies/echo.md @@ -0,0 +1,42 @@ +--- +title: "ECHO" +description: "COOK-ECHO test documentation" +weight: 1 +--- + +| | | +|---|---| +| **Test ID** | `COOK-ECHO` | +| **Category** | Cookies | +| **Scored** | No | +| **Expected** | `2xx` with `Cookie:` in echo body | + +## What it sends + +```http +GET /echo HTTP/1.1\r\n +Host: localhost:8080\r\n +Cookie: foo=bar\r\n +\r\n +``` + +A standard request with a simple, valid `Cookie` header targeting the `/echo` endpoint. + +## What the RFC says + +> "When the user agent generates an HTTP request, the user agent MUST NOT attach more than one header field named Cookie." — RFC 6265 §5.4 + +This test sends a single, well-formed `Cookie` header. It serves as a baseline to confirm the echo endpoint reflects cookie headers. + +## Why it matters + +This is the baseline cookie test. If the server cannot echo back a simple `Cookie: foo=bar` header, all other cookie tests are meaningless. + +## Verdicts + +- **Pass** — 2xx response with `Cookie:` visible in the echo body +- **Fail** — No response, or 2xx without the cookie header in the body + +## Sources + +- [RFC 6265 §5.4](https://www.rfc-editor.org/rfc/rfc6265#section-5.4) — sending cookies diff --git a/docs/content/docs/cookies/empty.md b/docs/content/docs/cookies/empty.md new file mode 100644 index 0000000..d6fc3ef --- /dev/null +++ b/docs/content/docs/cookies/empty.md @@ -0,0 +1,42 @@ +--- +title: "EMPTY" +description: "COOK-EMPTY test documentation" +weight: 3 +--- + +| | | +|---|---| +| **Test ID** | `COOK-EMPTY` | +| **Category** | Cookies | +| **Scored** | No | +| **Expected** | `2xx` or `400` | + +## What it sends + +```http +GET /echo HTTP/1.1\r\n +Host: localhost:8080\r\n +Cookie: \r\n +\r\n +``` + +A `Cookie` header with an empty value (nothing after the colon and space). + +## What the RFC says + +> "cookie-header = 'Cookie:' OWS cookie-string OWS" — RFC 6265 §4.2 + +An empty cookie-string does not match `cookie-pair *( ";" SP cookie-pair )` since `cookie-pair` requires at least a name. However, servers should handle this gracefully. + +## Why it matters + +Empty `Cookie` headers can trigger null-pointer dereferences or empty-string edge cases in cookie parsers. The test verifies the server doesn't crash. + +## Verdicts + +- **Pass** — `2xx` (accepted) or `400` (rejected gracefully) +- **Fail** — `500` or connection crash + +## Sources + +- [RFC 6265 §4.2](https://www.rfc-editor.org/rfc/rfc6265#section-4.2) — cookie header syntax diff --git a/docs/content/docs/cookies/malformed.md b/docs/content/docs/cookies/malformed.md new file mode 100644 index 0000000..3c7f1c9 --- /dev/null +++ b/docs/content/docs/cookies/malformed.md @@ -0,0 +1,44 @@ +--- +title: "MALFORMED" +description: "COOK-MALFORMED test documentation" +weight: 7 +--- + +| | | +|---|---| +| **Test ID** | `COOK-MALFORMED` | +| **Category** | Cookies | +| **Scored** | No | +| **Expected** | `2xx` or `400` | + +## What it sends + +```http +GET /echo HTTP/1.1\r\n +Host: localhost:8080\r\n +Cookie: ===;;;\r\n +\r\n +``` + +A `Cookie` header with completely invalid syntax — no valid cookie-name, only equals signs and semicolons. + +## What the RFC says + +> "cookie-pair = cookie-name '=' cookie-value" — RFC 6265 §4.1.1 + +> "cookie-name = token" — RFC 6265 §4.1.1 + +The value `===;;;` does not match the `cookie-pair` grammar. There is no valid `cookie-name` (an empty name before the first `=` is not a valid token). + +## Why it matters + +Framework cookie parsers must handle completely malformed cookie strings gracefully. This tests the worst-case scenario for parser resilience — the value bears no resemblance to valid cookie syntax. + +## Verdicts + +- **Pass** — `2xx` (survived) or `400` (rejected gracefully) +- **Fail** — `500` or connection crash + +## Sources + +- [RFC 6265 §4.1.1](https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1) — cookie-pair syntax diff --git a/docs/content/docs/cookies/many-pairs.md b/docs/content/docs/cookies/many-pairs.md new file mode 100644 index 0000000..d22b274 --- /dev/null +++ b/docs/content/docs/cookies/many-pairs.md @@ -0,0 +1,48 @@ +--- +title: "MANY-PAIRS" +description: "COOK-MANY-PAIRS test documentation" +weight: 6 +--- + +| | | +|---|---| +| **Test ID** | `COOK-MANY-PAIRS` | +| **Category** | Cookies | +| **Scored** | No | +| **Expected** | `2xx` or `400`/`431` | + +## What it sends + +```http +GET /echo HTTP/1.1\r\n +Host: localhost:8080\r\n +Cookie: k0=v0; k1=v1; k2=v2; ... ; k999=v999\r\n +\r\n +``` + +A `Cookie` header containing 1000 semicolon-separated key=value pairs. + +## What the RFC says + +> "At least 50 cookies per domain." — RFC 6265 §6.1 + +The practical limit of 50 cookies per domain is a user-agent guideline. Servers have no mandated limit, but 1000 pairs in a single header tests parser performance boundaries. + +## Why it matters + +Each cookie pair must be parsed, allocated, and stored in internal data structures. 1000 pairs can trigger: +- **O(n) or O(n^2) parsing** in naive cookie parsers +- **Memory exhaustion** from 1000 individual allocations +- **Hash table collisions** in cookie lookup structures + +A well-behaved server should either parse all 1000 pairs or reject the oversized header. + +## Verdicts + +- **Pass** — `2xx` (survived) or `400`/`431` (rejected gracefully) +- **Fail** — `500` or connection crash + +## Sources + +- [RFC 6265 §6.1](https://www.rfc-editor.org/rfc/rfc6265#section-6.1) — cookie limits +- [RFC 6585 §5](https://www.rfc-editor.org/rfc/rfc6585#section-5) — 431 status code diff --git a/docs/content/docs/cookies/multi-header.md b/docs/content/docs/cookies/multi-header.md new file mode 100644 index 0000000..a7586f3 --- /dev/null +++ b/docs/content/docs/cookies/multi-header.md @@ -0,0 +1,47 @@ +--- +title: "MULTI-HEADER" +description: "COOK-MULTI-HEADER test documentation" +weight: 8 +--- + +| | | +|---|---| +| **Test ID** | `COOK-MULTI-HEADER` | +| **Category** | Cookies | +| **Scored** | No | +| **Expected** | `2xx` with both cookies | + +## What it sends + +```http +GET /echo HTTP/1.1\r\n +Host: localhost:8080\r\n +Cookie: a=1\r\n +Cookie: b=2\r\n +\r\n +``` + +Two separate `Cookie` header lines in the same request. + +## What the RFC says + +> "If the user agent does attach a Cookie header field to an HTTP request, the user agent MUST NOT attach more than one header field named Cookie." — RFC 6265 §5.4 + +> "If a server receives multiple Cookie header field lines in a single request... the server SHOULD treat them as if they had been sent as a single cookie-string separated by semicolons." — RFC 6265 §5.3 (revised in RFC 6265bis) + +While clients MUST NOT send multiple Cookie headers, servers should handle them gracefully by folding them together. + +## Why it matters + +Multiple `Cookie` headers can occur in practice through proxy manipulation or misconfigured middleware. A server that crashes or drops cookies when it sees duplicates is fragile. The ideal behavior is to fold them into a single cookie-string as RFC 6265bis recommends. + +## Verdicts + +- **Pass** — `2xx` with both `a=1` and `b=2` in the echo body +- **Warn** — Only one cookie echoed, or `400` (rejected but didn't crash) +- **Fail** — `500` or connection crash + +## Sources + +- [RFC 6265 §5.4](https://www.rfc-editor.org/rfc/rfc6265#section-5.4) — sending cookies +- [RFC 6265 §5.3](https://www.rfc-editor.org/rfc/rfc6265#section-5.3) — cookie processing diff --git a/docs/content/docs/cookies/nul.md b/docs/content/docs/cookies/nul.md new file mode 100644 index 0000000..ab94ba5 --- /dev/null +++ b/docs/content/docs/cookies/nul.md @@ -0,0 +1,46 @@ +--- +title: "NUL" +description: "COOK-NUL test documentation" +weight: 4 +--- + +| | | +|---|---| +| **Test ID** | `COOK-NUL` | +| **Category** | Cookies | +| **Scored** | No | +| **Expected** | `400` (rejected) or `2xx` without NUL | + +## What it sends + +```http +GET /echo HTTP/1.1\r\n +Host: localhost:8080\r\n +Cookie: foo=\x00bar\r\n +\r\n +``` + +A `Cookie` header containing a NUL byte (`0x00`) embedded in the cookie value. + +## What the RFC says + +> "Field values containing CR, LF, or NUL characters are invalid and dangerous... a recipient of CR, LF, or NUL within a field value MUST either reject the message or replace each of those characters with SP before further processing." — RFC 9110 §5.5 + +NUL bytes in cookie values are not valid in any HTTP header field. + +## Why it matters + +NUL bytes in cookie values are a serious security concern. If a cookie parser preserves the NUL byte, it can: +- **Truncate strings** in C-based parsers, causing the cookie value to appear shorter than it is +- **Bypass security filters** that stop reading at NUL +- **Corrupt downstream processing** in systems that interpret NUL as a string terminator + +## Verdicts + +- **Pass** — `400` (rejected) or `2xx` with NUL stripped/cookie dropped +- **Fail** — `2xx` with NUL byte preserved in the response body (dangerous) + +## Sources + +- [RFC 9110 §5.5](https://www.rfc-editor.org/rfc/rfc9110#section-5.5) — field values with NUL +- [RFC 6265 §4.1.1](https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1) — cookie-value syntax diff --git a/docs/content/docs/cookies/oversized.md b/docs/content/docs/cookies/oversized.md new file mode 100644 index 0000000..66e0256 --- /dev/null +++ b/docs/content/docs/cookies/oversized.md @@ -0,0 +1,45 @@ +--- +title: "OVERSIZED" +description: "COOK-OVERSIZED test documentation" +weight: 2 +--- + +| | | +|---|---| +| **Test ID** | `COOK-OVERSIZED` | +| **Category** | Cookies | +| **Scored** | No | +| **Expected** | `400`/`431` (rejected) or `2xx` (survived) | + +## What it sends + +```http +GET /echo HTTP/1.1\r\n +Host: localhost:8080\r\n +Cookie: big=AAAA...AAAA\r\n +\r\n +``` + +A `Cookie` header with a 64KB value (65,536 `A` characters). + +## What the RFC says + +> "Practical cookie limits: At least 4096 bytes per cookie (as measured by the sum of the length of the cookie's name, value, and attributes)." — RFC 6265 §6.1 + +64KB vastly exceeds the recommended 4096-byte minimum. Servers are free to reject oversized cookies. + +> "The 431 status code indicates that the server is unwilling to process the request because its header fields are too large." — RFC 6585 §5 + +## Why it matters + +Oversized cookie headers can exhaust server memory or trigger buffer overflows in cookie parsers. A well-behaved server should either reject the request (400/431) or accept it without crashing. + +## Verdicts + +- **Pass** — `400`/`431` (rejected) or `2xx` (survived without crash) +- **Fail** — `500` or connection crash + +## Sources + +- [RFC 6265 §6.1](https://www.rfc-editor.org/rfc/rfc6265#section-6.1) — cookie limits +- [RFC 6585 §5](https://www.rfc-editor.org/rfc/rfc6585#section-5) — 431 status code diff --git a/docs/content/docs/cookies/parsed-basic.md b/docs/content/docs/cookies/parsed-basic.md new file mode 100644 index 0000000..ff29d4c --- /dev/null +++ b/docs/content/docs/cookies/parsed-basic.md @@ -0,0 +1,43 @@ +--- +title: "PARSED-BASIC" +description: "COOK-PARSED-BASIC test documentation" +weight: 9 +--- + +| | | +|---|---| +| **Test ID** | `COOK-PARSED-BASIC` | +| **Category** | Cookies | +| **Scored** | No | +| **Expected** | `2xx` with `foo=bar` in body | + +## What it sends + +```http +GET /cookie HTTP/1.1\r\n +Host: localhost:8080\r\n +Cookie: foo=bar\r\n +\r\n +``` + +A simple request with a single cookie, targeting the `/cookie` endpoint which returns parsed cookie key=value pairs. + +## What the RFC says + +> "cookie-pair = cookie-name '=' cookie-value" — RFC 6265 §4.1.1 + +`foo=bar` is a perfectly valid cookie-pair. The framework parser should extract `foo` with value `bar`. + +## Why it matters + +This is the baseline for parsed-cookie tests. It confirms that the framework's cookie parser can extract a simple cookie and return it. If this fails, the framework has a fundamental cookie parsing issue. + +## Verdicts + +- **Pass** — `2xx` with `foo=bar` in the response body +- **Warn** — `404` (endpoint not available on this server) +- **Fail** — `2xx` without `foo=bar`, or `500` + +## Sources + +- [RFC 6265 §4.1.1](https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1) — cookie-pair syntax diff --git a/docs/content/docs/cookies/parsed-empty-val.md b/docs/content/docs/cookies/parsed-empty-val.md new file mode 100644 index 0000000..05be783 --- /dev/null +++ b/docs/content/docs/cookies/parsed-empty-val.md @@ -0,0 +1,43 @@ +--- +title: "PARSED-EMPTY-VAL" +description: "COOK-PARSED-EMPTY-VAL test documentation" +weight: 11 +--- + +| | | +|---|---| +| **Test ID** | `COOK-PARSED-EMPTY-VAL` | +| **Category** | Cookies | +| **Scored** | No | +| **Expected** | `2xx` (no crash) | + +## What it sends + +```http +GET /cookie HTTP/1.1\r\n +Host: localhost:8080\r\n +Cookie: foo=\r\n +\r\n +``` + +A cookie with an empty value — the key `foo` is present but its value is an empty string. + +## What the RFC says + +> "cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )" — RFC 6265 §4.1.1 + +The `*` (zero or more) operator means an empty cookie-value is syntactically valid. The parser should accept `foo=` and store `foo` with an empty string value. + +## Why it matters + +Empty cookie values are common in practice — they often represent cleared or expired cookies. A parser that crashes on empty values has a serious resilience issue. + +## Verdicts + +- **Pass** — `2xx` (with or without `foo=` in the body — survival is the key) +- **Warn** — `404` (endpoint not available) +- **Fail** — `500` or connection crash + +## Sources + +- [RFC 6265 §4.1.1](https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1) — cookie-value syntax diff --git a/docs/content/docs/cookies/parsed-multi.md b/docs/content/docs/cookies/parsed-multi.md new file mode 100644 index 0000000..fd48eb1 --- /dev/null +++ b/docs/content/docs/cookies/parsed-multi.md @@ -0,0 +1,43 @@ +--- +title: "PARSED-MULTI" +description: "COOK-PARSED-MULTI test documentation" +weight: 10 +--- + +| | | +|---|---| +| **Test ID** | `COOK-PARSED-MULTI` | +| **Category** | Cookies | +| **Scored** | No | +| **Expected** | `2xx` with `a=1`, `b=2`, `c=3` in body | + +## What it sends + +```http +GET /cookie HTTP/1.1\r\n +Host: localhost:8080\r\n +Cookie: a=1; b=2; c=3\r\n +\r\n +``` + +A request with three cookies separated by `; ` (semicolon-space) in a single `Cookie` header. + +## What the RFC says + +> "cookie-string = cookie-pair *( ';' SP cookie-pair )" — RFC 6265 §4.2.1 + +Multiple cookies in a single header are delimited by `; ` (semicolon followed by a space). The parser must split on this delimiter and extract all pairs. + +## Why it matters + +Most real-world requests contain multiple cookies (session IDs, preferences, tracking tokens). If the framework parser fails to split on `; ` correctly, it will lose cookies — potentially dropping session tokens or authentication data. + +## Verdicts + +- **Pass** — `2xx` with all three cookies (`a=1`, `b=2`, `c=3`) in the response body +- **Warn** — `404` (endpoint not available) +- **Fail** — `2xx` with missing cookies, or `500` + +## Sources + +- [RFC 6265 §4.2.1](https://www.rfc-editor.org/rfc/rfc6265#section-4.2.1) — cookie-string syntax diff --git a/docs/content/docs/cookies/parsed-special.md b/docs/content/docs/cookies/parsed-special.md new file mode 100644 index 0000000..0d1af32 --- /dev/null +++ b/docs/content/docs/cookies/parsed-special.md @@ -0,0 +1,47 @@ +--- +title: "PARSED-SPECIAL" +description: "COOK-PARSED-SPECIAL test documentation" +weight: 12 +--- + +| | | +|---|---| +| **Test ID** | `COOK-PARSED-SPECIAL` | +| **Category** | Cookies | +| **Scored** | No | +| **Expected** | `2xx` (no crash) | + +## What it sends + +```http +GET /cookie HTTP/1.1\r\n +Host: localhost:8080\r\n +Cookie: a=hello world; b=x=y\r\n +\r\n +``` + +Two cookies with edge-case values: +- `a=hello world` — contains a space (technically invalid per RFC 6265 cookie-octet, but common in practice) +- `b=x=y` — contains an `=` sign in the value + +## What the RFC says + +> "cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E" — RFC 6265 §4.1.1 + +A space (`0x20`) is not in the `cookie-octet` set, making `hello world` technically invalid. However, many frameworks accept spaces in cookie values for compatibility. The `=` sign (`0x3D`) is in the `cookie-octet` range (`%x3C-5B`), so `x=y` is valid. + +## Why it matters + +Real-world cookies often contain characters that are technically outside the RFC 6265 grammar. Frameworks must decide whether to be strict (reject) or lenient (accept). Either approach is acceptable — crashing is not. + +The `=` in `b=x=y` is a common parser edge case: the parser must split on the *first* `=` only, yielding key `b` and value `x=y`. + +## Verdicts + +- **Pass** — `2xx` (survived, with or without correct parsing) +- **Warn** — `404` (endpoint not available) +- **Fail** — `500` or connection crash + +## Sources + +- [RFC 6265 §4.1.1](https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1) — cookie-octet syntax diff --git a/docs/content/docs/rfc-requirement-dashboard.md b/docs/content/docs/rfc-requirement-dashboard.md index 77385ec..3e7034e 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 203 Http11Probe tests" +description: "Complete RFC 2119 requirement-level analysis for all 215 Http11Probe tests" weight: 2 breadcrumbs: false --- @@ -15,10 +15,10 @@ This dashboard classifies every Http11Probe test by its [RFC 2119](https://www.r | **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** | 39 | Informational — no pass/fail judgement | +| **Unscored** | 51 | Informational — no pass/fail judgement | | **N/A** | 11 | Best-practice / no single RFC verb applies | -**Total: 203 tests** +**Total: 215 tests** --- @@ -222,7 +222,7 @@ Weaker than SHOULD — recommends but does not normatively require. --- -## Unscored Tests (39 tests) +## Unscored Tests (51 tests) These tests are informational — they produce warnings but never fail. @@ -267,6 +267,18 @@ These tests are informational — they produce warnings but never fail. | 37 | `CAP-IMS-INVALID` | Capabilities | [RFC 9110 §13.1.3](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.3) | If-Modified-Since with a garbage (non-HTTP-date) value must be ignored — server should return 200. | | 38 | `CAP-INM-UNQUOTED` | Capabilities | [RFC 9110 §8.8.3](https://www.rfc-editor.org/rfc/rfc9110#section-8.8.3) | If-None-Match with an unquoted ETag violates entity-tag syntax — server should return 200, not 304. | | 39 | `CAP-ETAG-WEAK` | Capabilities | [RFC 9110 §13.1.2](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.2) | Weak ETag comparison for GET If-None-Match — server must use weak comparison and return 304. | +| 40 | `COOK-ECHO` | Cookies | [RFC 6265 §5.4](https://www.rfc-editor.org/rfc/rfc6265#section-5.4) | Baseline — confirms /echo endpoint reflects Cookie header. | +| 41 | `COOK-OVERSIZED` | Cookies | [RFC 6265 §6.1](https://www.rfc-editor.org/rfc/rfc6265#section-6.1) | 64KB Cookie header — tests header size limits on cookie data. 400/431 or 2xx both acceptable. | +| 42 | `COOK-EMPTY` | Cookies | [RFC 6265 §4.2](https://www.rfc-editor.org/rfc/rfc6265#section-4.2) | Empty Cookie value — tests parser resilience on empty cookie-string. | +| 43 | `COOK-NUL` | Cookies | [RFC 9110 §5.5](https://www.rfc-editor.org/rfc/rfc9110#section-5.5) | NUL byte in cookie value — dangerous if preserved by parser. | +| 44 | `COOK-CONTROL-CHARS` | Cookies | [RFC 6265 §4.1.1](https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1) | Control characters (0x01-0x03) in cookie value — not valid cookie-octets. | +| 45 | `COOK-MANY-PAIRS` | Cookies | [RFC 6265 §6.1](https://www.rfc-editor.org/rfc/rfc6265#section-6.1) | 1000 cookie pairs — tests parser performance limits. | +| 46 | `COOK-MALFORMED` | Cookies | [RFC 6265 §4.1.1](https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1) | Completely malformed cookie syntax (===;;;) — tests crash resilience. | +| 47 | `COOK-MULTI-HEADER` | Cookies | [RFC 6265 §5.4](https://www.rfc-editor.org/rfc/rfc6265#section-5.4) | Two separate Cookie headers — should be folded per RFC 6265. | +| 48 | `COOK-PARSED-BASIC` | Cookies | [RFC 6265 §4.1.1](https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1) | Single cookie parsed by framework. | +| 49 | `COOK-PARSED-MULTI` | Cookies | [RFC 6265 §4.2.1](https://www.rfc-editor.org/rfc/rfc6265#section-4.2.1) | Three cookies parsed from semicolon-delimited header. | +| 50 | `COOK-PARSED-EMPTY-VAL` | Cookies | [RFC 6265 §4.1.1](https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1) | Cookie with empty value — *cookie-octet allows zero or more. | +| 51 | `COOK-PARSED-SPECIAL` | Cookies | [RFC 6265 §4.1.1](https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1) | Spaces and = in cookie values — edge cases for parser splitting. | --- @@ -336,6 +348,12 @@ These tests don't map to a single RFC 2119 keyword but enforce defensive best pr |-------|-------| | Unscored | 9 | +### Cookies Suite (12 tests) + +| Level | Tests | +|-------|-------| +| Unscored | 12 | + --- ## RFC Section Cross-Reference @@ -376,4 +394,5 @@ These tests don't map to a single RFC 2119 keyword but enforce defensive best pr | RFC 6585 | 3 | 431 status code | | RFC 3629 | 1 | UTF-8 encoding | | RFC 9113 | 1 | HTTP/2 preface | +| RFC 6265 | 12 | Cookie handling | | N/A | 7 | Best practice / defensive | diff --git a/docs/hugo.yaml b/docs/hugo.yaml index 75a7f79..e2b3f02 100644 --- a/docs/hugo.yaml +++ b/docs/hugo.yaml @@ -47,6 +47,11 @@ menu: parent: test-details pageRef: /caching weight: 5 + - name: Cookies + identifier: test-details-cookies + parent: test-details + pageRef: /cookies + weight: 6 - name: Glossary identifier: glossary pageRef: /docs diff --git a/src/Http11Probe.Cli/Program.cs b/src/Http11Probe.Cli/Program.cs index ed1c8b4..ba8c42e 100644 --- a/src/Http11Probe.Cli/Program.cs +++ b/src/Http11Probe.Cli/Program.cs @@ -65,6 +65,7 @@ testCases.AddRange(MalformedInputSuite.GetTestCases()); testCases.AddRange(NormalizationSuite.GetTestCases()); testCases.AddRange(CapabilitiesSuite.GetSequenceTestCases()); + testCases.AddRange(CookieSuite.GetTestCases()); var runner = new TestRunner(options); diff --git a/src/Http11Probe/TestCases/Suites/CookieSuite.cs b/src/Http11Probe/TestCases/Suites/CookieSuite.cs new file mode 100644 index 0000000..c38c7a7 --- /dev/null +++ b/src/Http11Probe/TestCases/Suites/CookieSuite.cs @@ -0,0 +1,473 @@ +using System.Text; +using Http11Probe.Client; +using Http11Probe.Response; + +namespace Http11Probe.TestCases.Suites; + +public static class CookieSuite +{ + public static IEnumerable GetTestCases() + { + // ── Echo-based tests (target /echo, all servers) ───────────── + + yield return new TestCase + { + Id = "COOK-ECHO", + Description = "Basic Cookie header echoed back by /echo endpoint", + Category = TestCategory.Cookies, + Scored = false, + RfcLevel = RfcLevel.NotApplicable, + PayloadFactory = ctx => MakeRequest( + $"GET /echo HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nCookie: foo=bar\r\n\r\n"), + Expected = new ExpectedBehavior + { + Description = "2xx with Cookie in body", + CustomValidator = (response, state) => + { + if (response is null) + return state == ConnectionState.ClosedByServer ? TestVerdict.Warn : TestVerdict.Fail; + if (response.StatusCode is < 200 or >= 300) + return TestVerdict.Fail; + var body = response.Body ?? ""; + return body.Contains("Cookie:", StringComparison.OrdinalIgnoreCase) + ? TestVerdict.Pass + : TestVerdict.Fail; + } + }, + BehavioralAnalyzer = response => + { + if (response is null) return null; + var body = response.Body ?? ""; + if (body.Contains("foo=bar")) return "Cookie echoed: foo=bar"; + if (body.Contains("Cookie:", StringComparison.OrdinalIgnoreCase)) return "Cookie header present but value differs"; + return "Cookie header missing from echo"; + } + }; + + yield return new TestCase + { + Id = "COOK-OVERSIZED", + Description = "64KB Cookie header — tests header size limits on cookie data", + Category = TestCategory.Cookies, + Scored = false, + RfcLevel = RfcLevel.NotApplicable, + PayloadFactory = ctx => + { + var bigValue = new string('A', 65_536); + return MakeRequest( + $"GET /echo HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nCookie: big={bigValue}\r\n\r\n"); + }, + Expected = new ExpectedBehavior + { + Description = "400/431 (rejected) or 2xx (survived)", + CustomValidator = (response, state) => + { + if (response is null) + return state == ConnectionState.ClosedByServer ? TestVerdict.Pass : TestVerdict.Fail; + if (response.StatusCode is 400 or 431) + return TestVerdict.Pass; + if (response.StatusCode is >= 200 and < 300) + return TestVerdict.Pass; // survived + return TestVerdict.Fail; // 500 = crash + } + }, + BehavioralAnalyzer = response => + { + if (response is null) return null; + if (response.StatusCode is 400 or 431) return "Rejected oversized cookie"; + if (response.StatusCode is >= 200 and < 300) return "Accepted 64KB cookie"; + return $"Unexpected: {response.StatusCode}"; + } + }; + + yield return new TestCase + { + Id = "COOK-EMPTY", + Description = "Empty Cookie header value — tests parser resilience", + Category = TestCategory.Cookies, + Scored = false, + RfcLevel = RfcLevel.NotApplicable, + PayloadFactory = ctx => MakeRequest( + $"GET /echo HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nCookie: \r\n\r\n"), + Expected = new ExpectedBehavior + { + Description = "2xx or 400", + CustomValidator = (response, state) => + { + if (response is null) + return state == ConnectionState.ClosedByServer ? TestVerdict.Pass : TestVerdict.Fail; + if (response.StatusCode is (>= 200 and < 300) or 400) + return TestVerdict.Pass; + return TestVerdict.Fail; // 500 = crash + } + }, + BehavioralAnalyzer = response => + { + if (response is null) return null; + if (response.StatusCode is >= 200 and < 300) return "Accepted empty cookie"; + if (response.StatusCode == 400) return "Rejected empty cookie"; + return $"Unexpected: {response.StatusCode}"; + } + }; + + yield return new TestCase + { + Id = "COOK-NUL", + Description = "NUL byte in cookie value — dangerous if preserved by parser", + Category = TestCategory.Cookies, + Scored = false, + RfcLevel = RfcLevel.NotApplicable, + PayloadFactory = ctx => + { + var request = $"GET /echo HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nCookie: foo=\0bar\r\n\r\n"; + return Encoding.ASCII.GetBytes(request); + }, + Expected = new ExpectedBehavior + { + Description = "400 (rejected) or 2xx without NUL", + CustomValidator = (response, state) => + { + if (response is null) + return state == ConnectionState.ClosedByServer ? TestVerdict.Pass : TestVerdict.Fail; + if (response.StatusCode == 400) + return TestVerdict.Pass; // rejected + if (response.StatusCode is >= 200 and < 300) + { + var body = response.Body ?? ""; + // NUL preserved in output = dangerous + return body.Contains('\0') ? TestVerdict.Fail : TestVerdict.Pass; + } + return TestVerdict.Fail; // 500 = crash + } + }, + BehavioralAnalyzer = response => + { + if (response is null) return null; + if (response.StatusCode == 400) return "Rejected NUL in cookie"; + if (response.StatusCode is >= 200 and < 300) + { + var body = response.Body ?? ""; + return body.Contains('\0') ? "NUL byte preserved (dangerous)" : "NUL stripped or cookie dropped"; + } + return $"Unexpected: {response.StatusCode}"; + } + }; + + yield return new TestCase + { + Id = "COOK-CONTROL-CHARS", + Description = "Control characters (0x01-0x03) in cookie value — dangerous if preserved", + Category = TestCategory.Cookies, + Scored = false, + RfcLevel = RfcLevel.NotApplicable, + PayloadFactory = ctx => + { + var request = $"GET /echo HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nCookie: foo=\x01\x02\x03\r\n\r\n"; + return Encoding.ASCII.GetBytes(request); + }, + Expected = new ExpectedBehavior + { + Description = "400 (rejected) or 2xx without control chars", + CustomValidator = (response, state) => + { + if (response is null) + return state == ConnectionState.ClosedByServer ? TestVerdict.Pass : TestVerdict.Fail; + if (response.StatusCode == 400) + return TestVerdict.Pass; // rejected + if (response.StatusCode is >= 200 and < 300) + { + var body = response.Body ?? ""; + // Control chars preserved = dangerous + return body.Any(c => c is '\x01' or '\x02' or '\x03') + ? TestVerdict.Fail + : TestVerdict.Pass; + } + return TestVerdict.Fail; // 500 = crash + } + }, + BehavioralAnalyzer = response => + { + if (response is null) return null; + if (response.StatusCode == 400) return "Rejected control chars in cookie"; + if (response.StatusCode is >= 200 and < 300) + { + var body = response.Body ?? ""; + return body.Any(c => c is '\x01' or '\x02' or '\x03') + ? "Control chars preserved (dangerous)" + : "Control chars stripped or cookie dropped"; + } + return $"Unexpected: {response.StatusCode}"; + } + }; + + yield return new TestCase + { + Id = "COOK-MANY-PAIRS", + Description = "1000 cookie key=value pairs — tests parser performance limits", + Category = TestCategory.Cookies, + Scored = false, + RfcLevel = RfcLevel.NotApplicable, + PayloadFactory = ctx => + { + var pairs = string.Join("; ", Enumerable.Range(0, 1000).Select(i => $"k{i}=v{i}")); + return MakeRequest( + $"GET /echo HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nCookie: {pairs}\r\n\r\n"); + }, + Expected = new ExpectedBehavior + { + Description = "2xx or 400/431", + CustomValidator = (response, state) => + { + if (response is null) + return state == ConnectionState.ClosedByServer ? TestVerdict.Pass : TestVerdict.Fail; + if (response.StatusCode is (>= 200 and < 300) or 400 or 431) + return TestVerdict.Pass; + return TestVerdict.Fail; // 500 = crash + } + }, + BehavioralAnalyzer = response => + { + if (response is null) return null; + if (response.StatusCode is >= 200 and < 300) return "Accepted 1000 cookie pairs"; + if (response.StatusCode is 400 or 431) return "Rejected 1000 cookie pairs"; + return $"Unexpected: {response.StatusCode}"; + } + }; + + yield return new TestCase + { + Id = "COOK-MALFORMED", + Description = "Completely malformed cookie value (===;;;) — tests parser crash resilience", + Category = TestCategory.Cookies, + Scored = false, + RfcLevel = RfcLevel.NotApplicable, + PayloadFactory = ctx => MakeRequest( + $"GET /echo HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nCookie: ===;;;\r\n\r\n"), + Expected = new ExpectedBehavior + { + Description = "2xx or 400", + CustomValidator = (response, state) => + { + if (response is null) + return state == ConnectionState.ClosedByServer ? TestVerdict.Pass : TestVerdict.Fail; + if (response.StatusCode is (>= 200 and < 300) or 400) + return TestVerdict.Pass; + return TestVerdict.Fail; // 500 = crash + } + }, + BehavioralAnalyzer = response => + { + if (response is null) return null; + if (response.StatusCode is >= 200 and < 300) return "Accepted malformed cookie"; + if (response.StatusCode == 400) return "Rejected malformed cookie"; + return $"Unexpected: {response.StatusCode}"; + } + }; + + yield return new TestCase + { + Id = "COOK-MULTI-HEADER", + Description = "Two separate Cookie headers — should be folded per RFC 6265 §5.4", + Category = TestCategory.Cookies, + Scored = false, + RfcLevel = RfcLevel.NotApplicable, + PayloadFactory = ctx => MakeRequest( + $"GET /echo HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nCookie: a=1\r\nCookie: b=2\r\n\r\n"), + Expected = new ExpectedBehavior + { + Description = "2xx with both cookies", + CustomValidator = (response, state) => + { + if (response is null) + return state == ConnectionState.ClosedByServer ? TestVerdict.Warn : TestVerdict.Fail; + if (response.StatusCode is >= 200 and < 300) + { + var body = response.Body ?? ""; + var hasA = body.Contains("a=1"); + var hasB = body.Contains("b=2"); + return hasA && hasB ? TestVerdict.Pass : TestVerdict.Warn; + } + if (response.StatusCode == 400) + return TestVerdict.Warn; // Rejected but didn't crash + return TestVerdict.Fail; // 500 = crash + } + }, + BehavioralAnalyzer = response => + { + if (response is null) return null; + if (response.StatusCode is >= 200 and < 300) + { + var body = response.Body ?? ""; + var hasA = body.Contains("a=1"); + var hasB = body.Contains("b=2"); + if (hasA && hasB) return "Both cookies echoed"; + if (hasA || hasB) return "Only one cookie echoed"; + return "Neither cookie echoed"; + } + if (response.StatusCode == 400) return "Rejected multiple Cookie headers"; + return $"Unexpected: {response.StatusCode}"; + } + }; + + // ── Parsed-cookie tests (target /cookie, AspNetMinimal only) ─ + + yield return new TestCase + { + Id = "COOK-PARSED-BASIC", + Description = "Basic cookie parsed correctly by framework", + Category = TestCategory.Cookies, + Scored = false, + RfcLevel = RfcLevel.NotApplicable, + PayloadFactory = ctx => MakeRequest( + $"GET /cookie HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nCookie: foo=bar\r\n\r\n"), + Expected = new ExpectedBehavior + { + Description = "2xx with foo=bar in body", + CustomValidator = MakeParsedValidator("foo=bar") + }, + BehavioralAnalyzer = MakeParsedAnalyzer("foo=bar") + }; + + yield return new TestCase + { + Id = "COOK-PARSED-MULTI", + Description = "Multiple cookies parsed correctly by framework", + Category = TestCategory.Cookies, + Scored = false, + RfcLevel = RfcLevel.NotApplicable, + PayloadFactory = ctx => MakeRequest( + $"GET /cookie HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nCookie: a=1; b=2; c=3\r\n\r\n"), + Expected = new ExpectedBehavior + { + Description = "2xx with a=1, b=2, c=3 in body", + CustomValidator = MakeParsedValidator("a=1", "b=2", "c=3") + }, + BehavioralAnalyzer = MakeParsedAnalyzer("a=1", "b=2", "c=3") + }; + + yield return new TestCase + { + Id = "COOK-PARSED-EMPTY-VAL", + Description = "Cookie with empty value parsed without crash", + Category = TestCategory.Cookies, + Scored = false, + RfcLevel = RfcLevel.NotApplicable, + PayloadFactory = ctx => MakeRequest( + $"GET /cookie HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nCookie: foo=\r\n\r\n"), + Expected = new ExpectedBehavior + { + Description = "2xx (no crash)", + CustomValidator = (response, state) => + { + if (response is null) + return state == ConnectionState.ClosedByServer ? TestVerdict.Warn : TestVerdict.Fail; + if (response.StatusCode == 404) + return TestVerdict.Warn; // endpoint not available + if (response.StatusCode is >= 200 and < 300) + return TestVerdict.Pass; + if (response.StatusCode == 400) + return TestVerdict.Pass; // rejected but didn't crash + return TestVerdict.Fail; // 500 = crash + } + }, + BehavioralAnalyzer = response => + { + if (response is null) return null; + if (response.StatusCode == 404) return "Endpoint not available"; + if (response.StatusCode is >= 200 and < 300) + { + var body = response.Body ?? ""; + return body.Contains("foo=") ? "Parsed foo= (empty value)" : "Survived (cookie may have been dropped)"; + } + if (response.StatusCode == 400) return "Rejected empty cookie value"; + return $"Unexpected: {response.StatusCode}"; + } + }; + + yield return new TestCase + { + Id = "COOK-PARSED-SPECIAL", + Description = "Cookies with spaces and = in values — tests framework parser edge cases", + Category = TestCategory.Cookies, + Scored = false, + RfcLevel = RfcLevel.NotApplicable, + PayloadFactory = ctx => MakeRequest( + $"GET /cookie HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nCookie: a=hello world; b=x=y\r\n\r\n"), + Expected = new ExpectedBehavior + { + Description = "2xx (no crash)", + CustomValidator = (response, state) => + { + if (response is null) + return state == ConnectionState.ClosedByServer ? TestVerdict.Warn : TestVerdict.Fail; + if (response.StatusCode == 404) + return TestVerdict.Warn; // endpoint not available + if (response.StatusCode is >= 200 and < 300) + return TestVerdict.Pass; + if (response.StatusCode == 400) + return TestVerdict.Pass; // rejected but didn't crash + return TestVerdict.Fail; // 500 = crash + } + }, + BehavioralAnalyzer = response => + { + if (response is null) return null; + if (response.StatusCode == 404) return "Endpoint not available"; + if (response.StatusCode is >= 200 and < 300) + { + var body = response.Body ?? ""; + var hasA = body.Contains("a="); + var hasB = body.Contains("b="); + if (hasA && hasB) return "Both cookies parsed"; + if (hasA || hasB) return "Partially parsed"; + return "Survived but no cookies parsed"; + } + if (response.StatusCode == 400) return "Rejected special characters in cookie"; + return $"Unexpected: {response.StatusCode}"; + } + }; + } + + private static Func MakeParsedValidator( + params string[] expectedPairs) + { + return (response, state) => + { + if (response is null) + return state == ConnectionState.ClosedByServer ? TestVerdict.Warn : TestVerdict.Fail; + if (response.StatusCode == 404) + return TestVerdict.Warn; // endpoint not available on this server + if (response.StatusCode is >= 200 and < 300) + { + var body = response.Body ?? ""; + return expectedPairs.All(p => body.Contains(p)) + ? TestVerdict.Pass + : TestVerdict.Fail; + } + if (response.StatusCode == 400) + return TestVerdict.Warn; + return TestVerdict.Fail; // 500 = crash + }; + } + + private static Func MakeParsedAnalyzer(params string[] expectedPairs) + { + return response => + { + if (response is null) return null; + if (response.StatusCode == 404) return "Endpoint not available"; + if (response.StatusCode is >= 200 and < 300) + { + var body = response.Body ?? ""; + var found = expectedPairs.Count(p => body.Contains(p)); + return found == expectedPairs.Length + ? $"All {found} cookie(s) parsed" + : $"{found}/{expectedPairs.Length} cookie(s) found"; + } + if (response.StatusCode == 400) return "Rejected"; + return $"Unexpected: {response.StatusCode}"; + }; + } + + private static byte[] MakeRequest(string request) => Encoding.ASCII.GetBytes(request); +} diff --git a/src/Http11Probe/TestCases/TestCategory.cs b/src/Http11Probe/TestCases/TestCategory.cs index 87e77d7..8e29199 100644 --- a/src/Http11Probe/TestCases/TestCategory.cs +++ b/src/Http11Probe/TestCases/TestCategory.cs @@ -8,5 +8,6 @@ public enum TestCategory ResourceLimits, Injection, Normalization, - Capabilities + Capabilities, + Cookies } diff --git a/src/Servers/AspNetMinimal/Program.cs b/src/Servers/AspNetMinimal/Program.cs index b0c5804..13b8220 100644 --- a/src/Servers/AspNetMinimal/Program.cs +++ b/src/Servers/AspNetMinimal/Program.cs @@ -30,4 +30,12 @@ return Results.Text(sb.ToString()); }); +app.Map("/cookie", (HttpContext ctx) => +{ + var sb = new System.Text.StringBuilder(); + foreach (var cookie in ctx.Request.Cookies) + sb.AppendLine($"{cookie.Key}={cookie.Value}"); + return Results.Text(sb.ToString()); +}); + app.Run(); From a65d6d828576cf9cc98cd26a661d14b249dd0177 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 16 Feb 2026 23:24:04 +0000 Subject: [PATCH 2/3] Multiple UI improvements, add cookie to all servers --- AGENTS.md | 16 ++- docs/content/_index.md | 1 + docs/content/add-a-test.md | 4 +- docs/content/add-with-ai-agent.md | 2 +- docs/content/servers/actix.md | 54 +++++++- docs/content/servers/apache.md | 61 ++++++++- docs/content/servers/aspnet-minimal.md | 56 ++++++++- docs/content/servers/bun.md | 50 +++++++- docs/content/servers/caddy.md | 61 ++++++++- docs/content/servers/deno.md | 50 +++++++- docs/content/servers/embedio.md | 47 ++++++- docs/content/servers/envoy.md | 53 +++++++- docs/content/servers/express.md | 51 +++++++- docs/content/servers/fastendpoints.md | 64 +++++++++- docs/content/servers/fasthttp.md | 50 +++++++- docs/content/servers/flask.md | 47 ++++++- docs/content/servers/genhttp.md | 118 ++++++++++++------ docs/content/servers/gin.md | 51 +++++++- docs/content/servers/glyph.md | 58 ++++++++- docs/content/servers/gunicorn.md | 52 +++++++- docs/content/servers/h2o.md | 52 +++++++- docs/content/servers/haproxy.md | 83 +++++++++++- docs/content/servers/hyper.md | 56 ++++++++- docs/content/servers/jetty.md | 57 ++++++++- docs/content/servers/lighttpd.md | 64 +++++++++- docs/content/servers/nancy.md | 82 ------------ docs/content/servers/netcoreserver.md | 59 ++++++++- docs/content/servers/nginx.md | 76 ++++++++++- docs/content/servers/node.md | 52 +++++++- docs/content/servers/ntex.md | 54 +++++++- docs/content/servers/php.md | 48 ++++++- docs/content/servers/pingora.md | 64 +++++++++- docs/content/servers/puma.md | 52 +++++++- docs/content/servers/quarkus.md | 71 ++++++++++- docs/content/servers/servicestack.md | 47 ++++++- docs/content/servers/simplew.md | 61 ++++++++- docs/content/servers/sisk.md | 61 ++++++++- docs/content/servers/spring-boot.md | 52 +++++++- docs/content/servers/tomcat.md | 68 +++++++++- docs/content/servers/traefik.md | 87 +++++++++++-- docs/content/servers/uvicorn.md | 64 +++++++++- docs/static/probe/render.js | 115 ++++++++++++++++- src/Http11Probe.Cli/Reporting/DocsUrlMap.cs | 7 ++ src/Servers/ActixServer/src/main.rs | 14 +++ src/Servers/ApacheServer/Dockerfile | 3 +- src/Servers/ApacheServer/cookie.cgi | 8 ++ src/Servers/ApacheServer/httpd-probe.conf | 1 + src/Servers/BunServer/server.ts | 10 ++ src/Servers/CaddyServer/Caddyfile | 9 ++ src/Servers/CaddyServer/Dockerfile | 1 + src/Servers/CaddyServer/cookie.html | 2 + src/Servers/DenoServer/server.ts | 10 ++ src/Servers/EmbedIOServer/Program.cs | 7 ++ src/Servers/EnvoyServer/envoy.yaml | 13 ++ src/Servers/ExpressServer/server.js | 11 ++ src/Servers/FastEndpointsServer/Program.cs | 20 +++ src/Servers/FastHttpServer/main.go | 10 ++ src/Servers/FlaskServer/app.py | 7 ++ src/Servers/GenHttpServer/Program.cs | 19 +++ src/Servers/GinServer/main.go | 11 ++ src/Servers/GlyphServer/Program.cs | 18 +++ src/Servers/GunicornServer/app.py | 12 ++ src/Servers/H2OServer/h2o.conf | 12 ++ src/Servers/HAProxyServer/echo.lua | 21 ++++ src/Servers/HAProxyServer/haproxy.cfg | 4 + src/Servers/HyperServer/src/main.rs | 16 +++ .../src/main/java/server/Application.java | 17 ++- src/Servers/LighttpdServer/Dockerfile | 3 +- src/Servers/LighttpdServer/cookie.cgi | 8 ++ src/Servers/LighttpdServer/lighttpd.conf | 2 +- src/Servers/NancyServer/Dockerfile | 11 -- src/Servers/NancyServer/NancyServer.csproj | 13 -- src/Servers/NancyServer/Program.cs | 54 -------- src/Servers/NetCoreServerFramework/Program.cs | 19 +++ src/Servers/NginxServer/echo.js | 18 ++- src/Servers/NginxServer/nginx.conf | 4 + src/Servers/NodeServer/server.js | 12 +- src/Servers/NtexServer/src/main.rs | 14 +++ src/Servers/PhpServer/index.php | 8 ++ src/Servers/PingoraServer/src/main.rs | 24 ++++ src/Servers/PumaServer/config.ru | 12 ++ .../src/main/java/server/Application.java | 31 +++++ src/Servers/ServiceStackServer/Program.cs | 7 ++ src/Servers/SimpleWServer/Program.cs | 21 ++++ src/Servers/SiskServer/Program.cs | 21 ++++ .../src/main/java/server/Application.java | 12 ++ .../TomcatServer/webapp/WEB-INF/web.xml | 9 ++ src/Servers/TomcatServer/webapp/cookie.jsp | 8 ++ src/Servers/TraefikServer/dynamic.yml | 6 + src/Servers/TraefikServer/echo/main.go | 12 ++ src/Servers/UvicornServer/app.py | 24 ++++ 91 files changed, 2684 insertions(+), 303 deletions(-) delete mode 100644 docs/content/servers/nancy.md create mode 100755 src/Servers/ApacheServer/cookie.cgi create mode 100644 src/Servers/CaddyServer/cookie.html create mode 100755 src/Servers/LighttpdServer/cookie.cgi delete mode 100644 src/Servers/NancyServer/Dockerfile delete mode 100644 src/Servers/NancyServer/NancyServer.csproj delete mode 100644 src/Servers/NancyServer/Program.cs create mode 100644 src/Servers/TomcatServer/webapp/cookie.jsp diff --git a/AGENTS.md b/AGENTS.md index 45a0da9..43cfd2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ Choose the correct suite file based on category: | Smuggling | `src/Http11Probe/TestCases/Suites/SmugglingSuite.cs` | | Malformed Input | `src/Http11Probe/TestCases/Suites/MalformedInputSuite.cs` | | Normalization | `src/Http11Probe/TestCases/Suites/NormalizationSuite.cs` | +| Cookies | `src/Http11Probe/TestCases/Suites/CookieSuite.cs` | Append a `yield return new TestCase { ... };` inside the `GetTestCases()` method. Here is the full schema: @@ -57,6 +58,7 @@ yield return new TestCase | `SMUG-` | Smuggling | | `MAL-` | Malformed Input | | `NORM-` | Normalization | +| `COOK-` | Cookies | | `RFC9112-X.X-` or `RFC9110-X.X-` | Compliance (maps directly to an RFC section) | **Validation patterns — choose ONE:** @@ -137,6 +139,7 @@ This step is **only needed** for `COMP-*` and `RFC*` prefixed tests. The followi - `SMUG-XYZ` → `smuggling/xyz` (lowercased) - `MAL-XYZ` → `malformed-input/xyz` (lowercased) - `NORM-XYZ` → `normalization/xyz` (lowercased) +- `COOK-XYZ` → `cookies/xyz` (lowercased) For compliance tests, add an entry to the `ComplianceSlugs` dictionary: ```csharp @@ -166,6 +169,7 @@ Category slug mapping: | Smuggling | `smuggling` | | Malformed Input | `malformed-input` | | Normalization | `normalization` | +| Cookies | `cookies` | Use this exact template: @@ -272,7 +276,8 @@ Your server MUST listen on **port 8080** and implement these endpoints: | `/` | `HEAD` | Return `200 OK` with no body | | `/` | `POST` | Read the full request body and return it in the response body | | `/` | `OPTIONS` | Return `200 OK` | -| `/echo` | `POST` | Return all received request headers in the response body, one per line as `Name: Value` | +| `/echo` | `GET`, `POST` | Return all received request headers in the response body, one per line as `Name: Value` | +| `/cookie` | `GET`, `POST` | Parse the `Cookie` header and return each cookie as `name=value` on its own line | The `/echo` endpoint is critical for normalization tests. It must echo back all headers the server received, preserving the names as the server internally represents them. @@ -283,6 +288,14 @@ Content-Length: 11 Content-Type: text/plain ``` +The `/cookie` endpoint is used by the Cookies test suite. It must split the `Cookie` header on `;`, trim leading whitespace from each pair, find the first `=`, and output `name=value\n` for each cookie. + +Example — given `Cookie: foo=bar; baz=qux`, the response body should be: +``` +foo=bar +baz=qux +``` + ### Step 3 — Add a Dockerfile Create `src/Servers/YourServer/Dockerfile` that builds and runs the server. @@ -352,6 +365,7 @@ Rules: - `curl http://localhost:8080/` returns 200 - `curl -X POST -d "hello" http://localhost:8080/` returns "hello" - `curl -X POST -d "test" http://localhost:8080/echo` returns headers + - `curl -H "Cookie: foo=bar; baz=qux" http://localhost:8080/cookie` returns `foo=bar` and `baz=qux` on separate lines 4. Run the probe: `dotnet run --project src/Http11Probe.Cli -- --host localhost --port 8080` No changes to CI workflows, configs, or other files are needed. The pipeline auto-discovers servers from `src/Servers/*/probe.json`. diff --git a/docs/content/_index.md b/docs/content/_index.md index aee80c9..a56fe66 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -43,6 +43,7 @@ Http11Probe sends a suite of crafted HTTP requests to each server and checks whe {{< card link="malformed-input" title="Robustness" subtitle="Binary garbage, oversized fields, too many headers, control characters, integer overflow, incomplete requests." icon="lightning-bolt" >}} {{< card link="normalization" title="Normalization" subtitle="Header normalization behavior — underscore-to-hyphen, space before colon, tab in name, case folding on Transfer-Encoding." icon="adjustments" >}} {{< card link="caching" title="Caching" subtitle="Conditional request support — ETag, Last-Modified, If-None-Match precedence, weak comparison, edge cases." icon="beaker" >}} + {{< card link="cookies" title="Cookies" subtitle="Cookie header parsing resilience — oversized values, NUL bytes, control characters, malformed pairs, multiple headers." icon="cake" >}} {{< /cards >}}
diff --git a/docs/content/add-a-test.md b/docs/content/add-a-test.md index 22f35fc..2221391 100644 --- a/docs/content/add-a-test.md +++ b/docs/content/add-a-test.md @@ -14,6 +14,7 @@ Pick the suite that matches your test's category and add a `yield return new Tes | Smuggling | `src/Http11Probe/TestCases/Suites/SmugglingSuite.cs` | | Malformed Input | `src/Http11Probe/TestCases/Suites/MalformedInputSuite.cs` | | Normalization | `src/Http11Probe/TestCases/Suites/NormalizationSuite.cs` | +| Cookies | `src/Http11Probe/TestCases/Suites/CookieSuite.cs` | ```csharp yield return new TestCase @@ -46,6 +47,7 @@ yield return new TestCase | `SMUG-` | Smuggling | | `MAL-` | Malformed Input | | `NORM-` | Normalization | +| `COOK-` | Cookies | | `RFC9112-...` or `RFC9110-...` | Compliance (when the test maps directly to a specific RFC section) | ### Validation options @@ -97,7 +99,7 @@ Expected = new ExpectedBehavior **File:** `src/Http11Probe.Cli/Reporting/DocsUrlMap.cs` -Tests prefixed with `SMUG-`, `MAL-`, or `NORM-` are auto-mapped to their doc URL based on the ID. For example, `SMUG-CL-TE-BOTH` maps to `smuggling/cl-te-both`. +Tests prefixed with `SMUG-`, `MAL-`, `NORM-`, or `COOK-` are auto-mapped to their doc URL based on the ID. For example, `SMUG-CL-TE-BOTH` maps to `smuggling/cl-te-both`. For `COMP-*` or `RFC*` prefixed tests, add an entry to the `ComplianceSlugs` dictionary: diff --git a/docs/content/add-with-ai-agent.md b/docs/content/add-with-ai-agent.md index bc3f7ac..91ee354 100644 --- a/docs/content/add-with-ai-agent.md +++ b/docs/content/add-with-ai-agent.md @@ -34,7 +34,7 @@ For a new **test**, the agent will: For a new **framework**, the agent will: 1. Create a server directory under `src/Servers/` -2. Implement the server with all required endpoints (GET, HEAD, POST, OPTIONS on `/` and POST on `/echo`) +2. Implement the server with all required endpoints (GET, HEAD, POST, OPTIONS on `/`, GET/POST on `/echo`, and GET/POST on `/cookie`) 3. Write a Dockerfile that builds and runs the server on port 8080 4. Add a `probe.json` with the display name diff --git a/docs/content/servers/actix.md b/docs/content/servers/actix.md index 2a17a2f..788b292 100644 --- a/docs/content/servers/actix.md +++ b/docs/content/servers/actix.md @@ -1,6 +1,6 @@ --- title: "Actix" -toc: false +toc: true breadcrumbs: false --- @@ -24,7 +24,7 @@ COPY --from=build /src/target/release/actix-server /usr/local/bin/ ENTRYPOINT ["actix-server", "8080"] ``` -## Source — `src/main.rs` +## Source ```rust use actix_web::{web, App, HttpServer, HttpRequest, HttpResponse, Responder}; @@ -37,6 +37,19 @@ async fn echo(req: HttpRequest) -> impl Responder { HttpResponse::Ok().content_type("text/plain").body(body) } +async fn cookie(req: HttpRequest) -> impl Responder { + let mut body = String::new(); + if let Some(raw) = req.headers().get("cookie").and_then(|v| v.to_str().ok()) { + for pair in raw.split(';') { + let trimmed = pair.trim_start(); + if let Some(eq) = trimmed.find('=') { + body.push_str(&format!("{}={}\n", &trimmed[..eq], &trimmed[eq+1..])); + } + } + } + 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 { HttpResponse::Ok() @@ -59,6 +72,7 @@ async fn main() -> std::io::Result<()> { HttpServer::new(|| { App::new() .route("/echo", web::to(echo)) + .route("/cookie", web::to(cookie)) .default_service(web::to(handler)) }) .bind(("0.0.0.0", port))? @@ -66,3 +80,39 @@ async fn main() -> std::io::Result<()> { .await } ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/apache.md b/docs/content/servers/apache.md index 6e7a675..505a916 100644 --- a/docs/content/servers/apache.md +++ b/docs/content/servers/apache.md @@ -1,6 +1,6 @@ --- title: "Apache" -toc: false +toc: true breadcrumbs: false --- @@ -14,10 +14,13 @@ FROM httpd:2.4 COPY src/Servers/ApacheServer/httpd-probe.conf /usr/local/apache2/conf/httpd.conf RUN echo "OK" > /usr/local/apache2/htdocs/index.html COPY src/Servers/ApacheServer/echo.cgi /usr/local/apache2/cgi-bin/echo.cgi -RUN chmod +x /usr/local/apache2/cgi-bin/echo.cgi +COPY src/Servers/ApacheServer/cookie.cgi /usr/local/apache2/cgi-bin/cookie.cgi +RUN chmod +x /usr/local/apache2/cgi-bin/echo.cgi /usr/local/apache2/cgi-bin/cookie.cgi ``` -## Source — `httpd-probe.conf` +## Source + +**`httpd-probe.conf`** ```apache ServerRoot "/usr/local/apache2" @@ -40,13 +43,14 @@ DocumentRoot "/usr/local/apache2/htdocs" ScriptAlias /echo /usr/local/apache2/cgi-bin/echo.cgi +ScriptAlias /cookie /usr/local/apache2/cgi-bin/cookie.cgi Require all granted ``` -## Source — `echo.cgi` +**`echo.cgi`** ```bash #!/bin/sh @@ -62,3 +66,52 @@ if [ -n "$CONTENT_LENGTH" ]; then printf 'Content-Length: %s\n' "$CONTENT_LENGTH" fi ``` + +**`cookie.cgi`** + +```bash +#!/bin/sh +printf 'Content-Type: text/plain\r\n\r\n' +if [ -n "$HTTP_COOKIE" ]; then + echo "$HTTP_COOKIE" | tr ';' '\n' | while read -r pair; do + trimmed=$(echo "$pair" | sed 's/^ *//') + printf '%s\n' "$trimmed" + done +fi +``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/aspnet-minimal.md b/docs/content/servers/aspnet-minimal.md index 018238a..a4b94ff 100644 --- a/docs/content/servers/aspnet-minimal.md +++ b/docs/content/servers/aspnet-minimal.md @@ -1,6 +1,6 @@ --- title: "ASP.NET Minimal" -toc: false +toc: true breadcrumbs: false --- @@ -22,7 +22,7 @@ COPY --from=build /app . ENTRYPOINT ["dotnet", "AspNetMinimal.dll"] ``` -## Source — `Program.cs` +## Source ```csharp var builder = WebApplication.CreateBuilder(args); @@ -33,6 +33,14 @@ var app = builder.Build(); app.MapGet("/", () => "OK"); +app.MapMethods("/", ["HEAD"], () => Results.Ok()); + +app.MapMethods("/", ["OPTIONS"], (HttpContext ctx) => +{ + ctx.Response.Headers["Allow"] = "GET, HEAD, POST, OPTIONS"; + return Results.Ok(); +}); + app.MapPost("/", async (HttpContext ctx) => { using var reader = new StreamReader(ctx.Request.Body); @@ -49,5 +57,49 @@ app.Map("/echo", (HttpContext ctx) => return Results.Text(sb.ToString()); }); +app.Map("/cookie", (HttpContext ctx) => +{ + var sb = new System.Text.StringBuilder(); + foreach (var cookie in ctx.Request.Cookies) + sb.AppendLine($"{cookie.Key}={cookie.Value}"); + return Results.Text(sb.ToString()); +}); + app.Run(); ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/bun.md b/docs/content/servers/bun.md index 252cfe9..6810e37 100644 --- a/docs/content/servers/bun.md +++ b/docs/content/servers/bun.md @@ -1,6 +1,6 @@ --- title: "Bun" -toc: false +toc: true breadcrumbs: false --- @@ -15,7 +15,7 @@ COPY src/Servers/BunServer/server.ts . ENTRYPOINT ["bun", "run", "server.ts", "8080"] ``` -## Source — `server.ts` +## Source ```typescript const port = parseInt(Bun.argv[2] || "8080", 10); @@ -32,6 +32,16 @@ Bun.serve({ } return new Response(body, { headers: { "Content-Type": "text/plain" } }); } + if (url.pathname === "/cookie") { + let body = ""; + const raw = req.headers.get("cookie") || ""; + for (const pair of raw.split(";")) { + const trimmed = pair.trimStart(); + const eq = trimmed.indexOf("="); + if (eq > 0) body += trimmed.substring(0, eq) + "=" + trimmed.substring(eq + 1) + "\n"; + } + return new Response(body, { headers: { "Content-Type": "text/plain" } }); + } if (req.method === "POST") { const body = await req.text(); return new Response(body); @@ -42,3 +52,39 @@ Bun.serve({ console.log(`Bun listening on 127.0.0.1:${port}`); ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/caddy.md b/docs/content/servers/caddy.md index cc2e985..4f4f609 100644 --- a/docs/content/servers/caddy.md +++ b/docs/content/servers/caddy.md @@ -1,6 +1,6 @@ --- title: "Caddy" -toc: false +toc: true breadcrumbs: false --- @@ -12,9 +12,12 @@ breadcrumbs: false FROM caddy:2 COPY src/Servers/CaddyServer/Caddyfile /etc/caddy/Caddyfile COPY src/Servers/CaddyServer/echo.html /srv/echo.html +COPY src/Servers/CaddyServer/cookie.html /srv/cookie.html ``` -## Source — `Caddyfile` +## Source + +**`Caddyfile`** ```text :8080 { @@ -39,13 +42,65 @@ COPY src/Servers/CaddyServer/echo.html /srv/echo.html file_server } + handle /cookie { + root * /srv + templates { + mime text/plain + } + rewrite * /cookie.html + file_server + } + respond "OK" 200 } ``` -## Source — `echo.html` +**`echo.html`** ```html {{range $key, $vals := .Req.Header}}{{range $vals}}{{$key}}: {{.}} {{end}}{{end}} ``` + +**`cookie.html`** + +```html +{{range .Req.Header.Cookie}}{{range splitList ";" .}}{{trim .}} +{{end}}{{end}} +``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/deno.md b/docs/content/servers/deno.md index c4d8283..a27e37c 100644 --- a/docs/content/servers/deno.md +++ b/docs/content/servers/deno.md @@ -1,6 +1,6 @@ --- title: "Deno" -toc: false +toc: true breadcrumbs: false --- @@ -17,7 +17,7 @@ EXPOSE 8080 CMD ["deno", "run", "--allow-net", "server.ts"] ``` -## Source — `server.ts` +## Source ```typescript Deno.serve({ port: 8080, hostname: "0.0.0.0" }, async (req) => { @@ -29,6 +29,16 @@ Deno.serve({ port: 8080, hostname: "0.0.0.0" }, async (req) => { } return new Response(body, { headers: { "content-type": "text/plain" } }); } + if (url.pathname === "/cookie") { + let body = ""; + const raw = req.headers.get("cookie") || ""; + for (const pair of raw.split(";")) { + const trimmed = pair.trimStart(); + const eq = trimmed.indexOf("="); + if (eq > 0) body += trimmed.substring(0, eq) + "=" + trimmed.substring(eq + 1) + "\n"; + } + return new Response(body, { headers: { "content-type": "text/plain" } }); + } if (req.method === "POST") { const body = await req.text(); return new Response(body, { headers: { "content-type": "text/plain" } }); @@ -36,3 +46,39 @@ Deno.serve({ port: 8080, hostname: "0.0.0.0" }, async (req) => { return new Response("OK", { headers: { "content-type": "text/plain" } }); }); ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/embedio.md b/docs/content/servers/embedio.md index 500f04e..296f5e8 100644 --- a/docs/content/servers/embedio.md +++ b/docs/content/servers/embedio.md @@ -1,6 +1,6 @@ --- title: "EmbedIO" -toc: false +toc: true breadcrumbs: false --- @@ -22,7 +22,7 @@ COPY --from=build /app . ENTRYPOINT ["dotnet", "EmbedIOServer.dll", "8080"] ``` -## Source — `Program.cs` +## Source ```csharp using EmbedIO; @@ -34,6 +34,13 @@ var url = $"http://*:{port}/"; using var server = new WebServer(o => o .WithUrlPrefix(url) .WithMode(HttpListenerMode.EmbedIO)) + .WithModule(new ActionModule("/cookie", HttpVerbs.Any, async ctx => + { + var sb = new System.Text.StringBuilder(); + foreach (System.Net.Cookie cookie in ctx.Request.Cookies) + sb.AppendLine($"{cookie.Name}={cookie.Value}"); + await ctx.SendStringAsync(sb.ToString(), "text/plain", System.Text.Encoding.UTF8); + })) .WithModule(new ActionModule("/echo", HttpVerbs.Any, async ctx => { var sb = new System.Text.StringBuilder(); @@ -60,3 +67,39 @@ using var server = new WebServer(o => o Console.WriteLine($"EmbedIO listening on http://localhost:{port}"); await server.RunAsync(); ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/envoy.md b/docs/content/servers/envoy.md index 1659c2f..9a6e997 100644 --- a/docs/content/servers/envoy.md +++ b/docs/content/servers/envoy.md @@ -1,6 +1,6 @@ --- title: "Envoy" -toc: false +toc: true breadcrumbs: false --- @@ -13,7 +13,7 @@ FROM envoyproxy/envoy:v1.32-latest COPY src/Servers/EnvoyServer/envoy.yaml /etc/envoy/envoy.yaml ``` -## Source — `envoy.yaml` +## Source ```yaml static_resources: @@ -46,6 +46,19 @@ static_resources: end end request_handle:respond({[":status"] = "200", ["content-type"] = "text/plain"}, body) + elseif path == "/cookie" then + local body = "" + local raw = request_handle:headers():get("cookie") + if raw then + for pair in raw:gmatch("[^;]+") do + local trimmed = pair:match("^%s*(.*)") + local eq = trimmed:find("=") + if eq and eq > 1 then + body = body .. trimmed:sub(1, eq-1) .. "=" .. trimmed:sub(eq+1) .. "\n" + end + end + end + request_handle:respond({[":status"] = "200", ["content-type"] = "text/plain"}, body) end end - name: envoy.filters.http.router @@ -63,3 +76,39 @@ static_resources: body: inline_string: "OK" ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/express.md b/docs/content/servers/express.md index b319b72..79fce94 100644 --- a/docs/content/servers/express.md +++ b/docs/content/servers/express.md @@ -1,6 +1,6 @@ --- title: "Express" -toc: false +toc: true breadcrumbs: false --- @@ -17,7 +17,7 @@ COPY src/Servers/ExpressServer/server.js . ENTRYPOINT ["node", "server.js", "8080"] ``` -## Source — `server.js` +## Source ```javascript const express = require("express"); @@ -35,6 +35,17 @@ app.post("/", (req, res) => { req.on("end", () => res.send(Buffer.concat(chunks))); }); +app.all('/cookie', (req, res) => { + let body = ''; + const raw = req.headers.cookie || ''; + for (const pair of raw.split(';')) { + const trimmed = pair.trimStart(); + const eq = trimmed.indexOf('='); + if (eq > 0) body += trimmed.substring(0, eq) + '=' + trimmed.substring(eq + 1) + '\n'; + } + res.set('Content-Type', 'text/plain').send(body); +}); + app.all('/echo', (req, res) => { let body = ''; for (const [name, value] of Object.entries(req.headers)) { @@ -48,3 +59,39 @@ app.listen(port, "127.0.0.1", () => { console.log(`Express listening on 127.0.0.1:${port}`); }); ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/fastendpoints.md b/docs/content/servers/fastendpoints.md index c56352b..e226913 100644 --- a/docs/content/servers/fastendpoints.md +++ b/docs/content/servers/fastendpoints.md @@ -1,6 +1,6 @@ --- title: "FastEndpoints" -toc: false +toc: true breadcrumbs: false --- @@ -22,7 +22,9 @@ COPY --from=build /app . ENTRYPOINT ["dotnet", "FastEndpointsServer.dll"] ``` -## Source — `Program.cs` +## Source + +**`Program.cs`** ```csharp using FastEndpoints; @@ -109,6 +111,26 @@ sealed class OptionsRoot : EndpointWithoutRequest } } +// ── GET/POST /cookie ────────────────────────────────────────── + +sealed class CookieEndpoint : EndpointWithoutRequest +{ + public override void Configure() + { + Verbs("GET", "POST"); + Routes("/cookie"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var sb = new System.Text.StringBuilder(); + foreach (var cookie in HttpContext.Request.Cookies) + sb.AppendLine($"{cookie.Key}={cookie.Value}"); + await HttpContext.Response.WriteAsync(sb.ToString(), ct); + } +} + // ── POST /echo ───────────────────────────────────────────────── sealed class PostEcho : EndpointWithoutRequest @@ -130,7 +152,7 @@ sealed class PostEcho : EndpointWithoutRequest } ``` -## Source — `FastEndpointsServer.csproj` +**`FastEndpointsServer.csproj`** ```xml @@ -148,3 +170,39 @@ sealed class PostEcho : EndpointWithoutRequest ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/fasthttp.md b/docs/content/servers/fasthttp.md index 527ae19..c121358 100644 --- a/docs/content/servers/fasthttp.md +++ b/docs/content/servers/fasthttp.md @@ -1,6 +1,6 @@ --- title: "FastHTTP" -toc: false +toc: true breadcrumbs: false --- @@ -20,13 +20,14 @@ COPY --from=build /fasthttp-server /usr/local/bin/ ENTRYPOINT ["fasthttp-server", "8080"] ``` -## Source — `main.go` +## Source ```go package main import ( "os" + "strings" "github.com/valyala/fasthttp" ) @@ -45,6 +46,15 @@ func main() { ctx.Request.Header.VisitAll(func(key, value []byte) { ctx.WriteString(string(key) + ": " + string(value) + "\n") }) + case "/cookie": + ctx.SetContentType("text/plain") + raw := string(ctx.Request.Header.Peek("Cookie")) + for _, pair := range strings.Split(raw, ";") { + pair = strings.TrimLeft(pair, " ") + if eq := strings.Index(pair, "="); eq > 0 { + ctx.WriteString(pair[:eq] + "=" + pair[eq+1:] + "\n") + } + } default: if string(ctx.Method()) == "POST" { ctx.SetBody(ctx.Request.Body()) @@ -57,3 +67,39 @@ func main() { fasthttp.ListenAndServe("0.0.0.0:"+port, handler) } ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/flask.md b/docs/content/servers/flask.md index 63e5154..77b9ba3 100644 --- a/docs/content/servers/flask.md +++ b/docs/content/servers/flask.md @@ -1,6 +1,6 @@ --- title: "Flask" -toc: false +toc: true breadcrumbs: false --- @@ -16,7 +16,7 @@ COPY src/Servers/FlaskServer/app.py . ENTRYPOINT ["python3", "app.py", "8080"] ``` -## Source — `app.py` +## Source ```python import sys @@ -25,6 +25,13 @@ from werkzeug.routing import Rule app = Flask(__name__) +@app.route('/cookie', methods=['GET','POST','PUT','DELETE','PATCH','OPTIONS','HEAD']) +def cookie_endpoint(): + lines = [] + for name, value in request.cookies.items(): + lines.append(f"{name}={value}") + return '\n'.join(lines) + '\n', 200, {'Content-Type': 'text/plain'} + @app.route('/echo', methods=['GET','POST','PUT','DELETE','PATCH','OPTIONS','HEAD']) def echo(): lines = [] @@ -45,3 +52,39 @@ if __name__ == "__main__": port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080 app.run(host="0.0.0.0", port=port) ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/genhttp.md b/docs/content/servers/genhttp.md index 6c87b81..d263fc1 100644 --- a/docs/content/servers/genhttp.md +++ b/docs/content/servers/genhttp.md @@ -1,6 +1,6 @@ --- title: "GenHTTP" -toc: false +toc: true breadcrumbs: false --- @@ -22,51 +22,97 @@ COPY --from=build /app . ENTRYPOINT ["dotnet", "GenHttpServer.dll", "8080"] ``` -## Source — `Program.cs` +## Source ```csharp -using GenHTTP.Api.Content; using GenHTTP.Api.Protocol; + using GenHTTP.Engine.Internal; + using GenHTTP.Modules.Functional; -using GenHTTP.Modules.Layouting; using GenHTTP.Modules.Practices; -var port = args.Length > 0 && int.TryParse(args[0], out var p) ? p : 8080; +var port = (args.Length > 0 && ushort.TryParse(args[0], out var p)) ? p : (ushort)8080; -var echoHandler = Inline.Create() - .Any(async (IRequest request) => - { - await ValueTask.CompletedTask; - var sb = new System.Text.StringBuilder(); - foreach (var h in request.Headers) - sb.AppendLine($"{h.Key}: {h.Value}"); - return sb.ToString(); - }); - -var rootHandler = Inline.Create() - .Get(async (IRequest request) => +var app = Inline.Create() + .Get("/cookie", (IRequest request) => ParseCookies(request)) + .Post("/cookie", (IRequest request) => ParseCookies(request)) + .Get("/echo", (IRequest request) => Echo(request)) + .Post("/echo", (IRequest request) => Echo(request)) + .Post((Stream body) => RequestContent(body)) + .Any(() => StringContent()); + +return await Host.Create() + .Handler(app) + .Defaults() + .Port(port) + .RunAsync(); + +static string Echo(IRequest request) +{ + var headers = new System.Text.StringBuilder(); + + foreach (var h in request.Headers) { - await ValueTask.CompletedTask; - return "OK"; - }) - .Post(async (IRequest request) => + headers.AppendLine($"{h.Key}: {h.Value}"); + } + + return headers.ToString(); +} + +static string ParseCookies(IRequest request) +{ + var sb = new System.Text.StringBuilder(); + if (request.Headers.TryGetValue("Cookie", out var cookieHeader)) { - if (request.Content is not null) + foreach (var pair in cookieHeader.Split(';')) { - using var reader = new StreamReader(request.Content); - return await reader.ReadToEndAsync(); + var trimmed = pair.TrimStart(); + var eqIdx = trimmed.IndexOf('='); + if (eqIdx > 0) + sb.AppendLine($"{trimmed[..eqIdx]}={trimmed[(eqIdx + 1)..]}"); } - return ""; - }); - -var layout = Layout.Create() - .Add("echo", echoHandler) - .Add(rootHandler); - -await Host.Create() - .Handler(layout) - .Defaults() - .Port((ushort)port) - .RunAsync(); + } + return sb.ToString(); +} + +static string StringContent() => "OK"; + +static Stream RequestContent(Stream body) => body; ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/gin.md b/docs/content/servers/gin.md index 67bbdbc..332ae0b 100644 --- a/docs/content/servers/gin.md +++ b/docs/content/servers/gin.md @@ -1,6 +1,6 @@ --- title: "Gin" -toc: false +toc: true breadcrumbs: false --- @@ -20,7 +20,7 @@ COPY --from=build /gin-server /usr/local/bin/ ENTRYPOINT ["gin-server", "8080"] ``` -## Source — `main.go` +## Source ```go package main @@ -41,6 +41,17 @@ func main() { gin.SetMode(gin.ReleaseMode) r := gin.New() + r.Any("/cookie", func(c *gin.Context) { + var sb strings.Builder + raw := c.GetHeader("Cookie") + for _, pair := range strings.Split(raw, ";") { + pair = strings.TrimLeft(pair, " ") + if eq := strings.Index(pair, "="); eq > 0 { + sb.WriteString(pair[:eq] + "=" + pair[eq+1:] + "\n") + } + } + c.Data(200, "text/plain", []byte(sb.String())) + }) r.Any("/echo", func(c *gin.Context) { var sb strings.Builder for name, values := range c.Request.Header { @@ -61,3 +72,39 @@ func main() { r.Run("0.0.0.0:" + port) } ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/glyph.md b/docs/content/servers/glyph.md index 79d2671..9d70ee3 100644 --- a/docs/content/servers/glyph.md +++ b/docs/content/servers/glyph.md @@ -1,6 +1,6 @@ --- title: "Glyph11" -toc: false +toc: true breadcrumbs: false --- @@ -22,7 +22,7 @@ COPY --from=build /app . ENTRYPOINT ["dotnet", "GlyphServer.dll", "8080"] ``` -## Source — `Program.cs` +## Source ```csharp using System.Buffers; @@ -309,6 +309,24 @@ static byte[] BuildResponse(string method, string path, string? echoBody, List 0) + sb.AppendLine($"{trimmed[..eqIdx]}={trimmed[(eqIdx + 1)..]}"); + } + } + } + return MakeResponse(200, "OK", sb.ToString()); + } var body = method == "POST" && echoBody is not null ? echoBody : $"Hello from GlyphServer\r\nMethod: {method}\r\nPath: {path}\r\n"; @@ -332,3 +350,39 @@ static byte[] MakeErrorResponse(int status, string reason) return MakeResponse(status, reason, $"{status} {reason}\r\n"); } ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/gunicorn.md b/docs/content/servers/gunicorn.md index d065b85..a13b181 100644 --- a/docs/content/servers/gunicorn.md +++ b/docs/content/servers/gunicorn.md @@ -1,6 +1,6 @@ --- title: "Gunicorn" -toc: false +toc: true breadcrumbs: false --- @@ -17,12 +17,24 @@ EXPOSE 8080 CMD ["gunicorn", "-b", "0.0.0.0:8080", "app:app"] ``` -## Source — `app.py` +## Source ```python def app(environ, start_response): path = environ.get('PATH_INFO', '/') + if path == '/cookie': + cookie_str = environ.get('HTTP_COOKIE', '') + lines = [] + for pair in cookie_str.split(';'): + pair = pair.strip() + eq = pair.find('=') + if eq > 0: + lines.append(f"{pair[:eq]}={pair[eq+1:]}") + body = ('\n'.join(lines) + '\n').encode('utf-8') if lines else b'' + start_response('200 OK', [('Content-Type', 'text/plain')]) + return [body] + if path == '/echo': lines = [] for key, value in environ.items(): @@ -47,3 +59,39 @@ def app(environ, start_response): return [body] return [b'OK'] ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/h2o.md b/docs/content/servers/h2o.md index b6f4244..52a3958 100644 --- a/docs/content/servers/h2o.md +++ b/docs/content/servers/h2o.md @@ -1,6 +1,6 @@ --- title: "H2O" -toc: false +toc: true breadcrumbs: false --- @@ -24,7 +24,7 @@ RUN mkdir -p /var/www && echo "OK" > /var/www/index.html ENTRYPOINT ["h2o", "-c", "/etc/h2o/h2o.conf"] ``` -## Source — `h2o.conf` +## Source ```yaml listen: 8080 @@ -45,6 +45,18 @@ hosts: body += "Content-Type: #{env['CONTENT_TYPE']}\n" if env['CONTENT_TYPE'] && !env['CONTENT_TYPE'].empty? body += "Content-Length: #{env['CONTENT_LENGTH']}\n" if env['CONTENT_LENGTH'] && !env['CONTENT_LENGTH'].empty? [200, {"content-type" => "text/plain"}, [body]] + elsif env["PATH_INFO"] == "/cookie" + body = "" + if env["HTTP_COOKIE"] + env["HTTP_COOKIE"].split(";").each do |pair| + trimmed = pair.lstrip + eq = trimmed.index("=") + if eq && eq > 0 + body += "#{trimmed[0...eq]}=#{trimmed[(eq+1)..]}\n" + end + end + end + [200, {"content-type" => "text/plain"}, [body]] elsif env["REQUEST_METHOD"] == "POST" body = env["rack.input"] ? env["rack.input"].read : "" [200, {"content-type" => "text/plain"}, [body]] @@ -53,3 +65,39 @@ hosts: end } ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/haproxy.md b/docs/content/servers/haproxy.md index 49ed095..681b96f 100644 --- a/docs/content/servers/haproxy.md +++ b/docs/content/servers/haproxy.md @@ -1,6 +1,6 @@ --- title: "HAProxy" -toc: false +toc: true breadcrumbs: false --- @@ -14,7 +14,9 @@ COPY src/Servers/HAProxyServer/haproxy.cfg /usr/local/etc/haproxy/haproxy.cfg COPY src/Servers/HAProxyServer/echo.lua /usr/local/etc/haproxy/echo.lua ``` -## Source — `haproxy.cfg` +## Source + +**`haproxy.cfg`** ```text global @@ -30,13 +32,21 @@ defaults frontend http_in bind *:8080 use_backend echo_backend if { path /echo } + use_backend cookie_backend if { path /cookie } + use_backend post_echo_backend if { method POST } http-request return status 200 content-type "text/plain" string "OK" backend echo_backend http-request use-service lua.echo + +backend cookie_backend + http-request use-service lua.cookie + +backend post_echo_backend + http-request use-service lua.echo_body ``` -## Source — `echo.lua` +**`echo.lua`** ```lua core.register_service("echo", "http", function(applet) @@ -53,4 +63,71 @@ core.register_service("echo", "http", function(applet) applet:start_response() applet:send(body) end) + +core.register_service("cookie", "http", function(applet) + local body = "" + local hdrs = applet.headers + if hdrs["cookie"] then + for _, raw in ipairs(hdrs["cookie"]) do + for pair in raw:gmatch("[^;]+") do + local trimmed = pair:match("^%s*(.*)") + local eq = trimmed:find("=") + if eq and eq > 1 then + body = body .. trimmed:sub(1, eq-1) .. "=" .. trimmed:sub(eq+1) .. "\n" + end + end + end + end + applet:set_status(200) + applet:add_header("Content-Type", "text/plain") + applet:add_header("Content-Length", tostring(#body)) + applet:start_response() + applet:send(body) +end) + +core.register_service("echo_body", "http", function(applet) + local body = applet:receive() + if body == nil then body = "" end + applet:set_status(200) + applet:add_header("Content-Type", "text/plain") + applet:add_header("Content-Length", tostring(#body)) + applet:start_response() + applet:send(body) +end) ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/hyper.md b/docs/content/servers/hyper.md index a60cac3..0bebe17 100644 --- a/docs/content/servers/hyper.md +++ b/docs/content/servers/hyper.md @@ -1,6 +1,6 @@ --- title: "Hyper" -toc: false +toc: true breadcrumbs: false --- @@ -24,7 +24,7 @@ COPY --from=build /src/target/release/hyper-server /usr/local/bin/ ENTRYPOINT ["hyper-server", "8080"] ``` -## Source — `src/main.rs` +## Source ```rust use std::convert::Infallible; @@ -50,6 +50,22 @@ async fn handle(req: Request) -> Result collected.to_bytes(), @@ -85,3 +101,39 @@ async fn main() { } } ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/jetty.md b/docs/content/servers/jetty.md index fadac07..0fbfff4 100644 --- a/docs/content/servers/jetty.md +++ b/docs/content/servers/jetty.md @@ -1,6 +1,6 @@ --- title: "Jetty" -toc: false +toc: true breadcrumbs: false --- @@ -22,7 +22,7 @@ COPY --from=build /src/target/jetty-server-1.0.0.jar app.jar ENTRYPOINT ["java", "-jar", "app.jar", "8080"] ``` -## Source — `src/main/java/server/Application.java` +## Source ```java package server; @@ -48,7 +48,22 @@ public class Application extends Handler.Abstract { response.setStatus(200); response.getHeaders().put("Content-Type", "text/plain"); - if ("/echo".equals(request.getHttpURI().getPath())) { + if ("/cookie".equals(request.getHttpURI().getPath())) { + StringBuilder sb = new StringBuilder(); + for (HttpField field : request.getHeaders()) { + if ("Cookie".equalsIgnoreCase(field.getName())) { + for (String pair : field.getValue().split(";")) { + String trimmed = pair.stripLeading(); + int eq = trimmed.indexOf('='); + if (eq > 0) { + sb.append(trimmed, 0, eq).append("=").append(trimmed.substring(eq + 1)).append("\n"); + } + } + } + } + byte[] cookieBody = sb.toString().getBytes(StandardCharsets.UTF_8); + response.write(true, ByteBuffer.wrap(cookieBody), callback); + } else if ("/echo".equals(request.getHttpURI().getPath())) { StringBuilder sb = new StringBuilder(); for (HttpField field : request.getHeaders()) { sb.append(field.getName()).append(": ").append(field.getValue()).append("\n"); @@ -78,3 +93,39 @@ public class Application extends Handler.Abstract { } } ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/lighttpd.md b/docs/content/servers/lighttpd.md index f35a812..8b81b61 100644 --- a/docs/content/servers/lighttpd.md +++ b/docs/content/servers/lighttpd.md @@ -1,6 +1,6 @@ --- title: "Lighttpd" -toc: false +toc: true breadcrumbs: false --- @@ -14,12 +14,15 @@ RUN apk add --no-cache lighttpd COPY src/Servers/LighttpdServer/lighttpd.conf /etc/lighttpd/lighttpd.conf COPY src/Servers/LighttpdServer/index.cgi /var/www/index.cgi COPY src/Servers/LighttpdServer/echo.cgi /var/www/echo.cgi -RUN chmod +x /var/www/index.cgi /var/www/echo.cgi +COPY src/Servers/LighttpdServer/cookie.cgi /var/www/cookie.cgi +RUN chmod +x /var/www/index.cgi /var/www/echo.cgi /var/www/cookie.cgi EXPOSE 8080 CMD ["lighttpd", "-D", "-f", "/etc/lighttpd/lighttpd.conf"] ``` -## Source — `lighttpd.conf` +## Source + +**`lighttpd.conf`** ```text server.document-root = "/var/www" @@ -28,10 +31,10 @@ index-file.names = ("index.cgi") server.modules += ("mod_cgi", "mod_alias") cgi.assign = (".cgi" => "") server.error-handler = "/index.cgi" -alias.url = ("/echo" => "/var/www/echo.cgi") +alias.url = ("/echo" => "/var/www/echo.cgi", "/cookie" => "/var/www/cookie.cgi") ``` -## Source — `index.cgi` +**`index.cgi`** ```bash #!/bin/sh @@ -43,7 +46,7 @@ else fi ``` -## Source — `echo.cgi` +**`echo.cgi`** ```bash #!/bin/sh @@ -59,3 +62,52 @@ if [ -n "$CONTENT_LENGTH" ]; then printf 'Content-Length: %s\n' "$CONTENT_LENGTH" fi ``` + +**`cookie.cgi`** + +```bash +#!/bin/sh +printf 'Content-Type: text/plain\r\n\r\n' +if [ -n "$HTTP_COOKIE" ]; then + echo "$HTTP_COOKIE" | tr ';' '\n' | while read -r pair; do + trimmed=$(echo "$pair" | sed 's/^ *//') + printf '%s\n' "$trimmed" + done +fi +``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/nancy.md b/docs/content/servers/nancy.md deleted file mode 100644 index 6584c61..0000000 --- a/docs/content/servers/nancy.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -title: "Nancy" -toc: false -breadcrumbs: false ---- - -**Language:** C# · [View source on GitHub](https://github.com/MDA2AV/Http11Probe/tree/main/src/Servers/NancyServer) - -## Dockerfile - -```dockerfile -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build -WORKDIR /src -COPY Directory.Build.props . -COPY src/Servers/NancyServer/ src/Servers/NancyServer/ -RUN dotnet restore src/Servers/NancyServer/NancyServer.csproj -RUN dotnet publish src/Servers/NancyServer/NancyServer.csproj -c Release -o /app --no-restore - -FROM mcr.microsoft.com/dotnet/runtime:8.0 -WORKDIR /app -COPY --from=build /app . -ENTRYPOINT ["dotnet", "NancyServer.dll", "8080"] -``` - -## Source — `Program.cs` - -```csharp -using Nancy; -using Nancy.Hosting.Self; - -var port = args.Length > 0 ? args[0] : "9006"; -var uri = new Uri($"http://0.0.0.0:{port}"); - -var config = new HostConfiguration { RewriteLocalhost = false }; - -using var host = new NancyHost(config, uri); -host.Start(); - -Console.WriteLine($"Nancy listening on {uri}"); - -var waitHandle = new ManualResetEvent(false); -Console.CancelKeyPress += (_, e) => { e.Cancel = true; waitHandle.Set(); }; -waitHandle.WaitOne(); - -public class EchoModule : NancyModule -{ - public EchoModule() : base("/echo") - { - Get("/", _ => EchoHeaders()); - Post("/", _ => EchoHeaders()); - Put("/", _ => EchoHeaders()); - Delete("/", _ => EchoHeaders()); - Patch("/", _ => EchoHeaders()); - } - - private string EchoHeaders() - { - var sb = new System.Text.StringBuilder(); - foreach (var h in Request.Headers) - foreach (var v in h.Value) - sb.AppendLine($"{h.Key}: {v}"); - return sb.ToString(); - } -} - -public class HomeModule : NancyModule -{ - public HomeModule() - { - Get("/{path*}", _ => "OK"); - Get("/", _ => "OK"); - Post("/{path*}", _ => EchoBody()); - Post("/", _ => EchoBody()); - } - - private string EchoBody() - { - using var reader = new System.IO.StreamReader(Request.Body); - return reader.ReadToEnd(); - } -} -``` diff --git a/docs/content/servers/netcoreserver.md b/docs/content/servers/netcoreserver.md index 7803205..bb52d43 100644 --- a/docs/content/servers/netcoreserver.md +++ b/docs/content/servers/netcoreserver.md @@ -1,6 +1,6 @@ --- title: "NetCoreServer" -toc: false +toc: true breadcrumbs: false --- @@ -22,7 +22,7 @@ COPY --from=build /app . ENTRYPOINT ["dotnet", "NetCoreServerFramework.dll", "8080"] ``` -## Source — `Program.cs` +## Source ```csharp using System.Net; @@ -58,6 +58,25 @@ class OkHttpSession : HttpSession } SendResponseAsync(Response.MakeOkResponse(200).SetBody(sb.ToString())); } + else if (request.Url == "/cookie") + { + var sb = new System.Text.StringBuilder(); + for (int i = 0; i < request.Headers; i++) + { + var (name, value) = request.Header(i); + if (string.Equals(name, "Cookie", StringComparison.OrdinalIgnoreCase)) + { + foreach (var pair in value.Split(';')) + { + var trimmed = pair.TrimStart(); + var eqIdx = trimmed.IndexOf('='); + if (eqIdx > 0) + sb.AppendLine($"{trimmed[..eqIdx]}={trimmed[(eqIdx + 1)..]}"); + } + } + } + SendResponseAsync(Response.MakeOkResponse(200).SetBody(sb.ToString())); + } else if (request.Method == "POST" && request.Body.Length > 0) SendResponseAsync(Response.MakeOkResponse(200).SetBody(request.Body)); else @@ -81,3 +100,39 @@ class OkHttpServer : NetCoreServer.HttpServer protected override void OnError(SocketError error) { } } ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/nginx.md b/docs/content/servers/nginx.md index bdd7998..904e4dd 100644 --- a/docs/content/servers/nginx.md +++ b/docs/content/servers/nginx.md @@ -1,6 +1,6 @@ --- title: "Nginx" -toc: false +toc: true breadcrumbs: false --- @@ -14,7 +14,9 @@ COPY src/Servers/NginxServer/nginx.conf /etc/nginx/nginx.conf COPY src/Servers/NginxServer/echo.js /etc/nginx/echo.js ``` -## Source — `nginx.conf` +## Source + +**`nginx.conf`** ```nginx load_module modules/ngx_http_js_module.so; @@ -45,14 +47,18 @@ http { js_content echo.echo; } + location /cookie { + js_content echo.cookie; + } + location / { - return 200 "OK"; + js_content echo.handler; } } } ``` -## Source — `echo.js` +**`echo.js`** ```javascript function echo(r) { @@ -64,5 +70,65 @@ function echo(r) { r.return(200, body); } -export default { echo }; +function cookie(r) { + var body = ''; + var raw = r.headersIn['Cookie']; + if (raw) { + var pairs = raw.split(';'); + for (var i = 0; i < pairs.length; i++) { + var trimmed = pairs[i].replace(/^\s+/, ''); + var eq = trimmed.indexOf('='); + if (eq > 0) { + body += trimmed.substring(0, eq) + '=' + trimmed.substring(eq + 1) + '\n'; + } + } + } + r.return(200, body); +} + +function handler(r) { + if (r.method === 'POST') { + r.return(200, r.requestText || ''); + } else { + r.return(200, 'OK'); + } +} + +export default { echo, cookie, handler }; ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/node.md b/docs/content/servers/node.md index 4a21d32..ef62aa3 100644 --- a/docs/content/servers/node.md +++ b/docs/content/servers/node.md @@ -1,6 +1,6 @@ --- title: "Node.js" -toc: false +toc: true breadcrumbs: false --- @@ -15,7 +15,7 @@ COPY src/Servers/NodeServer/server.js . ENTRYPOINT ["node", "server.js", "8080"] ``` -## Source — `server.js` +## Source ```javascript const http = require('http'); @@ -29,7 +29,17 @@ const server = http.createServer((req, res) => { } catch { pathname = req.url; } - if (pathname === '/echo') { + if (pathname === '/cookie') { + let body = ''; + const raw = req.headers.cookie || ''; + for (const pair of raw.split(';')) { + const trimmed = pair.trimStart(); + const eq = trimmed.indexOf('='); + if (eq > 0) body += trimmed.substring(0, eq) + '=' + trimmed.substring(eq + 1) + '\n'; + } + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end(body); + } else if (pathname === '/echo') { let body = ''; for (const [name, value] of Object.entries(req.headers)) { if (Array.isArray(value)) value.forEach(v => body += name + ': ' + v + '\n'); @@ -52,3 +62,39 @@ const server = http.createServer((req, res) => { server.listen(port, '0.0.0.0'); ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/ntex.md b/docs/content/servers/ntex.md index f2c4d5f..f1bf88d 100644 --- a/docs/content/servers/ntex.md +++ b/docs/content/servers/ntex.md @@ -1,6 +1,6 @@ --- title: "Ntex" -toc: false +toc: true breadcrumbs: false --- @@ -24,7 +24,7 @@ COPY --from=build /src/target/release/ntex-server /usr/local/bin/ ENTRYPOINT ["ntex-server", "8080"] ``` -## Source — `src/main.rs` +## Source ```rust use ntex::web; @@ -38,6 +38,19 @@ async fn echo(req: web::HttpRequest) -> impl web::Responder { web::HttpResponse::Ok().content_type("text/plain").body(body) } +async fn cookie(req: web::HttpRequest) -> impl web::Responder { + let mut body = String::new(); + if let Some(raw) = req.headers().get("cookie").and_then(|v| v.to_str().ok()) { + for pair in raw.split(';') { + let trimmed = pair.trim_start(); + if let Some(eq) = trimmed.find('=') { + body.push_str(&format!("{}={}\n", &trimmed[..eq], &trimmed[eq+1..])); + } + } + } + web::HttpResponse::Ok().content_type("text/plain").body(body) +} + async fn handler(req: web::HttpRequest, body: Bytes) -> web::HttpResponse { if req.method() == ntex::http::Method::POST { web::HttpResponse::Ok() @@ -60,6 +73,7 @@ async fn main() -> std::io::Result<()> { web::server(|| { web::App::new() .route("/echo", web::to(echo)) + .route("/cookie", web::to(cookie)) .default_service(web::to(handler)) }) .bind(("0.0.0.0", port))? @@ -69,3 +83,39 @@ async fn main() -> std::io::Result<()> { Ok(()) } ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/php.md b/docs/content/servers/php.md index cdccebb..1b1e4da 100644 --- a/docs/content/servers/php.md +++ b/docs/content/servers/php.md @@ -1,6 +1,6 @@ --- title: "PHP" -toc: false +toc: true breadcrumbs: false --- @@ -16,7 +16,7 @@ EXPOSE 8080 CMD ["php", "-S", "0.0.0.0:8080", "index.php"] ``` -## Source — `index.php` +## Source ```php $value) { + echo "$name=$value\n"; + } + exit; +} + header('Content-Type: text/plain'); if ($_SERVER['REQUEST_METHOD'] === 'POST') { echo file_get_contents('php://input'); @@ -35,3 +43,39 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { echo 'OK'; } ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/pingora.md b/docs/content/servers/pingora.md index d0a1fee..04632b9 100644 --- a/docs/content/servers/pingora.md +++ b/docs/content/servers/pingora.md @@ -1,6 +1,6 @@ --- title: "Pingora" -toc: false +toc: true breadcrumbs: false --- @@ -26,7 +26,7 @@ COPY --from=build /src/target/release/pingora-server /usr/local/bin/ ENTRYPOINT ["pingora-server", "8080"] ``` -## Source — `src/main.rs` +## Source ```rust use async_trait::async_trait; @@ -48,6 +48,30 @@ impl ProxyHttp for OkProxy { session: &mut Session, _ctx: &mut Self::CTX, ) -> Result { + let is_cookie = session.req_header().uri.path() == "/cookie"; + if is_cookie { + let mut body_str = String::new(); + if let Some(raw) = session.req_header().headers.get("cookie").and_then(|v| v.to_str().ok()) { + for pair in raw.split(';') { + let trimmed = pair.trim_start(); + if let Some(eq) = trimmed.find('=') { + body_str.push_str(&format!("{}={}\n", &trimmed[..eq], &trimmed[eq+1..])); + } + } + } + let body = Bytes::from(body_str); + let mut header = ResponseHeader::build(200, None)?; + header.insert_header("Content-Type", "text/plain")?; + header.insert_header("Content-Length", &body.len().to_string())?; + session + .write_response_header(Box::new(header), false) + .await?; + session + .write_response_body(Some(body), true) + .await?; + return Ok(true); + } + let is_echo = session.req_header().uri.path() == "/echo"; if is_echo { let mut body_str = String::new(); @@ -115,3 +139,39 @@ fn main() { server.run_forever(); } ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/puma.md b/docs/content/servers/puma.md index 8635a50..bc8cfcd 100644 --- a/docs/content/servers/puma.md +++ b/docs/content/servers/puma.md @@ -1,6 +1,6 @@ --- title: "Puma" -toc: false +toc: true breadcrumbs: false --- @@ -20,7 +20,7 @@ EXPOSE 8080 CMD ["puma", "-b", "tcp://0.0.0.0:8080"] ``` -## Source — `config.ru` +## Source ```ruby app = proc { |env| @@ -30,6 +30,18 @@ app = proc { |env| body += "Content-Type: #{env['CONTENT_TYPE']}\n" if env['CONTENT_TYPE'] body += "Content-Length: #{env['CONTENT_LENGTH']}\n" if env['CONTENT_LENGTH'] [200, { 'Content-Type' => 'text/plain' }, [body]] + elsif env['PATH_INFO'] == '/cookie' + body = "" + if env['HTTP_COOKIE'] + env['HTTP_COOKIE'].split(';').each do |pair| + trimmed = pair.lstrip + eq = trimmed.index('=') + if eq && eq > 0 + body += "#{trimmed[0...eq]}=#{trimmed[(eq+1)..]}\n" + end + end + end + [200, { 'Content-Type' => 'text/plain' }, [body]] elsif env['REQUEST_METHOD'] == 'POST' body = env['rack.input'].read [200, { 'content-type' => 'text/plain' }, [body]] @@ -39,3 +51,39 @@ app = proc { |env| } run app ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/quarkus.md b/docs/content/servers/quarkus.md index fe1efcb..0d72c48 100644 --- a/docs/content/servers/quarkus.md +++ b/docs/content/servers/quarkus.md @@ -1,6 +1,6 @@ --- title: "Quarkus" -toc: false +toc: true breadcrumbs: false --- @@ -22,7 +22,7 @@ COPY --from=build /src/target/quarkus-app/ quarkus-app/ ENTRYPOINT ["java", "-Dquarkus.http.port=8080", "-jar", "quarkus-app/quarkus-run.jar"] ``` -## Source — `src/main/java/server/Application.java` +## Source ```java package server; @@ -58,6 +58,20 @@ public class Application { return body.readAllBytes(); } + @GET + @Path("/cookie") + @Produces(MediaType.TEXT_PLAIN) + public Response cookieGet(@Context HttpHeaders headers) { + return parseCookies(headers); + } + + @POST + @Path("/cookie") + @Produces(MediaType.TEXT_PLAIN) + public Response cookiePost(@Context HttpHeaders headers) { + return parseCookies(headers); + } + @GET @Path("/echo") @Produces(MediaType.TEXT_PLAIN) @@ -72,6 +86,23 @@ public class Application { return echoHeaders(headers); } + private Response parseCookies(HttpHeaders headers) { + StringBuilder sb = new StringBuilder(); + List cookieHeaders = headers.getRequestHeader("Cookie"); + if (cookieHeaders != null) { + for (String raw : cookieHeaders) { + for (String pair : raw.split(";")) { + String trimmed = pair.stripLeading(); + int eq = trimmed.indexOf('='); + if (eq > 0) { + sb.append(trimmed, 0, eq).append("=").append(trimmed.substring(eq + 1)).append("\n"); + } + } + } + } + return Response.ok(sb.toString(), MediaType.TEXT_PLAIN).build(); + } + private Response echoHeaders(HttpHeaders headers) { StringBuilder sb = new StringBuilder(); for (Map.Entry> entry : headers.getRequestHeaders().entrySet()) { @@ -83,3 +114,39 @@ public class Application { } } ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/servicestack.md b/docs/content/servers/servicestack.md index 22568f9..0685992 100644 --- a/docs/content/servers/servicestack.md +++ b/docs/content/servers/servicestack.md @@ -1,6 +1,6 @@ --- title: "ServiceStack" -toc: false +toc: true breadcrumbs: false --- @@ -22,7 +22,7 @@ COPY --from=build /app . ENTRYPOINT ["dotnet", "ServiceStackServer.dll"] ``` -## Source — `Program.cs` +## Source ```csharp using ServiceStack; @@ -39,6 +39,13 @@ app.Map("/echo", (HttpContext ctx) => sb.AppendLine($"{h.Key}: {v}"); return Results.Text(sb.ToString()); }); +app.Map("/cookie", (HttpContext ctx) => +{ + var sb = new System.Text.StringBuilder(); + foreach (var cookie in ctx.Request.Cookies) + sb.AppendLine($"{cookie.Key}={cookie.Value}"); + return Results.Text(sb.ToString()); +}); app.MapFallback(async (HttpContext ctx) => { if (ctx.Request.Method == "POST") @@ -57,3 +64,39 @@ class AppHost : AppHostBase public override void Configure() { } } ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/simplew.md b/docs/content/servers/simplew.md index 2807459..985b2dd 100644 --- a/docs/content/servers/simplew.md +++ b/docs/content/servers/simplew.md @@ -1,6 +1,6 @@ --- title: "SimpleW" -toc: false +toc: true breadcrumbs: false --- @@ -22,7 +22,7 @@ COPY --from=build /app . ENTRYPOINT ["dotnet", "SimpleWServer.dll", "8080"] ``` -## Source — `Program.cs` +## Source ```csharp using System.Net; @@ -33,6 +33,8 @@ var port = args.Length > 0 && int.TryParse(args[0], out var p) ? p : 8080; var server = new SimpleWServer(IPAddress.Any, port); +server.MapGet("/cookie", (HttpSession session) => ParseCookies(session)); +server.MapPost("/cookie", (HttpSession session) => ParseCookies(session)); server.MapGet("/echo", (HttpSession session) => { var sb = new System.Text.StringBuilder(); @@ -52,7 +54,62 @@ server.MapGet("/{path}", () => "OK"); server.MapPost("/", (HttpSession session) => session.Request.BodyString); server.MapPost("/{path}", (HttpSession session) => session.Request.BodyString); +static string ParseCookies(HttpSession session) +{ + var sb = new System.Text.StringBuilder(); + foreach (var h in session.Request.Headers.EnumerateAll()) + { + if (string.Equals(h.Key, "Cookie", StringComparison.OrdinalIgnoreCase)) + { + foreach (var pair in h.Value.Split(';')) + { + var trimmed = pair.TrimStart(); + var eqIdx = trimmed.IndexOf('='); + if (eqIdx > 0) + sb.AppendLine($"{trimmed[..eqIdx]}={trimmed[(eqIdx + 1)..]}"); + } + } + } + return sb.ToString(); +} + Console.WriteLine($"SimpleW listening on http://localhost:{port}"); await server.RunAsync(); ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/sisk.md b/docs/content/servers/sisk.md index 8cceb4f..4b35dbd 100644 --- a/docs/content/servers/sisk.md +++ b/docs/content/servers/sisk.md @@ -1,6 +1,6 @@ --- title: "Sisk" -toc: false +toc: true breadcrumbs: false --- @@ -22,7 +22,7 @@ COPY --from=build /app . ENTRYPOINT ["dotnet", "SiskServer.dll", "8080"] ``` -## Source — `Program.cs` +## Source ```csharp using Sisk.Core.Http; @@ -44,6 +44,27 @@ app.Router.SetRoute(RouteMethod.Any, Route.AnyPath, request => sb.AppendLine($"{h.Key}: {val}"); return new HttpResponse(200).WithContent(sb.ToString()); } + if (request.Path == "/cookie") + { + var sb = new System.Text.StringBuilder(); + foreach (var h in request.Headers) + { + if (string.Equals(h.Key, "Cookie", StringComparison.OrdinalIgnoreCase)) + { + foreach (var rawVal in h.Value) + { + foreach (var pair in rawVal.Split(';')) + { + var trimmed = pair.TrimStart(); + var eqIdx = trimmed.IndexOf('='); + if (eqIdx > 0) + sb.AppendLine($"{trimmed[..eqIdx]}={trimmed[(eqIdx + 1)..]}"); + } + } + } + } + return new HttpResponse(200).WithContent(sb.ToString()); + } if (request.Method == HttpMethod.Post && request.Body is not null) { var body = request.Body; @@ -54,3 +75,39 @@ app.Router.SetRoute(RouteMethod.Any, Route.AnyPath, request => await app.StartAsync(); ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/spring-boot.md b/docs/content/servers/spring-boot.md index 30a7208..016be63 100644 --- a/docs/content/servers/spring-boot.md +++ b/docs/content/servers/spring-boot.md @@ -1,6 +1,6 @@ --- title: "Spring Boot" -toc: false +toc: true breadcrumbs: false --- @@ -22,7 +22,7 @@ COPY --from=build /src/target/*.jar app.jar ENTRYPOINT ["java", "-jar", "app.jar", "--server.port=8080", "--server.address=127.0.0.1"] ``` -## Source — `src/main/java/server/Application.java` +## Source ```java package server; @@ -58,6 +58,18 @@ public class Application { return request.getInputStream().readAllBytes(); } + @RequestMapping("/cookie") + public ResponseEntity cookieEndpoint(HttpServletRequest request) { + StringBuilder sb = new StringBuilder(); + jakarta.servlet.http.Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (jakarta.servlet.http.Cookie c : cookies) { + sb.append(c.getName()).append("=").append(c.getValue()).append("\n"); + } + } + return ResponseEntity.ok().contentType(MediaType.TEXT_PLAIN).body(sb.toString()); + } + @RequestMapping("/echo") public ResponseEntity echo(HttpServletRequest request) { StringBuilder sb = new StringBuilder(); @@ -73,3 +85,39 @@ public class Application { } } ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/tomcat.md b/docs/content/servers/tomcat.md index 96a4a98..42d3a76 100644 --- a/docs/content/servers/tomcat.md +++ b/docs/content/servers/tomcat.md @@ -1,6 +1,6 @@ --- title: "Tomcat" -toc: false +toc: true breadcrumbs: false --- @@ -16,7 +16,9 @@ EXPOSE 8080 CMD ["catalina.sh", "run"] ``` -## Source — `webapp/WEB-INF/web.xml` +## Source + +**`webapp/WEB-INF/web.xml`** ```xml @@ -30,6 +32,15 @@ CMD ["catalina.sh", "run"] /echo + + cookie + /cookie.jsp + + + cookie + /cookie + + ok /ok.jsp @@ -41,7 +52,7 @@ CMD ["catalina.sh", "run"] ``` -## Source — `webapp/ok.jsp` +**`webapp/ok.jsp`** ```jsp <%@page contentType="text/plain" import="java.io.*"%><% @@ -55,7 +66,7 @@ if ("POST".equals(request.getMethod())) { %> ``` -## Source — `webapp/echo.jsp` +**`webapp/echo.jsp`** ```jsp <%@page contentType="text/plain" import="java.util.*"%><% @@ -69,3 +80,52 @@ while (names.hasMoreElements()) { } %> ``` + +**`webapp/cookie.jsp`** + +```jsp +<%@page contentType="text/plain"%><% +jakarta.servlet.http.Cookie[] cookies = request.getCookies(); +if (cookies != null) { + for (jakarta.servlet.http.Cookie c : cookies) { + out.print(c.getName() + "=" + c.getValue() + "\n"); + } +} +%> +``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/traefik.md b/docs/content/servers/traefik.md index 0e5702b..2232861 100644 --- a/docs/content/servers/traefik.md +++ b/docs/content/servers/traefik.md @@ -1,6 +1,6 @@ --- title: "Traefik" -toc: false +toc: true breadcrumbs: false --- @@ -28,7 +28,9 @@ RUN chmod +x /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] ``` -## Source — `traefik.yml` +## Source + +**`traefik.yml`** ```yaml entryPoints: @@ -45,7 +47,7 @@ experimental: moduleName: github.com/jdel/staticresponse ``` -## Source — `dynamic.yml` +**`dynamic.yml`** ```yaml http: @@ -56,6 +58,12 @@ http: - web service: echo-svc + cookie: + rule: "Path(`/cookie`)" + entryPoints: + - web + service: echo-svc + catchall: rule: "PathPrefix(`/`)" entryPoints: @@ -78,36 +86,91 @@ http: body: "OK" ``` -## Source — `echo/main.go` +**`echo/main.go`** ```go package main import ( - "fmt" + "io" "net/http" "strings" ) func main() { - http.HandleFunc("/echo", func(w http.ResponseWriter, r *http.Request) { + http.HandleFunc("/cookie", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") - var sb strings.Builder - for name, values := range r.Header { - for _, v := range values { - sb.WriteString(fmt.Sprintf("%s: %s\n", name, v)) + raw := r.Header.Get("Cookie") + for _, pair := range strings.Split(raw, ";") { + pair = strings.TrimLeft(pair, " ") + if eq := strings.Index(pair, "="); eq > 0 { + w.Write([]byte(pair[:eq] + "=" + pair[eq+1:] + "\n")) } } - fmt.Fprint(w, sb.String()) }) + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write(body) + }) + http.ListenAndServe(":9090", nil) } ``` -## Source — `entrypoint.sh` +**`entrypoint.sh`** ```bash #!/bin/sh /usr/local/bin/echo-server & exec traefik "$@" ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/content/servers/uvicorn.md b/docs/content/servers/uvicorn.md index f69cdda..6076a54 100644 --- a/docs/content/servers/uvicorn.md +++ b/docs/content/servers/uvicorn.md @@ -1,6 +1,6 @@ --- title: "Uvicorn" -toc: false +toc: true breadcrumbs: false --- @@ -17,12 +17,36 @@ EXPOSE 8080 CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"] ``` -## Source — `app.py` +## Source ```python async def app(scope, receive, send): path = scope.get('path', '/') + if path == '/cookie': + cookie_val = '' + for name, value in scope.get('headers', []): + if name.lower() == b'cookie': + cookie_val = value.decode('latin-1') + break + lines = [] + for pair in cookie_val.split(';'): + pair = pair.strip() + eq = pair.find('=') + if eq > 0: + lines.append(f"{pair[:eq]}={pair[eq+1:]}") + body = ('\n'.join(lines) + '\n').encode('utf-8') if lines else b'' + await send({ + 'type': 'http.response.start', + 'status': 200, + 'headers': [(b'content-type', b'text/plain')], + }) + await send({ + 'type': 'http.response.body', + 'body': body, + }) + return + if path == '/echo': lines = [] for name, value in scope.get('headers', []): @@ -58,3 +82,39 @@ async def app(scope, receive, send): 'body': body, }) ``` + +## Test Results + +

Loading results...

+ +### Compliance + +
+ +### Smuggling + +
+ +### Malformed Input + +
+ +### Caching + +
+ +### Cookies + +
+ + + + diff --git a/docs/static/probe/render.js b/docs/static/probe/render.js index d460cd1..3c95b3a 100644 --- a/docs/static/probe/render.js +++ b/docs/static/probe/render.js @@ -735,7 +735,7 @@ window.ProbeRender = (function () { el.innerHTML = html; } - var CAT_LABELS = { Compliance: 'Compliance', Smuggling: 'Smuggling', MalformedInput: 'Malformed Input', Normalization: 'Normalization', Capabilities: 'Caching' }; + var CAT_LABELS = { Compliance: 'Compliance', Smuggling: 'Smuggling', MalformedInput: 'Malformed Input', Normalization: 'Normalization', Capabilities: 'Caching', Cookies: 'Cookies' }; function renderTable(targetId, categoryKey, ctx, testIdFilter, tableLabel) { injectScrollStyle(); @@ -758,7 +758,7 @@ window.ProbeRender = (function () { var orderedTests = scoredTests.concat(unscoredTests); var shortLabels = orderedTests.map(function (tid) { - return tid.replace(/^(RFC\d+-[\d.]+-|COMP-|SMUG-|MAL-|NORM-)/, ''); + return tid.replace(/^(RFC\d+-[\d.]+-|COMP-|SMUG-|MAL-|NORM-|COOK-)/, ''); }); var unscoredStart = scoredTests.length; @@ -1414,6 +1414,116 @@ window.ProbeRender = (function () { }); } + // ── Per-server results page ──────────────────────────────────── + var SERVER_CAT_ORDER = ['Compliance', 'Smuggling', 'MalformedInput', 'Capabilities', 'Cookies']; + + function renderServerCategoryTable(catEl, results) { + var scored = results.filter(function (r) { return r.scored !== false; }); + var unscoredR = results.filter(function (r) { return r.scored === false; }); + var ordered = scored.concat(unscoredR); + + var html = '
'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + + ordered.forEach(function (r) { + var isUnscored = r.scored === false; + var opacity = isUnscored ? 'opacity:0.6;' : ''; + var url = testUrl(r.id); + var idHtml = url ? '' + r.id + '' : r.id; + var method = methodFromRequest(r.rawRequest); + var level = r.rfcLevel || 'Must'; + + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + }); + + html += '
TestGotExpectedMethodRFC LevelDescription
' + idHtml + '' + pill(verdictBg(r.verdict), r.got || r.verdict, r.rawResponse, r.behavioralNote, r.rawRequest, r.doubleFlush) + '' + expectedPill(EXPECT_BG, (r.expected || '').replace(/ or close/g, '/\u2715').replace(/\//g, '/\u200B')) + '' + methodTag(method) + '' + rfcLevelTag(level) + '' + (r.description || '') + '
'; + catEl.innerHTML = html; + } + + function renderServerPage(serverName) { + injectScrollStyle(); + var summaryEl = document.getElementById('server-summary'); + var data = window.PROBE_DATA; + if (!data || !data.servers) { + if (summaryEl) summaryEl.innerHTML = '

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

'; + return; + } + var sv = null; + data.servers.forEach(function (s) { if (s.name === serverName) sv = s; }); + if (!sv) { + if (summaryEl) summaryEl.innerHTML = '

No results found for ' + serverName + '.

'; + return; + } + + // Summary counts + var scoredPass = 0, scoredWarn = 0, scoredFail = 0, unscored = 0, total = sv.results.length; + sv.results.forEach(function (r) { + if (r.scored === false) { unscored++; return; } + if (r.verdict === 'Pass') scoredPass++; + else if (r.verdict === 'Warn') scoredWarn++; + else scoredFail++; + }); + var scored = total - unscored; + + // Summary bar + if (summaryEl) { + var html = '
'; + var trackBg = document.documentElement.classList.contains('dark') ? '#2a2f38' : '#f0f0f0'; + html += '
'; + if (scored > 0) { + html += '
'; + if (scoredWarn > 0) html += '
'; + if (scoredFail > 0) html += '
'; + } + if (unscored > 0) html += '
'; + html += '
'; + html += '
'; + html += '' + scoredPass + ' pass'; + if (scoredWarn > 0) html += '  ' + scoredWarn + ' warn'; + if (scoredFail > 0) html += '  ' + scoredFail + ' fail'; + html += '  ' + unscored + ' unscored · ' + total + ' total'; + html += '
'; + if (data.commit) { + html += '

Commit: ' + data.commit.id.substring(0, 7) + ' — ' + (data.commit.message || '') + '

'; + } + summaryEl.innerHTML = html; + } + + // Group by category + var byCat = {}; + sv.results.forEach(function (r) { + var cat = r.category || 'Other'; + if (!byCat[cat]) byCat[cat] = []; + byCat[cat].push(r); + }); + + // Render each category into its own div + SERVER_CAT_ORDER.forEach(function (cat) { + var catEl = document.getElementById('results-' + cat.toLowerCase()); + if (!catEl) return; + var results = byCat[cat]; + if (!results || results.length === 0) { + catEl.innerHTML = '

No results for this category yet.

'; + return; + } + renderServerCategoryTable(catEl, results); + }); + } + return { pill: pill, verdictBg: verdictBg, @@ -1421,6 +1531,7 @@ window.ProbeRender = (function () { renderSummary: renderSummary, renderTable: renderTable, renderSubTables: renderSubTables, + renderServerPage: renderServerPage, renderLanguageFilter: renderLanguageFilter, filterByCategory: filterByCategory, renderCategoryFilter: renderCategoryFilter, diff --git a/src/Http11Probe.Cli/Reporting/DocsUrlMap.cs b/src/Http11Probe.Cli/Reporting/DocsUrlMap.cs index 50c368d..146260a 100644 --- a/src/Http11Probe.Cli/Reporting/DocsUrlMap.cs +++ b/src/Http11Probe.Cli/Reporting/DocsUrlMap.cs @@ -135,6 +135,13 @@ internal static class DocsUrlMap return BaseUrl + "normalization/" + suffix; } + // COOK-* → cookies/{suffix} + if (testId.StartsWith("COOK-", StringComparison.OrdinalIgnoreCase)) + { + var suffix = testId[5..].ToLowerInvariant(); + return BaseUrl + "cookies/" + suffix; + } + return null; } } diff --git a/src/Servers/ActixServer/src/main.rs b/src/Servers/ActixServer/src/main.rs index d77610f..dd1e439 100644 --- a/src/Servers/ActixServer/src/main.rs +++ b/src/Servers/ActixServer/src/main.rs @@ -8,6 +8,19 @@ async fn echo(req: HttpRequest) -> impl Responder { HttpResponse::Ok().content_type("text/plain").body(body) } +async fn cookie(req: HttpRequest) -> impl Responder { + let mut body = String::new(); + if let Some(raw) = req.headers().get("cookie").and_then(|v| v.to_str().ok()) { + for pair in raw.split(';') { + let trimmed = pair.trim_start(); + if let Some(eq) = trimmed.find('=') { + body.push_str(&format!("{}={}\n", &trimmed[..eq], &trimmed[eq+1..])); + } + } + } + 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 { HttpResponse::Ok() @@ -30,6 +43,7 @@ async fn main() -> std::io::Result<()> { HttpServer::new(|| { App::new() .route("/echo", web::to(echo)) + .route("/cookie", web::to(cookie)) .default_service(web::to(handler)) }) .bind(("0.0.0.0", port))? diff --git a/src/Servers/ApacheServer/Dockerfile b/src/Servers/ApacheServer/Dockerfile index c9a8394..6699e78 100644 --- a/src/Servers/ApacheServer/Dockerfile +++ b/src/Servers/ApacheServer/Dockerfile @@ -3,4 +3,5 @@ FROM httpd:2.4 COPY src/Servers/ApacheServer/httpd-probe.conf /usr/local/apache2/conf/httpd.conf RUN echo "OK" > /usr/local/apache2/htdocs/index.html COPY src/Servers/ApacheServer/echo.cgi /usr/local/apache2/cgi-bin/echo.cgi -RUN chmod +x /usr/local/apache2/cgi-bin/echo.cgi +COPY src/Servers/ApacheServer/cookie.cgi /usr/local/apache2/cgi-bin/cookie.cgi +RUN chmod +x /usr/local/apache2/cgi-bin/echo.cgi /usr/local/apache2/cgi-bin/cookie.cgi diff --git a/src/Servers/ApacheServer/cookie.cgi b/src/Servers/ApacheServer/cookie.cgi new file mode 100755 index 0000000..827ab97 --- /dev/null +++ b/src/Servers/ApacheServer/cookie.cgi @@ -0,0 +1,8 @@ +#!/bin/sh +printf 'Content-Type: text/plain\r\n\r\n' +if [ -n "$HTTP_COOKIE" ]; then + echo "$HTTP_COOKIE" | tr ';' '\n' | while read -r pair; do + trimmed=$(echo "$pair" | sed 's/^ *//') + printf '%s\n' "$trimmed" + done +fi diff --git a/src/Servers/ApacheServer/httpd-probe.conf b/src/Servers/ApacheServer/httpd-probe.conf index e2c3401..7a9b30f 100644 --- a/src/Servers/ApacheServer/httpd-probe.conf +++ b/src/Servers/ApacheServer/httpd-probe.conf @@ -18,6 +18,7 @@ DocumentRoot "/usr/local/apache2/htdocs" ScriptAlias /echo /usr/local/apache2/cgi-bin/echo.cgi +ScriptAlias /cookie /usr/local/apache2/cgi-bin/cookie.cgi Require all granted diff --git a/src/Servers/BunServer/server.ts b/src/Servers/BunServer/server.ts index 5cc9dd4..6973411 100644 --- a/src/Servers/BunServer/server.ts +++ b/src/Servers/BunServer/server.ts @@ -12,6 +12,16 @@ Bun.serve({ } return new Response(body, { headers: { "Content-Type": "text/plain" } }); } + if (url.pathname === "/cookie") { + let body = ""; + const raw = req.headers.get("cookie") || ""; + for (const pair of raw.split(";")) { + const trimmed = pair.trimStart(); + const eq = trimmed.indexOf("="); + if (eq > 0) body += trimmed.substring(0, eq) + "=" + trimmed.substring(eq + 1) + "\n"; + } + return new Response(body, { headers: { "Content-Type": "text/plain" } }); + } if (req.method === "POST") { const body = await req.text(); return new Response(body); diff --git a/src/Servers/CaddyServer/Caddyfile b/src/Servers/CaddyServer/Caddyfile index 94eaab5..b7113b6 100644 --- a/src/Servers/CaddyServer/Caddyfile +++ b/src/Servers/CaddyServer/Caddyfile @@ -20,5 +20,14 @@ file_server } + handle /cookie { + root * /srv + templates { + mime text/plain + } + rewrite * /cookie.html + file_server + } + respond "OK" 200 } diff --git a/src/Servers/CaddyServer/Dockerfile b/src/Servers/CaddyServer/Dockerfile index 729486b..f2fa5ae 100644 --- a/src/Servers/CaddyServer/Dockerfile +++ b/src/Servers/CaddyServer/Dockerfile @@ -1,3 +1,4 @@ FROM caddy:2 COPY src/Servers/CaddyServer/Caddyfile /etc/caddy/Caddyfile COPY src/Servers/CaddyServer/echo.html /srv/echo.html +COPY src/Servers/CaddyServer/cookie.html /srv/cookie.html diff --git a/src/Servers/CaddyServer/cookie.html b/src/Servers/CaddyServer/cookie.html new file mode 100644 index 0000000..12cab4f --- /dev/null +++ b/src/Servers/CaddyServer/cookie.html @@ -0,0 +1,2 @@ +{{range .Req.Header.Cookie}}{{range splitList ";" .}}{{trim .}} +{{end}}{{end}} \ No newline at end of file diff --git a/src/Servers/DenoServer/server.ts b/src/Servers/DenoServer/server.ts index d87e1b1..d90b714 100644 --- a/src/Servers/DenoServer/server.ts +++ b/src/Servers/DenoServer/server.ts @@ -7,6 +7,16 @@ Deno.serve({ port: 8080, hostname: "0.0.0.0" }, async (req) => { } return new Response(body, { headers: { "content-type": "text/plain" } }); } + if (url.pathname === "/cookie") { + let body = ""; + const raw = req.headers.get("cookie") || ""; + for (const pair of raw.split(";")) { + const trimmed = pair.trimStart(); + const eq = trimmed.indexOf("="); + if (eq > 0) body += trimmed.substring(0, eq) + "=" + trimmed.substring(eq + 1) + "\n"; + } + return new Response(body, { headers: { "content-type": "text/plain" } }); + } if (req.method === "POST") { const body = await req.text(); return new Response(body, { headers: { "content-type": "text/plain" } }); diff --git a/src/Servers/EmbedIOServer/Program.cs b/src/Servers/EmbedIOServer/Program.cs index bddeb36..3391458 100644 --- a/src/Servers/EmbedIOServer/Program.cs +++ b/src/Servers/EmbedIOServer/Program.cs @@ -7,6 +7,13 @@ using var server = new WebServer(o => o .WithUrlPrefix(url) .WithMode(HttpListenerMode.EmbedIO)) + .WithModule(new ActionModule("/cookie", HttpVerbs.Any, async ctx => + { + var sb = new System.Text.StringBuilder(); + foreach (System.Net.Cookie cookie in ctx.Request.Cookies) + sb.AppendLine($"{cookie.Name}={cookie.Value}"); + await ctx.SendStringAsync(sb.ToString(), "text/plain", System.Text.Encoding.UTF8); + })) .WithModule(new ActionModule("/echo", HttpVerbs.Any, async ctx => { var sb = new System.Text.StringBuilder(); diff --git a/src/Servers/EnvoyServer/envoy.yaml b/src/Servers/EnvoyServer/envoy.yaml index a1e2f5d..94f2f58 100644 --- a/src/Servers/EnvoyServer/envoy.yaml +++ b/src/Servers/EnvoyServer/envoy.yaml @@ -28,6 +28,19 @@ static_resources: end end request_handle:respond({[":status"] = "200", ["content-type"] = "text/plain"}, body) + elseif path == "/cookie" then + local body = "" + local raw = request_handle:headers():get("cookie") + if raw then + for pair in raw:gmatch("[^;]+") do + local trimmed = pair:match("^%s*(.*)") + local eq = trimmed:find("=") + if eq and eq > 1 then + body = body .. trimmed:sub(1, eq-1) .. "=" .. trimmed:sub(eq+1) .. "\n" + end + end + end + request_handle:respond({[":status"] = "200", ["content-type"] = "text/plain"}, body) end end - name: envoy.filters.http.router diff --git a/src/Servers/ExpressServer/server.js b/src/Servers/ExpressServer/server.js index 6f00d7e..a0b6e5e 100644 --- a/src/Servers/ExpressServer/server.js +++ b/src/Servers/ExpressServer/server.js @@ -13,6 +13,17 @@ app.post("/", (req, res) => { req.on("end", () => res.send(Buffer.concat(chunks))); }); +app.all('/cookie', (req, res) => { + let body = ''; + const raw = req.headers.cookie || ''; + for (const pair of raw.split(';')) { + const trimmed = pair.trimStart(); + const eq = trimmed.indexOf('='); + if (eq > 0) body += trimmed.substring(0, eq) + '=' + trimmed.substring(eq + 1) + '\n'; + } + res.set('Content-Type', 'text/plain').send(body); +}); + app.all('/echo', (req, res) => { let body = ''; for (const [name, value] of Object.entries(req.headers)) { diff --git a/src/Servers/FastEndpointsServer/Program.cs b/src/Servers/FastEndpointsServer/Program.cs index 6a01f3f..9378f66 100644 --- a/src/Servers/FastEndpointsServer/Program.cs +++ b/src/Servers/FastEndpointsServer/Program.cs @@ -82,6 +82,26 @@ public override async Task HandleAsync(CancellationToken ct) } } +// ── GET/POST /cookie ────────────────────────────────────────── + +sealed class CookieEndpoint : EndpointWithoutRequest +{ + public override void Configure() + { + Verbs("GET", "POST"); + Routes("/cookie"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var sb = new System.Text.StringBuilder(); + foreach (var cookie in HttpContext.Request.Cookies) + sb.AppendLine($"{cookie.Key}={cookie.Value}"); + await HttpContext.Response.WriteAsync(sb.ToString(), ct); + } +} + // ── POST /echo ───────────────────────────────────────────────── sealed class PostEcho : EndpointWithoutRequest diff --git a/src/Servers/FastHttpServer/main.go b/src/Servers/FastHttpServer/main.go index 7b9f9e2..b557285 100644 --- a/src/Servers/FastHttpServer/main.go +++ b/src/Servers/FastHttpServer/main.go @@ -2,6 +2,7 @@ package main import ( "os" + "strings" "github.com/valyala/fasthttp" ) @@ -20,6 +21,15 @@ func main() { ctx.Request.Header.VisitAll(func(key, value []byte) { ctx.WriteString(string(key) + ": " + string(value) + "\n") }) + case "/cookie": + ctx.SetContentType("text/plain") + raw := string(ctx.Request.Header.Peek("Cookie")) + for _, pair := range strings.Split(raw, ";") { + pair = strings.TrimLeft(pair, " ") + if eq := strings.Index(pair, "="); eq > 0 { + ctx.WriteString(pair[:eq] + "=" + pair[eq+1:] + "\n") + } + } default: if string(ctx.Method()) == "POST" { ctx.SetBody(ctx.Request.Body()) diff --git a/src/Servers/FlaskServer/app.py b/src/Servers/FlaskServer/app.py index e98913a..b054e86 100644 --- a/src/Servers/FlaskServer/app.py +++ b/src/Servers/FlaskServer/app.py @@ -4,6 +4,13 @@ app = Flask(__name__) +@app.route('/cookie', methods=['GET','POST','PUT','DELETE','PATCH','OPTIONS','HEAD']) +def cookie_endpoint(): + lines = [] + for name, value in request.cookies.items(): + lines.append(f"{name}={value}") + return '\n'.join(lines) + '\n', 200, {'Content-Type': 'text/plain'} + @app.route('/echo', methods=['GET','POST','PUT','DELETE','PATCH','OPTIONS','HEAD']) def echo(): lines = [] diff --git a/src/Servers/GenHttpServer/Program.cs b/src/Servers/GenHttpServer/Program.cs index aafd975..60cc88c 100644 --- a/src/Servers/GenHttpServer/Program.cs +++ b/src/Servers/GenHttpServer/Program.cs @@ -8,6 +8,9 @@ var port = (args.Length > 0 && ushort.TryParse(args[0], out var p)) ? p : (ushort)8080; var app = Inline.Create() + .Get("/cookie", (IRequest request) => ParseCookies(request)) + .Post("/cookie", (IRequest request) => ParseCookies(request)) + .Get("/echo", (IRequest request) => Echo(request)) .Post("/echo", (IRequest request) => Echo(request)) .Post((Stream body) => RequestContent(body)) .Any(() => StringContent()); @@ -30,6 +33,22 @@ static string Echo(IRequest request) return headers.ToString(); } +static string ParseCookies(IRequest request) +{ + var sb = new System.Text.StringBuilder(); + if (request.Headers.TryGetValue("Cookie", out var cookieHeader)) + { + foreach (var pair in cookieHeader.Split(';')) + { + var trimmed = pair.TrimStart(); + var eqIdx = trimmed.IndexOf('='); + if (eqIdx > 0) + sb.AppendLine($"{trimmed[..eqIdx]}={trimmed[(eqIdx + 1)..]}"); + } + } + return sb.ToString(); +} + static string StringContent() => "OK"; static Stream RequestContent(Stream body) => body; diff --git a/src/Servers/GinServer/main.go b/src/Servers/GinServer/main.go index 142cae9..4071773 100644 --- a/src/Servers/GinServer/main.go +++ b/src/Servers/GinServer/main.go @@ -16,6 +16,17 @@ func main() { gin.SetMode(gin.ReleaseMode) r := gin.New() + r.Any("/cookie", func(c *gin.Context) { + var sb strings.Builder + raw := c.GetHeader("Cookie") + for _, pair := range strings.Split(raw, ";") { + pair = strings.TrimLeft(pair, " ") + if eq := strings.Index(pair, "="); eq > 0 { + sb.WriteString(pair[:eq] + "=" + pair[eq+1:] + "\n") + } + } + c.Data(200, "text/plain", []byte(sb.String())) + }) r.Any("/echo", func(c *gin.Context) { var sb strings.Builder for name, values := range c.Request.Header { diff --git a/src/Servers/GlyphServer/Program.cs b/src/Servers/GlyphServer/Program.cs index e9ee0f4..f7e16b0 100644 --- a/src/Servers/GlyphServer/Program.cs +++ b/src/Servers/GlyphServer/Program.cs @@ -282,6 +282,24 @@ static byte[] BuildResponse(string method, string path, string? echoBody, List 0) + sb.AppendLine($"{trimmed[..eqIdx]}={trimmed[(eqIdx + 1)..]}"); + } + } + } + return MakeResponse(200, "OK", sb.ToString()); + } var body = method == "POST" && echoBody is not null ? echoBody : $"Hello from GlyphServer\r\nMethod: {method}\r\nPath: {path}\r\n"; diff --git a/src/Servers/GunicornServer/app.py b/src/Servers/GunicornServer/app.py index 80e6959..b9feb99 100644 --- a/src/Servers/GunicornServer/app.py +++ b/src/Servers/GunicornServer/app.py @@ -1,6 +1,18 @@ def app(environ, start_response): path = environ.get('PATH_INFO', '/') + if path == '/cookie': + cookie_str = environ.get('HTTP_COOKIE', '') + lines = [] + for pair in cookie_str.split(';'): + pair = pair.strip() + eq = pair.find('=') + if eq > 0: + lines.append(f"{pair[:eq]}={pair[eq+1:]}") + body = ('\n'.join(lines) + '\n').encode('utf-8') if lines else b'' + start_response('200 OK', [('Content-Type', 'text/plain')]) + return [body] + if path == '/echo': lines = [] for key, value in environ.items(): diff --git a/src/Servers/H2OServer/h2o.conf b/src/Servers/H2OServer/h2o.conf index ecc13a5..c38ccd6 100644 --- a/src/Servers/H2OServer/h2o.conf +++ b/src/Servers/H2OServer/h2o.conf @@ -16,6 +16,18 @@ hosts: body += "Content-Type: #{env['CONTENT_TYPE']}\n" if env['CONTENT_TYPE'] && !env['CONTENT_TYPE'].empty? body += "Content-Length: #{env['CONTENT_LENGTH']}\n" if env['CONTENT_LENGTH'] && !env['CONTENT_LENGTH'].empty? [200, {"content-type" => "text/plain"}, [body]] + elsif env["PATH_INFO"] == "/cookie" + body = "" + if env["HTTP_COOKIE"] + env["HTTP_COOKIE"].split(";").each do |pair| + trimmed = pair.lstrip + eq = trimmed.index("=") + if eq && eq > 0 + body += "#{trimmed[0...eq]}=#{trimmed[(eq+1)..]}\n" + end + end + end + [200, {"content-type" => "text/plain"}, [body]] elsif env["REQUEST_METHOD"] == "POST" body = env["rack.input"] ? env["rack.input"].read : "" [200, {"content-type" => "text/plain"}, [body]] diff --git a/src/Servers/HAProxyServer/echo.lua b/src/Servers/HAProxyServer/echo.lua index 05b5e79..509bb58 100644 --- a/src/Servers/HAProxyServer/echo.lua +++ b/src/Servers/HAProxyServer/echo.lua @@ -13,6 +13,27 @@ core.register_service("echo", "http", function(applet) applet:send(body) end) +core.register_service("cookie", "http", function(applet) + local body = "" + local hdrs = applet.headers + if hdrs["cookie"] then + for _, raw in ipairs(hdrs["cookie"]) do + for pair in raw:gmatch("[^;]+") do + local trimmed = pair:match("^%s*(.*)") + local eq = trimmed:find("=") + if eq and eq > 1 then + body = body .. trimmed:sub(1, eq-1) .. "=" .. trimmed:sub(eq+1) .. "\n" + end + end + end + end + applet:set_status(200) + applet:add_header("Content-Type", "text/plain") + applet:add_header("Content-Length", tostring(#body)) + applet:start_response() + applet:send(body) +end) + core.register_service("echo_body", "http", function(applet) local body = applet:receive() if body == nil then body = "" end diff --git a/src/Servers/HAProxyServer/haproxy.cfg b/src/Servers/HAProxyServer/haproxy.cfg index fc01334..1fb1756 100644 --- a/src/Servers/HAProxyServer/haproxy.cfg +++ b/src/Servers/HAProxyServer/haproxy.cfg @@ -11,11 +11,15 @@ defaults frontend http_in bind *:8080 use_backend echo_backend if { path /echo } + use_backend cookie_backend if { path /cookie } use_backend post_echo_backend if { method POST } http-request return status 200 content-type "text/plain" string "OK" backend echo_backend http-request use-service lua.echo +backend cookie_backend + http-request use-service lua.cookie + backend post_echo_backend http-request use-service lua.echo_body diff --git a/src/Servers/HyperServer/src/main.rs b/src/Servers/HyperServer/src/main.rs index 2a596bf..295955a 100644 --- a/src/Servers/HyperServer/src/main.rs +++ b/src/Servers/HyperServer/src/main.rs @@ -21,6 +21,22 @@ async fn handle(req: Request) -> Result collected.to_bytes(), diff --git a/src/Servers/JettyServer/src/main/java/server/Application.java b/src/Servers/JettyServer/src/main/java/server/Application.java index 604f34c..88abfa6 100644 --- a/src/Servers/JettyServer/src/main/java/server/Application.java +++ b/src/Servers/JettyServer/src/main/java/server/Application.java @@ -21,7 +21,22 @@ public boolean handle(Request request, Response response, Callback callback) thr response.setStatus(200); response.getHeaders().put("Content-Type", "text/plain"); - if ("/echo".equals(request.getHttpURI().getPath())) { + if ("/cookie".equals(request.getHttpURI().getPath())) { + StringBuilder sb = new StringBuilder(); + for (HttpField field : request.getHeaders()) { + if ("Cookie".equalsIgnoreCase(field.getName())) { + for (String pair : field.getValue().split(";")) { + String trimmed = pair.stripLeading(); + int eq = trimmed.indexOf('='); + if (eq > 0) { + sb.append(trimmed, 0, eq).append("=").append(trimmed.substring(eq + 1)).append("\n"); + } + } + } + } + byte[] cookieBody = sb.toString().getBytes(StandardCharsets.UTF_8); + response.write(true, ByteBuffer.wrap(cookieBody), callback); + } else if ("/echo".equals(request.getHttpURI().getPath())) { StringBuilder sb = new StringBuilder(); for (HttpField field : request.getHeaders()) { sb.append(field.getName()).append(": ").append(field.getValue()).append("\n"); diff --git a/src/Servers/LighttpdServer/Dockerfile b/src/Servers/LighttpdServer/Dockerfile index f2f97d9..499a3b2 100644 --- a/src/Servers/LighttpdServer/Dockerfile +++ b/src/Servers/LighttpdServer/Dockerfile @@ -3,6 +3,7 @@ RUN apk add --no-cache lighttpd COPY src/Servers/LighttpdServer/lighttpd.conf /etc/lighttpd/lighttpd.conf COPY src/Servers/LighttpdServer/index.cgi /var/www/index.cgi COPY src/Servers/LighttpdServer/echo.cgi /var/www/echo.cgi -RUN chmod +x /var/www/index.cgi /var/www/echo.cgi +COPY src/Servers/LighttpdServer/cookie.cgi /var/www/cookie.cgi +RUN chmod +x /var/www/index.cgi /var/www/echo.cgi /var/www/cookie.cgi EXPOSE 8080 CMD ["lighttpd", "-D", "-f", "/etc/lighttpd/lighttpd.conf"] diff --git a/src/Servers/LighttpdServer/cookie.cgi b/src/Servers/LighttpdServer/cookie.cgi new file mode 100755 index 0000000..827ab97 --- /dev/null +++ b/src/Servers/LighttpdServer/cookie.cgi @@ -0,0 +1,8 @@ +#!/bin/sh +printf 'Content-Type: text/plain\r\n\r\n' +if [ -n "$HTTP_COOKIE" ]; then + echo "$HTTP_COOKIE" | tr ';' '\n' | while read -r pair; do + trimmed=$(echo "$pair" | sed 's/^ *//') + printf '%s\n' "$trimmed" + done +fi diff --git a/src/Servers/LighttpdServer/lighttpd.conf b/src/Servers/LighttpdServer/lighttpd.conf index 5c5ed49..3fd52e7 100644 --- a/src/Servers/LighttpdServer/lighttpd.conf +++ b/src/Servers/LighttpdServer/lighttpd.conf @@ -4,4 +4,4 @@ index-file.names = ("index.cgi") server.modules += ("mod_cgi", "mod_alias") cgi.assign = (".cgi" => "") server.error-handler = "/index.cgi" -alias.url = ("/echo" => "/var/www/echo.cgi") +alias.url = ("/echo" => "/var/www/echo.cgi", "/cookie" => "/var/www/cookie.cgi") diff --git a/src/Servers/NancyServer/Dockerfile b/src/Servers/NancyServer/Dockerfile deleted file mode 100644 index 4894350..0000000 --- a/src/Servers/NancyServer/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build -WORKDIR /src -COPY Directory.Build.props . -COPY src/Servers/NancyServer/ src/Servers/NancyServer/ -RUN dotnet restore src/Servers/NancyServer/NancyServer.csproj -RUN dotnet publish src/Servers/NancyServer/NancyServer.csproj -c Release -o /app --no-restore - -FROM mcr.microsoft.com/dotnet/runtime:8.0 -WORKDIR /app -COPY --from=build /app . -ENTRYPOINT ["dotnet", "NancyServer.dll", "8080"] diff --git a/src/Servers/NancyServer/NancyServer.csproj b/src/Servers/NancyServer/NancyServer.csproj deleted file mode 100644 index baef589..0000000 --- a/src/Servers/NancyServer/NancyServer.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - Exe - net8.0 - - - - - - - - diff --git a/src/Servers/NancyServer/Program.cs b/src/Servers/NancyServer/Program.cs deleted file mode 100644 index 831d378..0000000 --- a/src/Servers/NancyServer/Program.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Nancy; -using Nancy.Hosting.Self; - -var port = args.Length > 0 ? args[0] : "9006"; -var uri = new Uri($"http://0.0.0.0:{port}"); - -var config = new HostConfiguration { RewriteLocalhost = false }; - -using var host = new NancyHost(config, uri); -host.Start(); - -Console.WriteLine($"Nancy listening on {uri}"); - -var waitHandle = new ManualResetEvent(false); -Console.CancelKeyPress += (_, e) => { e.Cancel = true; waitHandle.Set(); }; -waitHandle.WaitOne(); - -public class EchoModule : NancyModule -{ - public EchoModule() : base("/echo") - { - Get("/", _ => EchoHeaders()); - Post("/", _ => EchoHeaders()); - Put("/", _ => EchoHeaders()); - Delete("/", _ => EchoHeaders()); - Patch("/", _ => EchoHeaders()); - } - - private string EchoHeaders() - { - var sb = new System.Text.StringBuilder(); - foreach (var h in Request.Headers) - foreach (var v in h.Value) - sb.AppendLine($"{h.Key}: {v}"); - return sb.ToString(); - } -} - -public class HomeModule : NancyModule -{ - public HomeModule() - { - Get("/{path*}", _ => "OK"); - Get("/", _ => "OK"); - Post("/{path*}", _ => EchoBody()); - Post("/", _ => EchoBody()); - } - - private string EchoBody() - { - using var reader = new System.IO.StreamReader(Request.Body); - return reader.ReadToEnd(); - } -} diff --git a/src/Servers/NetCoreServerFramework/Program.cs b/src/Servers/NetCoreServerFramework/Program.cs index fe2c2a2..fc9791a 100644 --- a/src/Servers/NetCoreServerFramework/Program.cs +++ b/src/Servers/NetCoreServerFramework/Program.cs @@ -31,6 +31,25 @@ protected override void OnReceivedRequest(HttpRequest request) } SendResponseAsync(Response.MakeOkResponse(200).SetBody(sb.ToString())); } + else if (request.Url == "/cookie") + { + var sb = new System.Text.StringBuilder(); + for (int i = 0; i < request.Headers; i++) + { + var (name, value) = request.Header(i); + if (string.Equals(name, "Cookie", StringComparison.OrdinalIgnoreCase)) + { + foreach (var pair in value.Split(';')) + { + var trimmed = pair.TrimStart(); + var eqIdx = trimmed.IndexOf('='); + if (eqIdx > 0) + sb.AppendLine($"{trimmed[..eqIdx]}={trimmed[(eqIdx + 1)..]}"); + } + } + } + SendResponseAsync(Response.MakeOkResponse(200).SetBody(sb.ToString())); + } else if (request.Method == "POST" && request.Body.Length > 0) SendResponseAsync(Response.MakeOkResponse(200).SetBody(request.Body)); else diff --git a/src/Servers/NginxServer/echo.js b/src/Servers/NginxServer/echo.js index 8d1606b..58b19f4 100644 --- a/src/Servers/NginxServer/echo.js +++ b/src/Servers/NginxServer/echo.js @@ -7,6 +7,22 @@ function echo(r) { r.return(200, body); } +function cookie(r) { + var body = ''; + var raw = r.headersIn['Cookie']; + if (raw) { + var pairs = raw.split(';'); + for (var i = 0; i < pairs.length; i++) { + var trimmed = pairs[i].replace(/^\s+/, ''); + var eq = trimmed.indexOf('='); + if (eq > 0) { + body += trimmed.substring(0, eq) + '=' + trimmed.substring(eq + 1) + '\n'; + } + } + } + r.return(200, body); +} + function handler(r) { if (r.method === 'POST') { r.return(200, r.requestText || ''); @@ -15,4 +31,4 @@ function handler(r) { } } -export default { echo, handler }; +export default { echo, cookie, handler }; diff --git a/src/Servers/NginxServer/nginx.conf b/src/Servers/NginxServer/nginx.conf index ce836c5..97b9157 100644 --- a/src/Servers/NginxServer/nginx.conf +++ b/src/Servers/NginxServer/nginx.conf @@ -26,6 +26,10 @@ http { js_content echo.echo; } + location /cookie { + js_content echo.cookie; + } + location / { js_content echo.handler; } diff --git a/src/Servers/NodeServer/server.js b/src/Servers/NodeServer/server.js index 1526756..d4720af 100644 --- a/src/Servers/NodeServer/server.js +++ b/src/Servers/NodeServer/server.js @@ -9,7 +9,17 @@ const server = http.createServer((req, res) => { } catch { pathname = req.url; } - if (pathname === '/echo') { + if (pathname === '/cookie') { + let body = ''; + const raw = req.headers.cookie || ''; + for (const pair of raw.split(';')) { + const trimmed = pair.trimStart(); + const eq = trimmed.indexOf('='); + if (eq > 0) body += trimmed.substring(0, eq) + '=' + trimmed.substring(eq + 1) + '\n'; + } + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end(body); + } else if (pathname === '/echo') { let body = ''; for (const [name, value] of Object.entries(req.headers)) { if (Array.isArray(value)) value.forEach(v => body += name + ': ' + v + '\n'); diff --git a/src/Servers/NtexServer/src/main.rs b/src/Servers/NtexServer/src/main.rs index 96c5074..d440732 100644 --- a/src/Servers/NtexServer/src/main.rs +++ b/src/Servers/NtexServer/src/main.rs @@ -9,6 +9,19 @@ async fn echo(req: web::HttpRequest) -> impl web::Responder { web::HttpResponse::Ok().content_type("text/plain").body(body) } +async fn cookie(req: web::HttpRequest) -> impl web::Responder { + let mut body = String::new(); + if let Some(raw) = req.headers().get("cookie").and_then(|v| v.to_str().ok()) { + for pair in raw.split(';') { + let trimmed = pair.trim_start(); + if let Some(eq) = trimmed.find('=') { + body.push_str(&format!("{}={}\n", &trimmed[..eq], &trimmed[eq+1..])); + } + } + } + web::HttpResponse::Ok().content_type("text/plain").body(body) +} + async fn handler(req: web::HttpRequest, body: Bytes) -> web::HttpResponse { if req.method() == ntex::http::Method::POST { web::HttpResponse::Ok() @@ -31,6 +44,7 @@ async fn main() -> std::io::Result<()> { web::server(|| { web::App::new() .route("/echo", web::to(echo)) + .route("/cookie", web::to(cookie)) .default_service(web::to(handler)) }) .bind(("0.0.0.0", port))? diff --git a/src/Servers/PhpServer/index.php b/src/Servers/PhpServer/index.php index 883666a..9d18334 100644 --- a/src/Servers/PhpServer/index.php +++ b/src/Servers/PhpServer/index.php @@ -7,6 +7,14 @@ exit; } +if ($_SERVER['REQUEST_URI'] === '/cookie') { + header('Content-Type: text/plain'); + foreach ($_COOKIE as $name => $value) { + echo "$name=$value\n"; + } + exit; +} + header('Content-Type: text/plain'); if ($_SERVER['REQUEST_METHOD'] === 'POST') { echo file_get_contents('php://input'); diff --git a/src/Servers/PingoraServer/src/main.rs b/src/Servers/PingoraServer/src/main.rs index 54edf0c..62193f0 100644 --- a/src/Servers/PingoraServer/src/main.rs +++ b/src/Servers/PingoraServer/src/main.rs @@ -17,6 +17,30 @@ impl ProxyHttp for OkProxy { session: &mut Session, _ctx: &mut Self::CTX, ) -> Result { + let is_cookie = session.req_header().uri.path() == "/cookie"; + if is_cookie { + let mut body_str = String::new(); + if let Some(raw) = session.req_header().headers.get("cookie").and_then(|v| v.to_str().ok()) { + for pair in raw.split(';') { + let trimmed = pair.trim_start(); + if let Some(eq) = trimmed.find('=') { + body_str.push_str(&format!("{}={}\n", &trimmed[..eq], &trimmed[eq+1..])); + } + } + } + let body = Bytes::from(body_str); + let mut header = ResponseHeader::build(200, None)?; + header.insert_header("Content-Type", "text/plain")?; + header.insert_header("Content-Length", &body.len().to_string())?; + session + .write_response_header(Box::new(header), false) + .await?; + session + .write_response_body(Some(body), true) + .await?; + return Ok(true); + } + let is_echo = session.req_header().uri.path() == "/echo"; if is_echo { let mut body_str = String::new(); diff --git a/src/Servers/PumaServer/config.ru b/src/Servers/PumaServer/config.ru index 08fd7bb..0afc58c 100644 --- a/src/Servers/PumaServer/config.ru +++ b/src/Servers/PumaServer/config.ru @@ -5,6 +5,18 @@ app = proc { |env| body += "Content-Type: #{env['CONTENT_TYPE']}\n" if env['CONTENT_TYPE'] body += "Content-Length: #{env['CONTENT_LENGTH']}\n" if env['CONTENT_LENGTH'] [200, { 'Content-Type' => 'text/plain' }, [body]] + elsif env['PATH_INFO'] == '/cookie' + body = "" + if env['HTTP_COOKIE'] + env['HTTP_COOKIE'].split(';').each do |pair| + trimmed = pair.lstrip + eq = trimmed.index('=') + if eq && eq > 0 + body += "#{trimmed[0...eq]}=#{trimmed[(eq+1)..]}\n" + end + end + end + [200, { 'Content-Type' => 'text/plain' }, [body]] elsif env['REQUEST_METHOD'] == 'POST' body = env['rack.input'].read [200, { 'content-type' => 'text/plain' }, [body]] diff --git a/src/Servers/QuarkusServer/src/main/java/server/Application.java b/src/Servers/QuarkusServer/src/main/java/server/Application.java index f630de9..138747d 100644 --- a/src/Servers/QuarkusServer/src/main/java/server/Application.java +++ b/src/Servers/QuarkusServer/src/main/java/server/Application.java @@ -31,6 +31,20 @@ public byte[] catchAllPost(InputStream body) throws IOException { return body.readAllBytes(); } + @GET + @Path("/cookie") + @Produces(MediaType.TEXT_PLAIN) + public Response cookieGet(@Context HttpHeaders headers) { + return parseCookies(headers); + } + + @POST + @Path("/cookie") + @Produces(MediaType.TEXT_PLAIN) + public Response cookiePost(@Context HttpHeaders headers) { + return parseCookies(headers); + } + @GET @Path("/echo") @Produces(MediaType.TEXT_PLAIN) @@ -45,6 +59,23 @@ public Response echoPost(@Context HttpHeaders headers) { return echoHeaders(headers); } + private Response parseCookies(HttpHeaders headers) { + StringBuilder sb = new StringBuilder(); + List cookieHeaders = headers.getRequestHeader("Cookie"); + if (cookieHeaders != null) { + for (String raw : cookieHeaders) { + for (String pair : raw.split(";")) { + String trimmed = pair.stripLeading(); + int eq = trimmed.indexOf('='); + if (eq > 0) { + sb.append(trimmed, 0, eq).append("=").append(trimmed.substring(eq + 1)).append("\n"); + } + } + } + } + return Response.ok(sb.toString(), MediaType.TEXT_PLAIN).build(); + } + private Response echoHeaders(HttpHeaders headers) { StringBuilder sb = new StringBuilder(); for (Map.Entry> entry : headers.getRequestHeaders().entrySet()) { diff --git a/src/Servers/ServiceStackServer/Program.cs b/src/Servers/ServiceStackServer/Program.cs index 9038620..62d37ee 100644 --- a/src/Servers/ServiceStackServer/Program.cs +++ b/src/Servers/ServiceStackServer/Program.cs @@ -12,6 +12,13 @@ sb.AppendLine($"{h.Key}: {v}"); return Results.Text(sb.ToString()); }); +app.Map("/cookie", (HttpContext ctx) => +{ + var sb = new System.Text.StringBuilder(); + foreach (var cookie in ctx.Request.Cookies) + sb.AppendLine($"{cookie.Key}={cookie.Value}"); + return Results.Text(sb.ToString()); +}); app.MapFallback(async (HttpContext ctx) => { if (ctx.Request.Method == "POST") diff --git a/src/Servers/SimpleWServer/Program.cs b/src/Servers/SimpleWServer/Program.cs index a6d72e4..80e8340 100644 --- a/src/Servers/SimpleWServer/Program.cs +++ b/src/Servers/SimpleWServer/Program.cs @@ -6,6 +6,8 @@ var server = new SimpleWServer(IPAddress.Any, port); +server.MapGet("/cookie", (HttpSession session) => ParseCookies(session)); +server.MapPost("/cookie", (HttpSession session) => ParseCookies(session)); server.MapGet("/echo", (HttpSession session) => { var sb = new System.Text.StringBuilder(); @@ -25,6 +27,25 @@ server.MapPost("/", (HttpSession session) => session.Request.BodyString); server.MapPost("/{path}", (HttpSession session) => session.Request.BodyString); +static string ParseCookies(HttpSession session) +{ + var sb = new System.Text.StringBuilder(); + foreach (var h in session.Request.Headers.EnumerateAll()) + { + if (string.Equals(h.Key, "Cookie", StringComparison.OrdinalIgnoreCase)) + { + foreach (var pair in h.Value.Split(';')) + { + var trimmed = pair.TrimStart(); + var eqIdx = trimmed.IndexOf('='); + if (eqIdx > 0) + sb.AppendLine($"{trimmed[..eqIdx]}={trimmed[(eqIdx + 1)..]}"); + } + } + } + return sb.ToString(); +} + Console.WriteLine($"SimpleW listening on http://localhost:{port}"); await server.RunAsync(); diff --git a/src/Servers/SiskServer/Program.cs b/src/Servers/SiskServer/Program.cs index 87902c9..bc50dd6 100644 --- a/src/Servers/SiskServer/Program.cs +++ b/src/Servers/SiskServer/Program.cs @@ -17,6 +17,27 @@ sb.AppendLine($"{h.Key}: {val}"); return new HttpResponse(200).WithContent(sb.ToString()); } + if (request.Path == "/cookie") + { + var sb = new System.Text.StringBuilder(); + foreach (var h in request.Headers) + { + if (string.Equals(h.Key, "Cookie", StringComparison.OrdinalIgnoreCase)) + { + foreach (var rawVal in h.Value) + { + foreach (var pair in rawVal.Split(';')) + { + var trimmed = pair.TrimStart(); + var eqIdx = trimmed.IndexOf('='); + if (eqIdx > 0) + sb.AppendLine($"{trimmed[..eqIdx]}={trimmed[(eqIdx + 1)..]}"); + } + } + } + } + return new HttpResponse(200).WithContent(sb.ToString()); + } if (request.Method == HttpMethod.Post && request.Body is not null) { var body = request.Body; diff --git a/src/Servers/SpringBootServer/src/main/java/server/Application.java b/src/Servers/SpringBootServer/src/main/java/server/Application.java index 9903cc0..77cad06 100644 --- a/src/Servers/SpringBootServer/src/main/java/server/Application.java +++ b/src/Servers/SpringBootServer/src/main/java/server/Application.java @@ -31,6 +31,18 @@ public byte[] indexPost(HttpServletRequest request) throws IOException { return request.getInputStream().readAllBytes(); } + @RequestMapping("/cookie") + public ResponseEntity cookieEndpoint(HttpServletRequest request) { + StringBuilder sb = new StringBuilder(); + jakarta.servlet.http.Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (jakarta.servlet.http.Cookie c : cookies) { + sb.append(c.getName()).append("=").append(c.getValue()).append("\n"); + } + } + return ResponseEntity.ok().contentType(MediaType.TEXT_PLAIN).body(sb.toString()); + } + @RequestMapping("/echo") public ResponseEntity echo(HttpServletRequest request) { StringBuilder sb = new StringBuilder(); diff --git a/src/Servers/TomcatServer/webapp/WEB-INF/web.xml b/src/Servers/TomcatServer/webapp/WEB-INF/web.xml index 76b6127..fff22c3 100644 --- a/src/Servers/TomcatServer/webapp/WEB-INF/web.xml +++ b/src/Servers/TomcatServer/webapp/WEB-INF/web.xml @@ -9,6 +9,15 @@ /echo + + cookie + /cookie.jsp + + + cookie + /cookie + + ok /ok.jsp diff --git a/src/Servers/TomcatServer/webapp/cookie.jsp b/src/Servers/TomcatServer/webapp/cookie.jsp new file mode 100644 index 0000000..87b1ce2 --- /dev/null +++ b/src/Servers/TomcatServer/webapp/cookie.jsp @@ -0,0 +1,8 @@ +<%@page contentType="text/plain"%><% +jakarta.servlet.http.Cookie[] cookies = request.getCookies(); +if (cookies != null) { + for (jakarta.servlet.http.Cookie c : cookies) { + out.print(c.getName() + "=" + c.getValue() + "\n"); + } +} +%> \ No newline at end of file diff --git a/src/Servers/TraefikServer/dynamic.yml b/src/Servers/TraefikServer/dynamic.yml index 6b25ace..4a9ac40 100644 --- a/src/Servers/TraefikServer/dynamic.yml +++ b/src/Servers/TraefikServer/dynamic.yml @@ -6,6 +6,12 @@ http: - web service: echo-svc + cookie: + rule: "Path(`/cookie`)" + entryPoints: + - web + service: echo-svc + catchall: rule: "PathPrefix(`/`)" entryPoints: diff --git a/src/Servers/TraefikServer/echo/main.go b/src/Servers/TraefikServer/echo/main.go index 9a930e2..2e41f64 100644 --- a/src/Servers/TraefikServer/echo/main.go +++ b/src/Servers/TraefikServer/echo/main.go @@ -3,9 +3,21 @@ package main import ( "io" "net/http" + "strings" ) func main() { + http.HandleFunc("/cookie", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + raw := r.Header.Get("Cookie") + for _, pair := range strings.Split(raw, ";") { + pair = strings.TrimLeft(pair, " ") + if eq := strings.Index(pair, "="); eq > 0 { + w.Write([]byte(pair[:eq] + "=" + pair[eq+1:] + "\n")) + } + } + }) + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) diff --git a/src/Servers/UvicornServer/app.py b/src/Servers/UvicornServer/app.py index f964cfc..b1a7b5c 100644 --- a/src/Servers/UvicornServer/app.py +++ b/src/Servers/UvicornServer/app.py @@ -1,6 +1,30 @@ async def app(scope, receive, send): path = scope.get('path', '/') + if path == '/cookie': + cookie_val = '' + for name, value in scope.get('headers', []): + if name.lower() == b'cookie': + cookie_val = value.decode('latin-1') + break + lines = [] + for pair in cookie_val.split(';'): + pair = pair.strip() + eq = pair.find('=') + if eq > 0: + lines.append(f"{pair[:eq]}={pair[eq+1:]}") + body = ('\n'.join(lines) + '\n').encode('utf-8') if lines else b'' + await send({ + 'type': 'http.response.start', + 'status': 200, + 'headers': [(b'content-type', b'text/plain')], + }) + await send({ + 'type': 'http.response.body', + 'body': body, + }) + return + if path == '/echo': lines = [] for name, value in scope.get('headers', []): From 6a0a2171d975fd888fb79f2b069bb821cb0ed870 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Tue, 17 Feb 2026 09:52:06 +0000 Subject: [PATCH 3/3] update GenHTTP --- docs/content/docs/caching/etag-304.md | 4 +- docs/content/docs/caching/etag-in-304.md | 4 +- docs/content/docs/caching/etag-weak.md | 2 +- docs/content/docs/caching/inm-precedence.md | 4 +- docs/content/docs/caching/inm-unquoted.md | 4 +- .../content/docs/caching/last-modified-304.md | 4 +- docs/content/docs/cookies/_index.md | 42 +------------------ docs/content/docs/cookies/control-chars.md | 26 ++++-------- docs/content/docs/cookies/echo.md | 23 ++++------ docs/content/docs/cookies/empty.md | 23 ++++------ docs/content/docs/cookies/malformed.md | 25 ++++------- docs/content/docs/cookies/many-pairs.md | 31 +++++--------- docs/content/docs/cookies/multi-header.md | 28 +++++-------- docs/content/docs/cookies/nul.md | 29 +++++-------- docs/content/docs/cookies/oversized.md | 26 +++++------- docs/content/docs/cookies/parsed-basic.md | 25 +++++------ docs/content/docs/cookies/parsed-empty-val.md | 25 +++++------ docs/content/docs/cookies/parsed-multi.md | 25 +++++------ docs/content/docs/cookies/parsed-special.md | 29 +++++-------- docs/static/probe/render.js | 14 ++++++- .../GenHttpServer/GenHttpServer.csproj | 4 +- src/Servers/GenHttpServer/Program.cs | 12 ++---- 22 files changed, 148 insertions(+), 261 deletions(-) diff --git a/docs/content/docs/caching/etag-304.md b/docs/content/docs/caching/etag-304.md index 3608a10..544b243 100644 --- a/docs/content/docs/caching/etag-304.md +++ b/docs/content/docs/caching/etag-304.md @@ -34,11 +34,11 @@ Captures the `ETag` header from the response for use in step 2. ```http GET / HTTP/1.1\r\n Host: localhost:8080\r\n -If-None-Match: "abc123"\r\n +If-None-Match: {ETag from step 1}\r\n \r\n ``` -Sends the captured ETag value in an `If-None-Match` header. If the resource hasn't changed, the server should return `304 Not Modified`. +Replays the `ETag` value captured from step 1 in an `If-None-Match` header. If the resource hasn't changed, the server should return `304 Not Modified`. If the server did not include an `ETag` header in step 1, the test reports Warn immediately. ## What the RFC says diff --git a/docs/content/docs/caching/etag-in-304.md b/docs/content/docs/caching/etag-in-304.md index a4bfea1..6ca3304 100644 --- a/docs/content/docs/caching/etag-in-304.md +++ b/docs/content/docs/caching/etag-in-304.md @@ -34,11 +34,11 @@ Captures the `ETag` header from the response. ```http GET / HTTP/1.1\r\n Host: localhost:8080\r\n -If-None-Match: "abc123"\r\n +If-None-Match: {ETag from step 1}\r\n \r\n ``` -Sends the captured ETag. If the server returns `304`, this test checks whether the `ETag` header is present in that response. +Replays the `ETag` value captured from step 1. If the server returns `304`, this test checks whether the `ETag` header is present in that response. ## What the RFC says diff --git a/docs/content/docs/caching/etag-weak.md b/docs/content/docs/caching/etag-weak.md index 9ce2a66..9538320 100644 --- a/docs/content/docs/caching/etag-weak.md +++ b/docs/content/docs/caching/etag-weak.md @@ -34,7 +34,7 @@ Captures the `ETag` header from the response. If the ETag is strong (e.g., `"abc ```http GET / HTTP/1.1\r\n Host: localhost:8080\r\n -If-None-Match: W/"abc123"\r\n +If-None-Match: {ETag from step 1, weak}\r\n \r\n ``` diff --git a/docs/content/docs/caching/inm-precedence.md b/docs/content/docs/caching/inm-precedence.md index f2fe8ea..1cd7ad3 100644 --- a/docs/content/docs/caching/inm-precedence.md +++ b/docs/content/docs/caching/inm-precedence.md @@ -34,12 +34,12 @@ Captures the `ETag` header from the response. ```http GET / HTTP/1.1\r\n Host: localhost:8080\r\n -If-None-Match: "abc123"\r\n +If-None-Match: {ETag from step 1}\r\n If-Modified-Since: Thu, 01 Jan 1970 00:00:00 GMT\r\n \r\n ``` -The `If-None-Match` header matches the current ETag (should produce `304`), but the `If-Modified-Since` is set to epoch (should produce `200` since the resource was certainly modified after 1970). If the server returns `304`, it correctly evaluated `If-None-Match` first. +Replays the `ETag` value captured from step 1 in `If-None-Match` (should produce `304`), combined with `If-Modified-Since` set to epoch (should produce `200` since the resource was certainly modified after 1970). If the server returns `304`, it correctly evaluated `If-None-Match` first. ## What the RFC says diff --git a/docs/content/docs/caching/inm-unquoted.md b/docs/content/docs/caching/inm-unquoted.md index 2326500..06cc667 100644 --- a/docs/content/docs/caching/inm-unquoted.md +++ b/docs/content/docs/caching/inm-unquoted.md @@ -34,11 +34,11 @@ Captures the `ETag` header from the response for use in step 2. ```http GET / HTTP/1.1\r\n Host: localhost:8080\r\n -If-None-Match: abc123\r\n +If-None-Match: {ETag from step 1, unquoted}\r\n \r\n ``` -Sends the ETag value without the required surrounding double quotes. According to the RFC grammar, `entity-tag = [ weak ] opaque-tag` and `opaque-tag = DQUOTE *etagc DQUOTE` — the quotes are mandatory. +Sends the ETag value captured from step 1, stripped of the required surrounding double quotes. According to the RFC grammar, `entity-tag = [ weak ] opaque-tag` and `opaque-tag = DQUOTE *etagc DQUOTE` — the quotes are mandatory. ## What the RFC says diff --git a/docs/content/docs/caching/last-modified-304.md b/docs/content/docs/caching/last-modified-304.md index d8d327f..aec84da 100644 --- a/docs/content/docs/caching/last-modified-304.md +++ b/docs/content/docs/caching/last-modified-304.md @@ -34,11 +34,11 @@ Captures the `Last-Modified` header from the response for use in step 2. ```http GET / HTTP/1.1\r\n Host: localhost:8080\r\n -If-Modified-Since: Sun, 01 Jan 2025 00:00:00 GMT\r\n +If-Modified-Since: {Last-Modified from step 1}\r\n \r\n ``` -Sends the captured Last-Modified value in an `If-Modified-Since` header. If the resource hasn't changed since that date, the server should return `304 Not Modified`. +Replays the `Last-Modified` value captured from step 1 in an `If-Modified-Since` header. If the resource hasn't changed since that date, the server should return `304 Not Modified`. If the server did not include a `Last-Modified` header in step 1, the test reports Warn immediately. ## What the RFC says diff --git a/docs/content/docs/cookies/_index.md b/docs/content/docs/cookies/_index.md index 0acf4ab..a8e90a2 100644 --- a/docs/content/docs/cookies/_index.md +++ b/docs/content/docs/cookies/_index.md @@ -1,45 +1,5 @@ --- -title: Cookies -description: "Cookies — Http11Probe documentation" -weight: 13 +title: Cookie Handling sidebar: open: false --- - -Cookie parsing is handled by framework-level parsers that run automatically on every request. Malformed `Cookie` headers can crash these parsers, cause memory issues, or produce mangled values. These tests check whether servers and frameworks survive adversarial cookie input. - -Cookies are defined by [RFC 6265](https://www.rfc-editor.org/rfc/rfc6265) (not RFC 9110/9112), so all tests are **unscored**. - -## Scoring - -All cookie tests are **unscored**: - -- **Pass** — Server handled the cookie input safely -- **Warn** — Endpoint not available or non-ideal but non-dangerous behavior -- **Fail** — Server crashed (500), preserved dangerous bytes, or lost data it should have parsed - -## Echo-Based Tests - -These tests target `/echo` and work on all servers. They check whether the server survives adversarial cookie headers without crashing. - -{{< cards >}} - {{< card link="echo" title="ECHO" subtitle="Basic Cookie header echoed back." >}} - {{< card link="oversized" title="OVERSIZED" subtitle="64KB Cookie header." >}} - {{< card link="empty" title="EMPTY" subtitle="Empty Cookie header value." >}} - {{< card link="nul" title="NUL" subtitle="NUL byte in cookie value." >}} - {{< card link="control-chars" title="CONTROL-CHARS" subtitle="Control characters in cookie value." >}} - {{< card link="many-pairs" title="MANY-PAIRS" subtitle="1000 cookie key=value pairs." >}} - {{< card link="malformed" title="MALFORMED" subtitle="Completely malformed cookie syntax." >}} - {{< card link="multi-header" title="MULTI-HEADER" subtitle="Two separate Cookie headers." >}} -{{< /cards >}} - -## Parsed-Cookie Tests - -These tests target `/cookie` and check whether the framework's cookie parser correctly extracts key=value pairs. Servers without a `/cookie` endpoint return 404 (Warn). - -{{< cards >}} - {{< card link="parsed-basic" title="PARSED-BASIC" subtitle="Single foo=bar cookie parsed." >}} - {{< card link="parsed-multi" title="PARSED-MULTI" subtitle="Three cookies parsed from one header." >}} - {{< card link="parsed-empty-val" title="PARSED-EMPTY-VAL" subtitle="Cookie with empty value." >}} - {{< card link="parsed-special" title="PARSED-SPECIAL" subtitle="Spaces and = in cookie values." >}} -{{< /cards >}} diff --git a/docs/content/docs/cookies/control-chars.md b/docs/content/docs/cookies/control-chars.md index de6f9f5..eacceed 100644 --- a/docs/content/docs/cookies/control-chars.md +++ b/docs/content/docs/cookies/control-chars.md @@ -1,6 +1,6 @@ --- title: "CONTROL-CHARS" -description: "COOK-CONTROL-CHARS test documentation" +description: "COOK-CONTROL-CHARS cookie test documentation" weight: 5 --- @@ -9,10 +9,13 @@ weight: 5 | **Test ID** | `COOK-CONTROL-CHARS` | | **Category** | Cookies | | **Scored** | No | -| **Expected** | `400` (rejected) or `2xx` without control chars | +| **RFC Level** | N/A | +| **Expected** | `400 (rejected) or 2xx without control chars` | ## What it sends +Control characters (0x01-0x03) in cookie value — dangerous if preserved. + ```http GET /echo HTTP/1.1\r\n Host: localhost:8080\r\n @@ -20,26 +23,15 @@ Cookie: foo=\x01\x02\x03\r\n \r\n ``` -A `Cookie` header containing control characters SOH (`0x01`), STX (`0x02`), and ETX (`0x03`) as the cookie value. - -## What the RFC says - -> "cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E" — RFC 6265 §4.1.1 - -Control characters (`0x00-0x1F`) are explicitly excluded from the `cookie-octet` production. They are not valid in cookie values. - ## Why it matters -Control characters in cookie values can cause: -- **Log injection** — if the bytes reach log files, they may corrupt formatting or inject terminal escape sequences -- **Parser confusion** — some parsers may interpret control characters as delimiters -- **Security filter bypass** — WAFs may not inspect or sanitize non-printable bytes +Control characters in cookie values violate RFC 6265's cookie-octet grammar and can enable response splitting or log injection if passed through to output. ## Verdicts -- **Pass** — `400` (rejected) or `2xx` with control characters stripped/cookie dropped -- **Fail** — `2xx` with control characters preserved in the response body +- **Pass** — 400 rejected, or 2xx with control chars stripped +- **Fail** — 2xx with control chars preserved (dangerous), or 500 ## Sources -- [RFC 6265 §4.1.1](https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1) — cookie-octet definition +- [RFC 6265 §5.4](https://www.rfc-editor.org/rfc/rfc6265#section-5.4) — Cookie header diff --git a/docs/content/docs/cookies/echo.md b/docs/content/docs/cookies/echo.md index b7a77ef..43eaa7a 100644 --- a/docs/content/docs/cookies/echo.md +++ b/docs/content/docs/cookies/echo.md @@ -1,6 +1,6 @@ --- title: "ECHO" -description: "COOK-ECHO test documentation" +description: "COOK-ECHO cookie test documentation" weight: 1 --- @@ -9,10 +9,13 @@ weight: 1 | **Test ID** | `COOK-ECHO` | | **Category** | Cookies | | **Scored** | No | -| **Expected** | `2xx` with `Cookie:` in echo body | +| **RFC Level** | N/A | +| **Expected** | `2xx with Cookie in body` | ## What it sends +Basic Cookie header echoed back by /echo endpoint. + ```http GET /echo HTTP/1.1\r\n Host: localhost:8080\r\n @@ -20,23 +23,15 @@ Cookie: foo=bar\r\n \r\n ``` -A standard request with a simple, valid `Cookie` header targeting the `/echo` endpoint. - -## What the RFC says - -> "When the user agent generates an HTTP request, the user agent MUST NOT attach more than one header field named Cookie." — RFC 6265 §5.4 - -This test sends a single, well-formed `Cookie` header. It serves as a baseline to confirm the echo endpoint reflects cookie headers. - ## Why it matters -This is the baseline cookie test. If the server cannot echo back a simple `Cookie: foo=bar` header, all other cookie tests are meaningless. +Baseline test — verifies the server's echo endpoint reflects Cookie headers, which is required for all other cookie tests to work. ## Verdicts -- **Pass** — 2xx response with `Cookie:` visible in the echo body -- **Fail** — No response, or 2xx without the cookie header in the body +- **Pass** — 2xx and body contains `Cookie:` header +- **Fail** — No response or missing header ## Sources -- [RFC 6265 §5.4](https://www.rfc-editor.org/rfc/rfc6265#section-5.4) — sending cookies +- [RFC 6265 §5.4](https://www.rfc-editor.org/rfc/rfc6265#section-5.4) — Cookie header diff --git a/docs/content/docs/cookies/empty.md b/docs/content/docs/cookies/empty.md index d6fc3ef..6565104 100644 --- a/docs/content/docs/cookies/empty.md +++ b/docs/content/docs/cookies/empty.md @@ -1,6 +1,6 @@ --- title: "EMPTY" -description: "COOK-EMPTY test documentation" +description: "COOK-EMPTY cookie test documentation" weight: 3 --- @@ -9,10 +9,13 @@ weight: 3 | **Test ID** | `COOK-EMPTY` | | **Category** | Cookies | | **Scored** | No | -| **Expected** | `2xx` or `400` | +| **RFC Level** | N/A | +| **Expected** | `2xx or 400` | ## What it sends +Empty Cookie header value — tests parser resilience. + ```http GET /echo HTTP/1.1\r\n Host: localhost:8080\r\n @@ -20,23 +23,15 @@ Cookie: \r\n \r\n ``` -A `Cookie` header with an empty value (nothing after the colon and space). - -## What the RFC says - -> "cookie-header = 'Cookie:' OWS cookie-string OWS" — RFC 6265 §4.2 - -An empty cookie-string does not match `cookie-pair *( ";" SP cookie-pair )` since `cookie-pair` requires at least a name. However, servers should handle this gracefully. - ## Why it matters -Empty `Cookie` headers can trigger null-pointer dereferences or empty-string edge cases in cookie parsers. The test verifies the server doesn't crash. +Empty Cookie headers can cause null-reference exceptions or crashes in parsers that assume at least one key=value pair. ## Verdicts -- **Pass** — `2xx` (accepted) or `400` (rejected gracefully) -- **Fail** — `500` or connection crash +- **Pass** — 2xx or 400 +- **Fail** — 500 (crash) ## Sources -- [RFC 6265 §4.2](https://www.rfc-editor.org/rfc/rfc6265#section-4.2) — cookie header syntax +- [RFC 6265 §5.4](https://www.rfc-editor.org/rfc/rfc6265#section-5.4) — Cookie header diff --git a/docs/content/docs/cookies/malformed.md b/docs/content/docs/cookies/malformed.md index 3c7f1c9..c1d5106 100644 --- a/docs/content/docs/cookies/malformed.md +++ b/docs/content/docs/cookies/malformed.md @@ -1,6 +1,6 @@ --- title: "MALFORMED" -description: "COOK-MALFORMED test documentation" +description: "COOK-MALFORMED cookie test documentation" weight: 7 --- @@ -9,10 +9,13 @@ weight: 7 | **Test ID** | `COOK-MALFORMED` | | **Category** | Cookies | | **Scored** | No | -| **Expected** | `2xx` or `400` | +| **RFC Level** | N/A | +| **Expected** | `2xx or 400` | ## What it sends +Completely malformed cookie value (===;;;) — tests parser crash resilience. + ```http GET /echo HTTP/1.1\r\n Host: localhost:8080\r\n @@ -20,25 +23,15 @@ Cookie: ===;;;\r\n \r\n ``` -A `Cookie` header with completely invalid syntax — no valid cookie-name, only equals signs and semicolons. - -## What the RFC says - -> "cookie-pair = cookie-name '=' cookie-value" — RFC 6265 §4.1.1 - -> "cookie-name = token" — RFC 6265 §4.1.1 - -The value `===;;;` does not match the `cookie-pair` grammar. There is no valid `cookie-name` (an empty name before the first `=` is not a valid token). - ## Why it matters -Framework cookie parsers must handle completely malformed cookie strings gracefully. This tests the worst-case scenario for parser resilience — the value bears no resemblance to valid cookie syntax. +Garbage cookie values with no valid key=value structure can crash naive parsers that split on `=` without bounds checking. ## Verdicts -- **Pass** — `2xx` (survived) or `400` (rejected gracefully) -- **Fail** — `500` or connection crash +- **Pass** — 2xx or 400 +- **Fail** — 500 (crash) ## Sources -- [RFC 6265 §4.1.1](https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1) — cookie-pair syntax +- [RFC 6265 §5.4](https://www.rfc-editor.org/rfc/rfc6265#section-5.4) — Cookie header diff --git a/docs/content/docs/cookies/many-pairs.md b/docs/content/docs/cookies/many-pairs.md index d22b274..7e73026 100644 --- a/docs/content/docs/cookies/many-pairs.md +++ b/docs/content/docs/cookies/many-pairs.md @@ -1,6 +1,6 @@ --- title: "MANY-PAIRS" -description: "COOK-MANY-PAIRS test documentation" +description: "COOK-MANY-PAIRS cookie test documentation" weight: 6 --- @@ -9,40 +9,29 @@ weight: 6 | **Test ID** | `COOK-MANY-PAIRS` | | **Category** | Cookies | | **Scored** | No | -| **Expected** | `2xx` or `400`/`431` | +| **RFC Level** | N/A | +| **Expected** | `2xx or 400/431` | ## What it sends +1000 cookie key=value pairs — tests parser performance limits. + ```http GET /echo HTTP/1.1\r\n Host: localhost:8080\r\n -Cookie: k0=v0; k1=v1; k2=v2; ... ; k999=v999\r\n +Cookie: k0=v0; k1=v1; ... k999=v999\r\n \r\n ``` -A `Cookie` header containing 1000 semicolon-separated key=value pairs. - -## What the RFC says - -> "At least 50 cookies per domain." — RFC 6265 §6.1 - -The practical limit of 50 cookies per domain is a user-agent guideline. Servers have no mandated limit, but 1000 pairs in a single header tests parser performance boundaries. - ## Why it matters -Each cookie pair must be parsed, allocated, and stored in internal data structures. 1000 pairs can trigger: -- **O(n) or O(n^2) parsing** in naive cookie parsers -- **Memory exhaustion** from 1000 individual allocations -- **Hash table collisions** in cookie lookup structures - -A well-behaved server should either parse all 1000 pairs or reject the oversized header. +A large number of cookie pairs can cause O(n^2) parsing behavior, hashtable flooding, or memory exhaustion in frameworks that eagerly parse all cookies. ## Verdicts -- **Pass** — `2xx` (survived) or `400`/`431` (rejected gracefully) -- **Fail** — `500` or connection crash +- **Pass** — 2xx or 400/431 +- **Fail** — 500 (crash) ## Sources -- [RFC 6265 §6.1](https://www.rfc-editor.org/rfc/rfc6265#section-6.1) — cookie limits -- [RFC 6585 §5](https://www.rfc-editor.org/rfc/rfc6585#section-5) — 431 status code +- [RFC 6265 §5.4](https://www.rfc-editor.org/rfc/rfc6265#section-5.4) — Cookie header diff --git a/docs/content/docs/cookies/multi-header.md b/docs/content/docs/cookies/multi-header.md index a7586f3..db9f1e1 100644 --- a/docs/content/docs/cookies/multi-header.md +++ b/docs/content/docs/cookies/multi-header.md @@ -1,6 +1,6 @@ --- title: "MULTI-HEADER" -description: "COOK-MULTI-HEADER test documentation" +description: "COOK-MULTI-HEADER cookie test documentation" weight: 8 --- @@ -9,10 +9,13 @@ weight: 8 | **Test ID** | `COOK-MULTI-HEADER` | | **Category** | Cookies | | **Scored** | No | -| **Expected** | `2xx` with both cookies | +| **RFC Level** | N/A | +| **Expected** | `2xx with both cookies` | ## What it sends +Two separate Cookie headers — should be folded per RFC 6265 §5.4. + ```http GET /echo HTTP/1.1\r\n Host: localhost:8080\r\n @@ -21,27 +24,16 @@ Cookie: b=2\r\n \r\n ``` -Two separate `Cookie` header lines in the same request. - -## What the RFC says - -> "If the user agent does attach a Cookie header field to an HTTP request, the user agent MUST NOT attach more than one header field named Cookie." — RFC 6265 §5.4 - -> "If a server receives multiple Cookie header field lines in a single request... the server SHOULD treat them as if they had been sent as a single cookie-string separated by semicolons." — RFC 6265 §5.3 (revised in RFC 6265bis) - -While clients MUST NOT send multiple Cookie headers, servers should handle them gracefully by folding them together. - ## Why it matters -Multiple `Cookie` headers can occur in practice through proxy manipulation or misconfigured middleware. A server that crashes or drops cookies when it sees duplicates is fragile. The ideal behavior is to fold them into a single cookie-string as RFC 6265bis recommends. +RFC 6265 §5.4 says the user agent SHOULD combine multiple cookie values with `; `, but servers must handle receiving them separately since some clients and proxies split them. ## Verdicts -- **Pass** — `2xx` with both `a=1` and `b=2` in the echo body -- **Warn** — Only one cookie echoed, or `400` (rejected but didn't crash) -- **Fail** — `500` or connection crash +- **Pass** — 2xx with both a=1 and b=2 in body +- **Warn** — Only one cookie echoed, or 400 +- **Fail** — 500 (crash) ## Sources -- [RFC 6265 §5.4](https://www.rfc-editor.org/rfc/rfc6265#section-5.4) — sending cookies -- [RFC 6265 §5.3](https://www.rfc-editor.org/rfc/rfc6265#section-5.3) — cookie processing +- [RFC 6265 §5.4](https://www.rfc-editor.org/rfc/rfc6265#section-5.4) — Cookie header diff --git a/docs/content/docs/cookies/nul.md b/docs/content/docs/cookies/nul.md index ab94ba5..f592a95 100644 --- a/docs/content/docs/cookies/nul.md +++ b/docs/content/docs/cookies/nul.md @@ -1,6 +1,6 @@ --- title: "NUL" -description: "COOK-NUL test documentation" +description: "COOK-NUL cookie test documentation" weight: 4 --- @@ -9,38 +9,31 @@ weight: 4 | **Test ID** | `COOK-NUL` | | **Category** | Cookies | | **Scored** | No | -| **Expected** | `400` (rejected) or `2xx` without NUL | +| **RFC Level** | N/A | +| **Expected** | `400 (rejected) or 2xx without NUL` | ## What it sends +NUL byte in cookie value — dangerous if preserved by parser. + ```http GET /echo HTTP/1.1\r\n Host: localhost:8080\r\n -Cookie: foo=\x00bar\r\n +Cookie: foo=\0bar\r\n \r\n ``` -A `Cookie` header containing a NUL byte (`0x00`) embedded in the cookie value. - -## What the RFC says - -> "Field values containing CR, LF, or NUL characters are invalid and dangerous... a recipient of CR, LF, or NUL within a field value MUST either reject the message or replace each of those characters with SP before further processing." — RFC 9110 §5.5 - -NUL bytes in cookie values are not valid in any HTTP header field. +The cookie value contains a NUL byte (`0x00`). ## Why it matters -NUL bytes in cookie values are a serious security concern. If a cookie parser preserves the NUL byte, it can: -- **Truncate strings** in C-based parsers, causing the cookie value to appear shorter than it is -- **Bypass security filters** that stop reading at NUL -- **Corrupt downstream processing** in systems that interpret NUL as a string terminator +NUL bytes in cookie values can truncate strings in C-based parsers, cause log injection, or enable header injection if the NUL terminates a string boundary check. ## Verdicts -- **Pass** — `400` (rejected) or `2xx` with NUL stripped/cookie dropped -- **Fail** — `2xx` with NUL byte preserved in the response body (dangerous) +- **Pass** — 400 rejected, or 2xx with NUL stripped +- **Fail** — 2xx with NUL byte preserved in output (dangerous), or 500 ## Sources -- [RFC 9110 §5.5](https://www.rfc-editor.org/rfc/rfc9110#section-5.5) — field values with NUL -- [RFC 6265 §4.1.1](https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1) — cookie-value syntax +- [RFC 6265 §5.4](https://www.rfc-editor.org/rfc/rfc6265#section-5.4) — Cookie header diff --git a/docs/content/docs/cookies/oversized.md b/docs/content/docs/cookies/oversized.md index 66e0256..b3f3bdb 100644 --- a/docs/content/docs/cookies/oversized.md +++ b/docs/content/docs/cookies/oversized.md @@ -1,6 +1,6 @@ --- title: "OVERSIZED" -description: "COOK-OVERSIZED test documentation" +description: "COOK-OVERSIZED cookie test documentation" weight: 2 --- @@ -9,10 +9,13 @@ weight: 2 | **Test ID** | `COOK-OVERSIZED` | | **Category** | Cookies | | **Scored** | No | -| **Expected** | `400`/`431` (rejected) or `2xx` (survived) | +| **RFC Level** | N/A | +| **Expected** | `400/431 (rejected) or 2xx (survived)` | ## What it sends +64KB Cookie header — tests header size limits on cookie data. + ```http GET /echo HTTP/1.1\r\n Host: localhost:8080\r\n @@ -20,26 +23,17 @@ Cookie: big=AAAA...AAAA\r\n \r\n ``` -A `Cookie` header with a 64KB value (65,536 `A` characters). - -## What the RFC says - -> "Practical cookie limits: At least 4096 bytes per cookie (as measured by the sum of the length of the cookie's name, value, and attributes)." — RFC 6265 §6.1 - -64KB vastly exceeds the recommended 4096-byte minimum. Servers are free to reject oversized cookies. - -> "The 431 status code indicates that the server is unwilling to process the request because its header fields are too large." — RFC 6585 §5 +The cookie value contains 65,536 bytes of `A`. ## Why it matters -Oversized cookie headers can exhaust server memory or trigger buffer overflows in cookie parsers. A well-behaved server should either reject the request (400/431) or accept it without crashing. +Oversized cookies can trigger buffer overflows, OOM crashes, or excessive memory allocation in parsers that don't enforce size limits. ## Verdicts -- **Pass** — `400`/`431` (rejected) or `2xx` (survived without crash) -- **Fail** — `500` or connection crash +- **Pass** — 400/431 rejected, or 2xx survived, or connection close +- **Fail** — 500 (crash) ## Sources -- [RFC 6265 §6.1](https://www.rfc-editor.org/rfc/rfc6265#section-6.1) — cookie limits -- [RFC 6585 §5](https://www.rfc-editor.org/rfc/rfc6585#section-5) — 431 status code +- [RFC 6265 §5.4](https://www.rfc-editor.org/rfc/rfc6265#section-5.4) — Cookie header diff --git a/docs/content/docs/cookies/parsed-basic.md b/docs/content/docs/cookies/parsed-basic.md index ff29d4c..2b91206 100644 --- a/docs/content/docs/cookies/parsed-basic.md +++ b/docs/content/docs/cookies/parsed-basic.md @@ -1,6 +1,6 @@ --- title: "PARSED-BASIC" -description: "COOK-PARSED-BASIC test documentation" +description: "COOK-PARSED-BASIC cookie test documentation" weight: 9 --- @@ -9,10 +9,13 @@ weight: 9 | **Test ID** | `COOK-PARSED-BASIC` | | **Category** | Cookies | | **Scored** | No | -| **Expected** | `2xx` with `foo=bar` in body | +| **RFC Level** | N/A | +| **Expected** | `2xx with foo=bar in body` | ## What it sends +Basic cookie parsed correctly by framework. + ```http GET /cookie HTTP/1.1\r\n Host: localhost:8080\r\n @@ -20,24 +23,16 @@ Cookie: foo=bar\r\n \r\n ``` -A simple request with a single cookie, targeting the `/cookie` endpoint which returns parsed cookie key=value pairs. - -## What the RFC says - -> "cookie-pair = cookie-name '=' cookie-value" — RFC 6265 §4.1.1 - -`foo=bar` is a perfectly valid cookie-pair. The framework parser should extract `foo` with value `bar`. - ## Why it matters -This is the baseline for parsed-cookie tests. It confirms that the framework's cookie parser can extract a simple cookie and return it. If this fails, the framework has a fundamental cookie parsing issue. +Tests that the framework's cookie parser correctly extracts a simple name=value pair — the most basic cookie parsing operation. ## Verdicts -- **Pass** — `2xx` with `foo=bar` in the response body -- **Warn** — `404` (endpoint not available on this server) -- **Fail** — `2xx` without `foo=bar`, or `500` +- **Pass** — 2xx and body contains `foo=bar` +- **Warn** — 404 (endpoint not available) +- **Fail** — 500 or mangled output ## Sources -- [RFC 6265 §4.1.1](https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1) — cookie-pair syntax +- [RFC 6265 §5.4](https://www.rfc-editor.org/rfc/rfc6265#section-5.4) — Cookie header diff --git a/docs/content/docs/cookies/parsed-empty-val.md b/docs/content/docs/cookies/parsed-empty-val.md index 05be783..b85bf61 100644 --- a/docs/content/docs/cookies/parsed-empty-val.md +++ b/docs/content/docs/cookies/parsed-empty-val.md @@ -1,6 +1,6 @@ --- title: "PARSED-EMPTY-VAL" -description: "COOK-PARSED-EMPTY-VAL test documentation" +description: "COOK-PARSED-EMPTY-VAL cookie test documentation" weight: 11 --- @@ -9,10 +9,13 @@ weight: 11 | **Test ID** | `COOK-PARSED-EMPTY-VAL` | | **Category** | Cookies | | **Scored** | No | -| **Expected** | `2xx` (no crash) | +| **RFC Level** | N/A | +| **Expected** | `2xx (no crash)` | ## What it sends +Cookie with empty value parsed without crash. + ```http GET /cookie HTTP/1.1\r\n Host: localhost:8080\r\n @@ -20,24 +23,16 @@ Cookie: foo=\r\n \r\n ``` -A cookie with an empty value — the key `foo` is present but its value is an empty string. - -## What the RFC says - -> "cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )" — RFC 6265 §4.1.1 - -The `*` (zero or more) operator means an empty cookie-value is syntactically valid. The parser should accept `foo=` and store `foo` with an empty string value. - ## Why it matters -Empty cookie values are common in practice — they often represent cleared or expired cookies. A parser that crashes on empty values has a serious resilience issue. +Cookies with empty values (`foo=`) are valid per RFC 6265 but can crash parsers that assume a non-empty value after the `=` sign. ## Verdicts -- **Pass** — `2xx` (with or without `foo=` in the body — survival is the key) -- **Warn** — `404` (endpoint not available) -- **Fail** — `500` or connection crash +- **Pass** — 2xx or 400 +- **Warn** — 404 (endpoint not available) +- **Fail** — 500 (crash) ## Sources -- [RFC 6265 §4.1.1](https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1) — cookie-value syntax +- [RFC 6265 §5.4](https://www.rfc-editor.org/rfc/rfc6265#section-5.4) — Cookie header diff --git a/docs/content/docs/cookies/parsed-multi.md b/docs/content/docs/cookies/parsed-multi.md index fd48eb1..ec953bd 100644 --- a/docs/content/docs/cookies/parsed-multi.md +++ b/docs/content/docs/cookies/parsed-multi.md @@ -1,6 +1,6 @@ --- title: "PARSED-MULTI" -description: "COOK-PARSED-MULTI test documentation" +description: "COOK-PARSED-MULTI cookie test documentation" weight: 10 --- @@ -9,10 +9,13 @@ weight: 10 | **Test ID** | `COOK-PARSED-MULTI` | | **Category** | Cookies | | **Scored** | No | -| **Expected** | `2xx` with `a=1`, `b=2`, `c=3` in body | +| **RFC Level** | N/A | +| **Expected** | `2xx with a=1, b=2, c=3 in body` | ## What it sends +Multiple cookies parsed correctly by framework. + ```http GET /cookie HTTP/1.1\r\n Host: localhost:8080\r\n @@ -20,24 +23,16 @@ Cookie: a=1; b=2; c=3\r\n \r\n ``` -A request with three cookies separated by `; ` (semicolon-space) in a single `Cookie` header. - -## What the RFC says - -> "cookie-string = cookie-pair *( ';' SP cookie-pair )" — RFC 6265 §4.2.1 - -Multiple cookies in a single header are delimited by `; ` (semicolon followed by a space). The parser must split on this delimiter and extract all pairs. - ## Why it matters -Most real-world requests contain multiple cookies (session IDs, preferences, tracking tokens). If the framework parser fails to split on `; ` correctly, it will lose cookies — potentially dropping session tokens or authentication data. +Tests the framework's ability to correctly split and parse multiple semicolon-delimited cookie pairs. ## Verdicts -- **Pass** — `2xx` with all three cookies (`a=1`, `b=2`, `c=3`) in the response body -- **Warn** — `404` (endpoint not available) -- **Fail** — `2xx` with missing cookies, or `500` +- **Pass** — 2xx and body contains all three pairs +- **Warn** — 404 (endpoint not available) +- **Fail** — Missing pairs or 500 ## Sources -- [RFC 6265 §4.2.1](https://www.rfc-editor.org/rfc/rfc6265#section-4.2.1) — cookie-string syntax +- [RFC 6265 §5.4](https://www.rfc-editor.org/rfc/rfc6265#section-5.4) — Cookie header diff --git a/docs/content/docs/cookies/parsed-special.md b/docs/content/docs/cookies/parsed-special.md index 0d1af32..ab73992 100644 --- a/docs/content/docs/cookies/parsed-special.md +++ b/docs/content/docs/cookies/parsed-special.md @@ -1,6 +1,6 @@ --- title: "PARSED-SPECIAL" -description: "COOK-PARSED-SPECIAL test documentation" +description: "COOK-PARSED-SPECIAL cookie test documentation" weight: 12 --- @@ -9,10 +9,13 @@ weight: 12 | **Test ID** | `COOK-PARSED-SPECIAL` | | **Category** | Cookies | | **Scored** | No | -| **Expected** | `2xx` (no crash) | +| **RFC Level** | N/A | +| **Expected** | `2xx (no crash)` | ## What it sends +Cookies with spaces and = in values — tests framework parser edge cases. + ```http GET /cookie HTTP/1.1\r\n Host: localhost:8080\r\n @@ -20,28 +23,16 @@ Cookie: a=hello world; b=x=y\r\n \r\n ``` -Two cookies with edge-case values: -- `a=hello world` — contains a space (technically invalid per RFC 6265 cookie-octet, but common in practice) -- `b=x=y` — contains an `=` sign in the value - -## What the RFC says - -> "cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E" — RFC 6265 §4.1.1 - -A space (`0x20`) is not in the `cookie-octet` set, making `hello world` technically invalid. However, many frameworks accept spaces in cookie values for compatibility. The `=` sign (`0x3D`) is in the `cookie-octet` range (`%x3C-5B`), so `x=y` is valid. - ## Why it matters -Real-world cookies often contain characters that are technically outside the RFC 6265 grammar. Frameworks must decide whether to be strict (reject) or lenient (accept). Either approach is acceptable — crashing is not. - -The `=` in `b=x=y` is a common parser edge case: the parser must split on the *first* `=` only, yielding key `b` and value `x=y`. +Spaces in values and `=` signs within values are common in real-world cookies (e.g., Base64-encoded tokens) and can confuse parsers that split on `=` or whitespace too aggressively. ## Verdicts -- **Pass** — `2xx` (survived, with or without correct parsing) -- **Warn** — `404` (endpoint not available) -- **Fail** — `500` or connection crash +- **Pass** — 2xx or 400 +- **Warn** — 404 (endpoint not available) +- **Fail** — 500 (crash) ## Sources -- [RFC 6265 §4.1.1](https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1) — cookie-octet syntax +- [RFC 6265 §5.4](https://www.rfc-editor.org/rfc/rfc6265#section-5.4) — Cookie header diff --git a/docs/static/probe/render.js b/docs/static/probe/render.js index 3c95b3a..b6b0f66 100644 --- a/docs/static/probe/render.js +++ b/docs/static/probe/render.js @@ -476,7 +476,19 @@ window.ProbeRender = (function () { 'CAP-IMS-FUTURE': '/Http11Probe/docs/caching/ims-future/', 'CAP-IMS-INVALID': '/Http11Probe/docs/caching/ims-invalid/', 'CAP-INM-UNQUOTED': '/Http11Probe/docs/caching/inm-unquoted/', - 'CAP-ETAG-WEAK': '/Http11Probe/docs/caching/etag-weak/' + 'CAP-ETAG-WEAK': '/Http11Probe/docs/caching/etag-weak/', + 'COOK-ECHO': '/Http11Probe/docs/cookies/echo/', + 'COOK-OVERSIZED': '/Http11Probe/docs/cookies/oversized/', + 'COOK-EMPTY': '/Http11Probe/docs/cookies/empty/', + 'COOK-NUL': '/Http11Probe/docs/cookies/nul/', + 'COOK-CONTROL-CHARS': '/Http11Probe/docs/cookies/control-chars/', + 'COOK-MANY-PAIRS': '/Http11Probe/docs/cookies/many-pairs/', + 'COOK-MALFORMED': '/Http11Probe/docs/cookies/malformed/', + 'COOK-MULTI-HEADER': '/Http11Probe/docs/cookies/multi-header/', + 'COOK-PARSED-BASIC': '/Http11Probe/docs/cookies/parsed-basic/', + 'COOK-PARSED-MULTI': '/Http11Probe/docs/cookies/parsed-multi/', + 'COOK-PARSED-EMPTY-VAL': '/Http11Probe/docs/cookies/parsed-empty-val/', + 'COOK-PARSED-SPECIAL': '/Http11Probe/docs/cookies/parsed-special/' }; function testUrl(tid) { diff --git a/src/Servers/GenHttpServer/GenHttpServer.csproj b/src/Servers/GenHttpServer/GenHttpServer.csproj index 57d2c3c..940edc3 100644 --- a/src/Servers/GenHttpServer/GenHttpServer.csproj +++ b/src/Servers/GenHttpServer/GenHttpServer.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/src/Servers/GenHttpServer/Program.cs b/src/Servers/GenHttpServer/Program.cs index 60cc88c..da1737d 100644 --- a/src/Servers/GenHttpServer/Program.cs +++ b/src/Servers/GenHttpServer/Program.cs @@ -36,16 +36,12 @@ static string Echo(IRequest request) static string ParseCookies(IRequest request) { var sb = new System.Text.StringBuilder(); - if (request.Headers.TryGetValue("Cookie", out var cookieHeader)) + + foreach (var cookie in request.Cookies.Values) { - foreach (var pair in cookieHeader.Split(';')) - { - var trimmed = pair.TrimStart(); - var eqIdx = trimmed.IndexOf('='); - if (eqIdx > 0) - sb.AppendLine($"{trimmed[..eqIdx]}={trimmed[(eqIdx + 1)..]}"); - } + sb.AppendLine($"{cookie.Name}={cookie.Value}"); } + return sb.ToString(); }