From 2b67c945fb87794d96578b34f881b006c5a1f579 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 15 Feb 2026 14:56:15 +0000 Subject: [PATCH] Sequence tests feature --- .gitignore | 3 + .../content/docs/smuggling/clte-conn-close.md | 63 +++ docs/content/docs/smuggling/clte-desync.md | 70 +++ docs/content/docs/smuggling/clte-keepalive.md | 64 +++ docs/content/docs/smuggling/pipeline-safe.md | 55 +++ .../content/docs/smuggling/tecl-conn-close.md | 62 +++ docs/content/docs/smuggling/tecl-desync.md | 66 +++ docs/content/smuggling/_index.md | 6 +- docs/hugo.yaml | 3 +- docs/static/probe/render.js | 6 + src/Http11Probe.Cli/Program.cs | 11 +- src/Http11Probe.Cli/Reporting/DocsUrlMap.cs | 4 + src/Http11Probe/Runner/TestRunner.cs | 138 +++++- src/Http11Probe/TestCases/ITestCase.cs | 12 + src/Http11Probe/TestCases/SequenceStep.cs | 7 + src/Http11Probe/TestCases/SequenceTestCase.cs | 15 + src/Http11Probe/TestCases/StepResult.cs | 13 + .../TestCases/Suites/SmugglingSuite.cs | 437 ++++++++++++++++++ src/Http11Probe/TestCases/TestCase.cs | 2 +- src/Http11Probe/TestCases/TestResult.cs | 2 +- 20 files changed, 1028 insertions(+), 11 deletions(-) create mode 100644 docs/content/docs/smuggling/clte-conn-close.md create mode 100644 docs/content/docs/smuggling/clte-desync.md create mode 100644 docs/content/docs/smuggling/clte-keepalive.md create mode 100644 docs/content/docs/smuggling/pipeline-safe.md create mode 100644 docs/content/docs/smuggling/tecl-conn-close.md create mode 100644 docs/content/docs/smuggling/tecl-desync.md create mode 100644 src/Http11Probe/TestCases/ITestCase.cs create mode 100644 src/Http11Probe/TestCases/SequenceStep.cs create mode 100644 src/Http11Probe/TestCases/SequenceTestCase.cs create mode 100644 src/Http11Probe/TestCases/StepResult.cs diff --git a/.gitignore b/.gitignore index 835aaeb..2dac362 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ ## ## Get latest from https://github.com/github/gitignore/blob/main/Dotnet.gitignore +# Claude Code +.claude/ + # Rider / VS .idea/ *.DotSettings.user diff --git a/docs/content/docs/smuggling/clte-conn-close.md b/docs/content/docs/smuggling/clte-conn-close.md new file mode 100644 index 0000000..308178d --- /dev/null +++ b/docs/content/docs/smuggling/clte-conn-close.md @@ -0,0 +1,63 @@ +--- +title: "CLTE-CONN-CLOSE" +description: "CLTE-CONN-CLOSE sequence test documentation" +weight: 10 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-CLTE-CONN-CLOSE` | +| **Category** | Smuggling | +| **Type** | Sequence (2 steps) | +| **Scored** | Yes | +| **RFC** | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | +| **RFC Level** | MUST | +| **Expected** | `400`, or `2xx` + connection close | + +## What it does + +This is a **sequence test** — it sends multiple requests on the same TCP connection to verify server behavior across the full exchange. + +### Step 1: Ambiguous POST (CL+TE) + +```http +POST / HTTP/1.1\r\n +Host: localhost:8080\r\n +Content-Length: 5\r\n +Transfer-Encoding: chunked\r\n +\r\n +0\r\n +\r\n +``` + +A POST with both `Content-Length: 5` and `Transfer-Encoding: chunked`. The chunked body is the `0` terminator (5 bytes), which happens to match the CL value. + +### Step 2: Follow-up GET + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +\r\n +``` + +A normal GET sent on the same connection. This step only executes if the connection is still open after step 1. + +## What the RFC says + +> "A server MAY reject a request that contains both Content-Length and Transfer-Encoding or process such a request in accordance with the Transfer-Encoding alone. **Regardless, the server MUST close the connection after responding to such a request** to avoid the potential attacks." — RFC 9112 §6.1 + +The key word is "regardless" — even if the server correctly processes the request via TE, it **must** close the connection afterward. + +## Why it matters + +The MUST-close requirement exists because keeping the connection open after a dual CL+TE request creates a window for request smuggling. If the connection stays alive, any leftover bytes (or a pipelined request) could be misinterpreted. This sequence test verifies the close actually happens. + +## Verdicts + +- **Pass** — Server returns `400` (rejected outright), OR returns `2xx` and closes the connection (step 2 never executes) +- **Fail** — Server returns `2xx` and keeps the connection open (step 2 executes and gets a response) + +## Sources + +- [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) +- [RFC 9112 §6.3](https://www.rfc-editor.org/rfc/rfc9112#section-6.3) diff --git a/docs/content/docs/smuggling/clte-desync.md b/docs/content/docs/smuggling/clte-desync.md new file mode 100644 index 0000000..2b1bf73 --- /dev/null +++ b/docs/content/docs/smuggling/clte-desync.md @@ -0,0 +1,70 @@ +--- +title: "CLTE-DESYNC" +description: "CLTE-DESYNC sequence test documentation" +weight: 13 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-CLTE-DESYNC` | +| **Category** | Smuggling | +| **Type** | Sequence (2 steps) | +| **Scored** | Yes | +| **RFC** | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | +| **RFC Level** | MUST | +| **Expected** | `400`, or connection close | + +## What it does + +This is a **sequence test** that detects actual CL.TE request boundary desynchronization — the classic request smuggling attack. + +### Step 1: Poison POST (CL=6, TE=chunked, extra byte) + +```http +POST / HTTP/1.1\r\n +Host: localhost:8080\r\n +Content-Length: 6\r\n +Transfer-Encoding: chunked\r\n +\r\n +0\r\n +\r\n +X +``` + +The chunked body terminates at `0\r\n\r\n` (5 bytes), but `Content-Length` claims 6 bytes. The extra `X` byte sits right after the chunked terminator. + +- If the server uses **TE**: reads the chunked terminator (5 bytes), body done. `X` is leftover on the wire. +- If the server uses **CL**: reads 6 bytes (`0\r\n\r\nX`), body done. + +Either way, `X` may poison the connection. + +### Step 2: Follow-up GET + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +\r\n +``` + +Sent immediately after step 1. If `X` is still on the wire, the server sees `XGET / HTTP/1.1` — a malformed request line that triggers a 400. + +## What the RFC says + +> "A server MAY reject a request that contains both Content-Length and Transfer-Encoding... **Regardless, the server MUST close the connection after responding to such a request.**" — RFC 9112 §6.1 + +The only safe outcomes are rejection (400) or closing the connection. Any other behavior risks desynchronization. + +## Why it matters + +This test detects **actual request smuggling**, not just RFC non-compliance. If the poison byte `X` merges with the follow-up GET, the server's request boundary parsing is broken. In a real proxy chain, an attacker could replace `X` with a complete smuggled request. + +## Verdicts + +- **Pass** — Server returns `400` (rejected outright), OR closes the connection (step 2 never executes) +- **Fail** — Step 2 executes and returns `400` (desync confirmed — poison byte merged with GET) +- **Fail** — Step 2 executes and returns `2xx` (MUST-close violated, connection stayed open) + +## Sources + +- [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) +- [RFC 9112 §11.2](https://www.rfc-editor.org/rfc/rfc9112#section-11.2) diff --git a/docs/content/docs/smuggling/clte-keepalive.md b/docs/content/docs/smuggling/clte-keepalive.md new file mode 100644 index 0000000..301d18f --- /dev/null +++ b/docs/content/docs/smuggling/clte-keepalive.md @@ -0,0 +1,64 @@ +--- +title: "CLTE-KEEPALIVE" +description: "CLTE-KEEPALIVE sequence test documentation" +weight: 12 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-CLTE-KEEPALIVE` | +| **Category** | Smuggling | +| **Type** | Sequence (2 steps) | +| **Scored** | Yes | +| **RFC** | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | +| **RFC Level** | MUST | +| **Expected** | `400`, or `2xx` + connection close | + +## What it does + +This is a **sequence test** that verifies the MUST-close requirement still applies even when the client explicitly requests a persistent connection. + +### Step 1: Ambiguous POST with keep-alive + +```http +POST / HTTP/1.1\r\n +Host: localhost:8080\r\n +Connection: keep-alive\r\n +Content-Length: 5\r\n +Transfer-Encoding: chunked\r\n +\r\n +0\r\n +\r\n +``` + +A POST with both `Content-Length: 5` and `Transfer-Encoding: chunked`, plus an explicit `Connection: keep-alive` header pressuring the server to maintain the connection. + +### Step 2: Follow-up GET + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +\r\n +``` + +A normal GET sent on the same connection. This step only executes if the connection is still open after step 1. + +## What the RFC says + +> "**Regardless, the server MUST close the connection after responding to such a request** to avoid the potential attacks." — RFC 9112 §6.1 + +The word "regardless" means the MUST-close requirement overrides any `Connection: keep-alive` request from the client. The server has no choice — it must close. + +## Why it matters + +This is the most tempting edge case for servers to get wrong. A server that correctly detects the CL+TE conflict might still honor the client's `keep-alive` request instead of closing. This test specifically targets that logic path. + +## Verdicts + +- **Pass** — Server returns `400` (rejected outright), OR returns `2xx` and closes the connection despite `keep-alive` (step 2 never executes) +- **Fail** — Server returns `2xx` and honors `keep-alive`, keeping the connection open (step 2 executes and gets a response) + +## Sources + +- [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) +- [RFC 9112 §9.3](https://www.rfc-editor.org/rfc/rfc9112#section-9.3) diff --git a/docs/content/docs/smuggling/pipeline-safe.md b/docs/content/docs/smuggling/pipeline-safe.md new file mode 100644 index 0000000..04fc8c6 --- /dev/null +++ b/docs/content/docs/smuggling/pipeline-safe.md @@ -0,0 +1,55 @@ +--- +title: "PIPELINE-SAFE" +description: "PIPELINE-SAFE baseline sequence test documentation" +weight: 15 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-PIPELINE-SAFE` | +| **Category** | Smuggling | +| **Type** | Sequence (2 steps) | +| **Scored** | No | +| **RFC** | [RFC 9112 §9.3](https://www.rfc-editor.org/rfc/rfc9112#section-9.3) | +| **RFC Level** | SHOULD | +| **Expected** | `2xx` + `2xx` | + +## What it does + +This is a **baseline sequence test** — it sends two clean, unambiguous requests on the same keep-alive connection to verify the server supports normal HTTP/1.1 pipelining. + +### Step 1: First GET + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +\r\n +``` + +### Step 2: Second GET + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +\r\n +``` + +Both requests are identical, clean, and unambiguous. No smuggling payload. + +## What the RFC says + +> "A client that supports persistent connections MAY 'pipeline' its requests (i.e., send multiple requests without waiting for each response). A server MAY process a sequence of pipelined requests in parallel if they all have safe methods." — RFC 9112 §9.3 + +## Why it matters + +This test serves as a **control** for the other sequence tests. If a server can't handle two clean GETs on one connection, the results of desync and MUST-close tests are unreliable — failures could be caused by missing pipelining support rather than smuggling vulnerabilities. + +## Verdicts + +- **Pass** — Both steps return `2xx` (pipelining works correctly) +- **Warn** — Step 1 returns `2xx` but server closes connection before step 2 (no pipelining support) +- **Fail** — Step 1 does not return `2xx` + +## Sources + +- [RFC 9112 §9.3](https://www.rfc-editor.org/rfc/rfc9112#section-9.3) diff --git a/docs/content/docs/smuggling/tecl-conn-close.md b/docs/content/docs/smuggling/tecl-conn-close.md new file mode 100644 index 0000000..7c31656 --- /dev/null +++ b/docs/content/docs/smuggling/tecl-conn-close.md @@ -0,0 +1,62 @@ +--- +title: "TECL-CONN-CLOSE" +description: "TECL-CONN-CLOSE sequence test documentation" +weight: 11 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-TECL-CONN-CLOSE` | +| **Category** | Smuggling | +| **Type** | Sequence (2 steps) | +| **Scored** | Yes | +| **RFC** | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | +| **RFC Level** | MUST | +| **Expected** | `400`, or `2xx` + connection close | + +## What it does + +This is a **sequence test** — it sends multiple requests on the same TCP connection to verify server behavior across the full exchange. It is a mirror of [CLTE-CONN-CLOSE](/Http11Probe/docs/smuggling/clte-conn-close/) with the header order reversed. + +### Step 1: Ambiguous POST (TE+CL) + +```http +POST / HTTP/1.1\r\n +Host: localhost:8080\r\n +Transfer-Encoding: chunked\r\n +Content-Length: 5\r\n +\r\n +0\r\n +\r\n +``` + +A POST with `Transfer-Encoding: chunked` listed **before** `Content-Length: 5`. Some parsers treat headers differently depending on order. The chunked body is the `0` terminator (5 bytes), matching the CL value. + +### Step 2: Follow-up GET + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +\r\n +``` + +A normal GET sent on the same connection. This step only executes if the connection is still open after step 1. + +## What the RFC says + +> "A server MAY reject a request that contains both Content-Length and Transfer-Encoding or process such a request in accordance with the Transfer-Encoding alone. **Regardless, the server MUST close the connection after responding to such a request** to avoid the potential attacks." — RFC 9112 §6.1 + +The MUST-close requirement applies regardless of header order. This test verifies servers don't accidentally rely on header ordering when deciding whether to close. + +## Why it matters + +Some servers process headers in order and may handle `TE, CL` differently from `CL, TE`. If a server only triggers its MUST-close logic when `Content-Length` appears first, the reversed order could bypass the protection, leaving the connection open for smuggling. + +## Verdicts + +- **Pass** — Server returns `400` (rejected outright), OR returns `2xx` and closes the connection (step 2 never executes) +- **Fail** — Server returns `2xx` and keeps the connection open (step 2 executes and gets a response) + +## Sources + +- [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) diff --git a/docs/content/docs/smuggling/tecl-desync.md b/docs/content/docs/smuggling/tecl-desync.md new file mode 100644 index 0000000..dd05bbf --- /dev/null +++ b/docs/content/docs/smuggling/tecl-desync.md @@ -0,0 +1,66 @@ +--- +title: "TECL-DESYNC" +description: "TECL-DESYNC sequence test documentation" +weight: 14 +--- + +| | | +|---|---| +| **Test ID** | `SMUG-TECL-DESYNC` | +| **Category** | Smuggling | +| **Type** | Sequence (2 steps) | +| **Scored** | Yes | +| **RFC** | [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) | +| **RFC Level** | MUST | +| **Expected** | `400`, or connection close | + +## What it does + +This is a **sequence test** that detects TE.CL request boundary desynchronization — the reverse of the classic CL.TE smuggling attack. + +### Step 1: Poison POST (TE terminates early, CL=30) + +```http +POST / HTTP/1.1\r\n +Host: localhost:8080\r\n +Transfer-Encoding: chunked\r\n +Content-Length: 30\r\n +\r\n +0\r\n +\r\n +X +``` + +The `Transfer-Encoding: chunked` body terminates at `0\r\n\r\n` (5 bytes), but `Content-Length` claims 30 bytes. The extra `X` sits after the chunked terminator. + +- If the server uses **TE**: reads the chunked terminator (5 bytes), body done. Still expects 25 more bytes per CL — `X` and any subsequent data become part of the expected body or a new request. +- If the server uses **CL**: waits for 30 bytes total, which never arrive (timeout). + +### Step 2: Follow-up GET + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +\r\n +``` + +Sent immediately after step 1. If the server used TE and left `X` on the wire, it sees `XGET / HTTP/1.1` — a malformed request that triggers a 400. + +## What the RFC says + +> "**Regardless, the server MUST close the connection after responding to such a request** to avoid the potential attacks." — RFC 9112 §6.1 + +## Why it matters + +In a proxy chain where the front-end uses CL and the back-end uses TE, this pattern allows an attacker to smuggle a request by placing it after the chunked terminator but within the CL-declared body. This test verifies the server doesn't leave the connection in an ambiguous state. + +## Verdicts + +- **Pass** — Server returns `400` (rejected outright), OR closes the connection (step 2 never executes) +- **Fail** — Step 2 executes and returns `400` (desync confirmed — poison byte merged with GET) +- **Fail** — Step 2 executes and returns `2xx` (MUST-close violated, connection stayed open) + +## Sources + +- [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1) +- [RFC 9112 §11.2](https://www.rfc-editor.org/rfc/rfc9112#section-11.2) diff --git a/docs/content/smuggling/_index.md b/docs/content/smuggling/_index.md index ac5e64d..9c17499 100644 --- a/docs/content/smuggling/_index.md +++ b/docs/content/smuggling/_index.md @@ -38,7 +38,8 @@ Some tests are **unscored** (marked with `*`). These send payloads where the RFC } var GROUPS = [ { key: 'framing', label: 'Framing Conflicts', testIds: [ - 'SMUG-CL-TE-BOTH','SMUG-CLTE-PIPELINE','SMUG-TECL-PIPELINE','SMUG-TE-HTTP10', + 'SMUG-CL-TE-BOTH','SMUG-CLTE-CONN-CLOSE','SMUG-TECL-CONN-CLOSE','SMUG-CLTE-KEEPALIVE', + 'SMUG-CLTE-PIPELINE','SMUG-TECL-PIPELINE','SMUG-TE-HTTP10', 'SMUG-DUPLICATE-CL','SMUG-CL-LEADING-ZEROS','SMUG-CL-NEGATIVE', 'SMUG-CL-COMMA-DIFFERENT','SMUG-CL-OCTAL','SMUG-CL-HEX-PREFIX', 'SMUG-CL-INTERNAL-SPACE','SMUG-CL-COMMA-SAME', @@ -60,6 +61,9 @@ Some tests are **unscored** (marked with `*`). These send payloads where the RFC 'SMUG-CHUNK-EXT-CTRL','SMUG-CHUNK-EXT-CR','SMUG-CHUNK-LF-TRAILER', 'SMUG-CHUNK-NEGATIVE','SMUG-CHUNK-BARE-CR-TERM' ]}, + { key: 'desync', label: 'Desync Detection', testIds: [ + 'SMUG-CLTE-DESYNC','SMUG-TECL-DESYNC','SMUG-PIPELINE-SAFE' + ]}, { key: 'headers-trailers', label: 'Headers, Trailers & Methods', testIds: [ 'SMUG-BARE-CR-HEADER-VALUE', 'SMUG-TRAILER-CL','SMUG-TRAILER-TE','SMUG-TRAILER-HOST', diff --git a/docs/hugo.yaml b/docs/hugo.yaml index 44c5503..4cd9d67 100644 --- a/docs/hugo.yaml +++ b/docs/hugo.yaml @@ -38,8 +38,9 @@ menu: weight: 4 - name: Sequence Tests weight: 3 - - name: Coming Soon + - name: Smuggling parent: Sequence Tests + pageRef: /smuggling weight: 1 - name: Glossary pageRef: /docs diff --git a/docs/static/probe/render.js b/docs/static/probe/render.js index fcac80f..98ab136 100644 --- a/docs/static/probe/render.js +++ b/docs/static/probe/render.js @@ -349,6 +349,9 @@ window.ProbeRender = (function () { 'SMUG-CL-INTERNAL-SPACE': '/Http11Probe/docs/smuggling/cl-internal-space/', 'SMUG-CL-OCTAL': '/Http11Probe/docs/smuggling/cl-octal/', 'SMUG-CL-TRAILING-SPACE': '/Http11Probe/docs/smuggling/cl-trailing-space/', + 'SMUG-CLTE-CONN-CLOSE': '/Http11Probe/docs/smuggling/clte-conn-close/', + 'SMUG-CLTE-DESYNC': '/Http11Probe/docs/smuggling/clte-desync/', + 'SMUG-CLTE-KEEPALIVE': '/Http11Probe/docs/smuggling/clte-keepalive/', 'SMUG-CLTE-PIPELINE': '/Http11Probe/docs/smuggling/clte-pipeline/', 'SMUG-EXPECT-100-CL': '/Http11Probe/docs/smuggling/expect-100-cl/', 'SMUG-HEAD-CL-BODY': '/Http11Probe/docs/smuggling/head-cl-body/', @@ -367,6 +370,9 @@ window.ProbeRender = (function () { 'SMUG-TE-VTAB': '/Http11Probe/docs/smuggling/te-vtab/', 'SMUG-TE-IDENTITY': '/Http11Probe/docs/smuggling/te-identity/', 'SMUG-TE-XCHUNKED': '/Http11Probe/docs/smuggling/te-xchunked/', + 'SMUG-PIPELINE-SAFE': '/Http11Probe/docs/smuggling/pipeline-safe/', + 'SMUG-TECL-CONN-CLOSE': '/Http11Probe/docs/smuggling/tecl-conn-close/', + 'SMUG-TECL-DESYNC': '/Http11Probe/docs/smuggling/tecl-desync/', 'SMUG-TECL-PIPELINE': '/Http11Probe/docs/smuggling/tecl-pipeline/', 'SMUG-TRAILER-AUTH': '/Http11Probe/docs/smuggling/trailer-auth/', 'SMUG-TRAILER-CL': '/Http11Probe/docs/smuggling/trailer-cl/', diff --git a/src/Http11Probe.Cli/Program.cs b/src/Http11Probe.Cli/Program.cs index f5d63fb..61ffcd5 100644 --- a/src/Http11Probe.Cli/Program.cs +++ b/src/Http11Probe.Cli/Program.cs @@ -58,11 +58,12 @@ : null }; - var testCases = ComplianceSuite.GetTestCases() - .Concat(SmugglingSuite.GetTestCases()) - .Concat(MalformedInputSuite.GetTestCases()) - .Concat(NormalizationSuite.GetTestCases()) - .ToList(); + var testCases = new List(); + testCases.AddRange(ComplianceSuite.GetTestCases()); + testCases.AddRange(SmugglingSuite.GetTestCases()); + testCases.AddRange(SmugglingSuite.GetSequenceTestCases()); + testCases.AddRange(MalformedInputSuite.GetTestCases()); + testCases.AddRange(NormalizationSuite.GetTestCases()); var runner = new TestRunner(options); diff --git a/src/Http11Probe.Cli/Reporting/DocsUrlMap.cs b/src/Http11Probe.Cli/Reporting/DocsUrlMap.cs index 33d74d4..bd4bca3 100644 --- a/src/Http11Probe.Cli/Reporting/DocsUrlMap.cs +++ b/src/Http11Probe.Cli/Reporting/DocsUrlMap.cs @@ -78,7 +78,11 @@ internal static class DocsUrlMap ["COMP-VERSION-MISSING-MINOR"] = "request-line/version-missing-minor", ["COMP-VERSION-WHITESPACE"] = "request-line/version-whitespace", + // range + ["COMP-RANGE-POST"] = "body/range-post", + // upgrade + ["COMP-UPGRADE-HTTP10"] = "upgrade/upgrade-http10", ["COMP-UPGRADE-INVALID-VER"] = "upgrade/upgrade-invalid-ver", ["COMP-UPGRADE-MISSING-CONN"] = "upgrade/upgrade-missing-conn", ["COMP-UPGRADE-POST"] = "upgrade/upgrade-post", diff --git a/src/Http11Probe/Runner/TestRunner.cs b/src/Http11Probe/Runner/TestRunner.cs index 3ced322..99eb0a5 100644 --- a/src/Http11Probe/Runner/TestRunner.cs +++ b/src/Http11Probe/Runner/TestRunner.cs @@ -15,7 +15,7 @@ public TestRunner(TestRunOptions options) _options = options; } - public async Task RunAsync(IEnumerable testCases, Action? onResult = null) + public async Task RunAsync(IEnumerable testCases, Action? onResult = null) { var results = new List(); var totalSw = Stopwatch.StartNew(); @@ -42,7 +42,12 @@ public async Task RunAsync(IEnumerable testCases, Actio continue; } - var result = await RunSingleAsync(testCase, context); + var result = testCase switch + { + SequenceTestCase seq => await RunSequenceAsync(seq, context), + TestCase single => await RunSingleAsync(single, context), + _ => throw new InvalidOperationException($"Unknown test case type: {testCase.GetType().Name}") + }; results.Add(result); onResult?.Invoke(result); } @@ -124,4 +129,133 @@ private async Task RunSingleAsync(TestCase testCase, TestContext con }; } } + + private async Task RunSequenceAsync(SequenceTestCase seq, TestContext context) + { + var sw = Stopwatch.StartNew(); + + try + { + await using var client = new RawTcpClient(_options.ConnectTimeout, _options.ReadTimeout); + var connectState = await client.ConnectAsync(_options.Host, _options.Port); + + if (connectState != ConnectionState.Open) + { + return new TestResult + { + TestCase = seq, + Verdict = TestVerdict.Error, + ConnectionState = connectState, + ErrorMessage = $"Failed to connect: {connectState}", + Duration = sw.Elapsed + }; + } + + var stepResults = new List(); + var rawRequestParts = new List(); + HttpResponse? lastResponse = null; + var connectionState = ConnectionState.Open; + var drainCaughtData = false; + + for (var i = 0; i < seq.Steps.Count; i++) + { + var step = seq.Steps[i]; + var label = step.Label ?? $"Step {i + 1}"; + + if (connectionState != ConnectionState.Open) + { + stepResults.Add(new StepResult + { + Label = label, + Executed = false, + ConnectionState = connectionState + }); + rawRequestParts.Add($"── {label} ──\n[Not executed — connection closed]"); + continue; + } + + var payload = step.PayloadFactory(context); + var rawReq = payload.Length > 8192 + ? Encoding.ASCII.GetString(payload, 0, 8192) + "\n\n[Truncated]" + : Encoding.ASCII.GetString(payload); + rawRequestParts.Add($"── {label} ──\n{rawReq}"); + + await client.SendAsync(payload); + + var (data, length, readState, drain) = await client.ReadResponseAsync(); + var response = ResponseParser.TryParse(data.AsSpan(), length); + if (response is not null) lastResponse = response; + connectionState = readState; + if (drain) drainCaughtData = true; + + if (connectionState == ConnectionState.Open) + { + await Task.Delay(50); + connectionState = client.CheckConnectionState(); + } + + stepResults.Add(new StepResult + { + Label = label, + Executed = true, + Response = response, + ConnectionState = connectionState, + RawRequest = rawReq + }); + } + + var verdict = seq.Validator(stepResults); + var behavioralNote = seq.BehavioralAnalyzer?.Invoke(stepResults); + + // Build combined raw response for display + var rawResponseParts = new List(); + foreach (var sr in stepResults) + { + if (!sr.Executed) + rawResponseParts.Add($"── {sr.Label} ──\n[Not executed — connection closed]"); + else if (sr.Response is not null) + rawResponseParts.Add($"── {sr.Label} ──\n{sr.Response.RawResponse}"); + else + rawResponseParts.Add($"── {sr.Label} ──\n[No response]"); + } + + // Synthetic response with combined raw output for the UI + HttpResponse? resultResponse = null; + if (lastResponse is not null) + { + resultResponse = new HttpResponse + { + StatusCode = lastResponse.StatusCode, + ReasonPhrase = lastResponse.ReasonPhrase, + HttpVersion = lastResponse.HttpVersion, + Headers = lastResponse.Headers, + Body = lastResponse.Body, + RawResponse = string.Join("\n\n", rawResponseParts) + }; + } + + return new TestResult + { + TestCase = seq, + Verdict = verdict, + Response = resultResponse, + ConnectionState = connectionState, + BehavioralNote = behavioralNote, + RawRequest = string.Join("\n\n", rawRequestParts), + DrainCaughtData = drainCaughtData, + Duration = sw.Elapsed + }; + } + catch (Exception ex) + { + return new TestResult + { + TestCase = seq, + Verdict = TestVerdict.Error, + ConnectionState = ConnectionState.Error, + ErrorMessage = ex.Message, + Duration = sw.Elapsed + }; + } + } } diff --git a/src/Http11Probe/TestCases/ITestCase.cs b/src/Http11Probe/TestCases/ITestCase.cs new file mode 100644 index 0000000..de52669 --- /dev/null +++ b/src/Http11Probe/TestCases/ITestCase.cs @@ -0,0 +1,12 @@ +namespace Http11Probe.TestCases; + +public interface ITestCase +{ + string Id { get; } + string Description { get; } + TestCategory Category { get; } + string? RfcReference { get; } + bool Scored { get; } + RfcLevel RfcLevel { get; } + ExpectedBehavior Expected { get; } +} diff --git a/src/Http11Probe/TestCases/SequenceStep.cs b/src/Http11Probe/TestCases/SequenceStep.cs new file mode 100644 index 0000000..6ea2306 --- /dev/null +++ b/src/Http11Probe/TestCases/SequenceStep.cs @@ -0,0 +1,7 @@ +namespace Http11Probe.TestCases; + +public sealed class SequenceStep +{ + public required Func PayloadFactory { get; init; } + public string? Label { get; init; } +} diff --git a/src/Http11Probe/TestCases/SequenceTestCase.cs b/src/Http11Probe/TestCases/SequenceTestCase.cs new file mode 100644 index 0000000..e655d30 --- /dev/null +++ b/src/Http11Probe/TestCases/SequenceTestCase.cs @@ -0,0 +1,15 @@ +namespace Http11Probe.TestCases; + +public sealed class SequenceTestCase : ITestCase +{ + public required string Id { get; init; } + public required string Description { get; init; } + public required TestCategory Category { get; init; } + public string? RfcReference { get; init; } + public bool Scored { get; init; } = true; + public RfcLevel RfcLevel { get; init; } = RfcLevel.Must; + public required ExpectedBehavior Expected { get; init; } + public required IReadOnlyList Steps { get; init; } + public required Func, TestVerdict> Validator { get; init; } + public Func, string?>? BehavioralAnalyzer { get; init; } +} diff --git a/src/Http11Probe/TestCases/StepResult.cs b/src/Http11Probe/TestCases/StepResult.cs new file mode 100644 index 0000000..f2f5b12 --- /dev/null +++ b/src/Http11Probe/TestCases/StepResult.cs @@ -0,0 +1,13 @@ +using Http11Probe.Client; +using Http11Probe.Response; + +namespace Http11Probe.TestCases; + +public sealed class StepResult +{ + public required string Label { get; init; } + public bool Executed { get; init; } + public HttpResponse? Response { get; init; } + public ConnectionState ConnectionState { get; init; } + public string? RawRequest { get; init; } +} diff --git a/src/Http11Probe/TestCases/Suites/SmugglingSuite.cs b/src/Http11Probe/TestCases/Suites/SmugglingSuite.cs index 250f5eb..0c931e4 100644 --- a/src/Http11Probe/TestCases/Suites/SmugglingSuite.cs +++ b/src/Http11Probe/TestCases/Suites/SmugglingSuite.cs @@ -1417,5 +1417,442 @@ public static IEnumerable GetTestCases() }; } + // ── Sequence tests ───────────────────────────────────────────── + + public static IEnumerable GetSequenceTestCases() + { + yield return new SequenceTestCase + { + Id = "SMUG-CLTE-CONN-CLOSE", + Description = "CL+TE conflict — server MUST close connection after responding", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9112 §6.1", + Expected = new ExpectedBehavior + { + Description = "400, or 2xx + close" + }, + Steps = + [ + new SequenceStep + { + Label = "Ambiguous POST (CL+TE)", + PayloadFactory = ctx => MakeRequest( + $"POST / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n") + }, + new SequenceStep + { + Label = "Follow-up GET", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n") + } + ], + Validator = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + // Server rejected the ambiguous request outright + if (step1.Response?.StatusCode == 400) + return TestVerdict.Pass; + + // Connection closed before or after step 1 response + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return TestVerdict.Pass; + + // Server returned 2xx — did it close the connection? + if (step1.Response?.StatusCode is >= 200 and < 300) + { + // Connection closed after step 1 → step 2 didn't execute → correct + if (!step2.Executed) + return TestVerdict.Pass; + + // Step 2 executed → connection was kept open → MUST-close violated + return TestVerdict.Fail; + } + + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (step1.Response?.StatusCode == 400) + return "Rejected ambiguous CL+TE request with 400"; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return "Connection closed — safe"; + if (step1.Response?.StatusCode is >= 200 and < 300) + { + if (!step2.Executed) + return $"Accepted with {step1.Response.StatusCode}, then closed connection (RFC-compliant)"; + return $"Accepted with {step1.Response.StatusCode} but kept connection open — follow-up GET returned {step2.Response?.StatusCode.ToString() ?? "no response"}"; + } + return $"Unexpected response: {step1.Response?.StatusCode.ToString() ?? "none"}"; + } + }; + + // ── TECL-CONN-CLOSE ───────────────────────────────────────── + // Mirror of CLTE-CONN-CLOSE with TE listed before CL. + // Some parsers behave differently depending on header order. + yield return new SequenceTestCase + { + Id = "SMUG-TECL-CONN-CLOSE", + Description = "TE+CL conflict (reversed order) — server MUST close connection after responding", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9112 §6.1", + Expected = new ExpectedBehavior + { + Description = "400, or 2xx + close" + }, + Steps = + [ + new SequenceStep + { + Label = "Ambiguous POST (TE+CL)", + PayloadFactory = ctx => MakeRequest( + $"POST / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nTransfer-Encoding: chunked\r\nContent-Length: 5\r\n\r\n0\r\n\r\n") + }, + new SequenceStep + { + Label = "Follow-up GET", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n") + } + ], + Validator = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (step1.Response?.StatusCode == 400) + return TestVerdict.Pass; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return TestVerdict.Pass; + if (step1.Response?.StatusCode is >= 200 and < 300) + return !step2.Executed ? TestVerdict.Pass : TestVerdict.Fail; + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (step1.Response?.StatusCode == 400) + return "Rejected ambiguous TE+CL request with 400"; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return "Connection closed — safe"; + if (step1.Response?.StatusCode is >= 200 and < 300) + { + if (!step2.Executed) + return $"Accepted with {step1.Response.StatusCode}, then closed connection (RFC-compliant)"; + return $"Accepted with {step1.Response.StatusCode} but kept connection open — follow-up GET returned {step2.Response?.StatusCode.ToString() ?? "no response"}"; + } + return $"Unexpected response: {step1.Response?.StatusCode.ToString() ?? "none"}"; + } + }; + + // ── CLTE-KEEPALIVE ────────────────────────────────────────── + // CL+TE conflict with explicit Connection: keep-alive. + // MUST-close overrides the keep-alive request. + yield return new SequenceTestCase + { + Id = "SMUG-CLTE-KEEPALIVE", + Description = "CL+TE conflict with Connection: keep-alive — MUST-close still applies", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9112 §6.1", + Expected = new ExpectedBehavior + { + Description = "400, or 2xx + close" + }, + Steps = + [ + new SequenceStep + { + Label = "Ambiguous POST (CL+TE+keep-alive)", + PayloadFactory = ctx => MakeRequest( + $"POST / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nConnection: keep-alive\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n") + }, + new SequenceStep + { + Label = "Follow-up GET", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n") + } + ], + Validator = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (step1.Response?.StatusCode == 400) + return TestVerdict.Pass; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return TestVerdict.Pass; + if (step1.Response?.StatusCode is >= 200 and < 300) + return !step2.Executed ? TestVerdict.Pass : TestVerdict.Fail; + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (step1.Response?.StatusCode == 400) + return "Rejected ambiguous request with 400"; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return "Connection closed — safe (MUST-close honored despite keep-alive)"; + if (step1.Response?.StatusCode is >= 200 and < 300) + { + if (!step2.Executed) + return $"Accepted with {step1.Response.StatusCode}, then closed connection (MUST-close honored despite keep-alive)"; + return $"Accepted with {step1.Response.StatusCode} and honored keep-alive — MUST-close violated"; + } + return $"Unexpected response: {step1.Response?.StatusCode.ToString() ?? "none"}"; + } + }; + + // ── CLTE-DESYNC ───────────────────────────────────────────── + // Classic CL.TE desync: CL declares a small body (6 bytes), + // but the chunked stream includes extra data after CL's boundary. + // If the server uses CL, the leftover bytes stay on the wire + // and get interpreted as the start of the next request. + // + // Wire bytes (step 1): + // POST / HTTP/1.1\r\n + // Host: ...\r\n + // Content-Length: 6\r\n ← CL says 6 bytes of body + // Transfer-Encoding: chunked\r\n + // \r\n + // 0\r\n\r\nX ← TE sees terminator at byte 5; CL reads 6 bytes (includes 'X') + // + // If server uses TE: reads "0\r\n\r\n" (5 bytes), body done, 'X' is leftover. + // If server uses CL: reads 6 bytes "0\r\n\r\nX", body done. + // Either way, 'X' may poison the connection. Step 2 (GET) follows. + // If 'X' merged with step 2's GET, the server sees "XGET / HTTP/1.1" → 400. + // A safe server rejects step 1 with 400 or closes the connection. + yield return new SequenceTestCase + { + Id = "SMUG-CLTE-DESYNC", + Description = "CL.TE desync — leftover bytes after CL boundary may become a smuggled request", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9112 §6.1", + Expected = new ExpectedBehavior + { + Description = "400, or close" + }, + Steps = + [ + new SequenceStep + { + Label = "Poison POST (CL=6, TE=chunked, extra byte)", + PayloadFactory = ctx => MakeRequest( + $"POST / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nContent-Length: 6\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\nX") + }, + new SequenceStep + { + Label = "Follow-up GET", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n") + } + ], + Validator = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + // Rejected the ambiguous request outright — safe + if (step1.Response?.StatusCode == 400) + return TestVerdict.Pass; + + // Connection closed — safe + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return TestVerdict.Pass; + + if (step1.Response?.StatusCode is >= 200 and < 300) + { + // Server accepted but closed connection — safe + if (!step2.Executed) + return TestVerdict.Pass; + + // Step 2 executed. If server got a clean 2xx, it consumed our + // GET correctly despite the poison byte — still a MUST-close violation + // but not a desync. If step 2 got 400, the poison byte merged + // with the GET ("XGET /...") — desync detected. + if (step2.Response?.StatusCode == 400) + return TestVerdict.Fail; // Desync confirmed + + // Connection stayed open, step 2 got 2xx — MUST-close violated + return TestVerdict.Fail; + } + + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (step1.Response?.StatusCode == 400) + return "Rejected ambiguous CL+TE request with 400"; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return "Connection closed — safe"; + if (step1.Response?.StatusCode is >= 200 and < 300) + { + if (!step2.Executed) + return $"Accepted with {step1.Response.StatusCode}, then closed connection — no desync"; + if (step2.Response?.StatusCode == 400) + return $"DESYNC: Server accepted step 1 ({step1.Response.StatusCode}), but poison byte merged with follow-up GET → 400"; + return $"Accepted with {step1.Response.StatusCode}, kept connection open — follow-up GET returned {step2.Response?.StatusCode.ToString() ?? "no response"}"; + } + return $"Unexpected response: {step1.Response?.StatusCode.ToString() ?? "none"}"; + } + }; + + // ── TECL-DESYNC ───────────────────────────────────────────── + // Reverse desync: TE terminates early (0\r\n\r\n), but CL claims a + // larger body. If the server uses TE, it stops at the terminator + // and the remaining CL bytes (which include a smuggled prefix) + // stay on the wire. + // + // Wire bytes (step 1): + // POST / HTTP/1.1\r\n + // Host: ...\r\n + // Transfer-Encoding: chunked\r\n + // Content-Length: 30\r\n ← CL says 30 bytes of body + // \r\n + // 0\r\n\r\nX ← TE ends at byte 5; CL expects 25 more + // + // A safe server rejects step 1 with 400 or closes the connection. + yield return new SequenceTestCase + { + Id = "SMUG-TECL-DESYNC", + Description = "TE.CL desync — chunked terminator before CL boundary, leftover bytes smuggled", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9112 §6.1", + Expected = new ExpectedBehavior + { + Description = "400, or close" + }, + Steps = + [ + new SequenceStep + { + Label = "Poison POST (TE terminates early, CL=30)", + PayloadFactory = ctx => MakeRequest( + $"POST / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nTransfer-Encoding: chunked\r\nContent-Length: 30\r\n\r\n0\r\n\r\nX") + }, + new SequenceStep + { + Label = "Follow-up GET", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n") + } + ], + Validator = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (step1.Response?.StatusCode == 400) + return TestVerdict.Pass; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return TestVerdict.Pass; + if (step1.Response?.StatusCode is >= 200 and < 300) + { + if (!step2.Executed) + return TestVerdict.Pass; + if (step2.Response?.StatusCode == 400) + return TestVerdict.Fail; // Desync confirmed + return TestVerdict.Fail; // MUST-close violated + } + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (step1.Response?.StatusCode == 400) + return "Rejected ambiguous TE+CL request with 400"; + if (!step1.Executed || step1.ConnectionState == ConnectionState.ClosedByServer) + return "Connection closed — safe"; + if (step1.Response?.StatusCode is >= 200 and < 300) + { + if (!step2.Executed) + return $"Accepted with {step1.Response.StatusCode}, then closed connection — no desync"; + if (step2.Response?.StatusCode == 400) + return $"DESYNC: Server accepted step 1 ({step1.Response.StatusCode}), but poison byte merged with follow-up GET → 400"; + return $"Accepted with {step1.Response.StatusCode}, kept connection open — follow-up GET returned {step2.Response?.StatusCode.ToString() ?? "no response"}"; + } + return $"Unexpected response: {step1.Response?.StatusCode.ToString() ?? "none"}"; + } + }; + + // ── PIPELINE-SAFE ─────────────────────────────────────────── + // Baseline: two clean, unambiguous GET requests on one connection. + // Validates that the server supports normal HTTP/1.1 pipelining. + // If this fails, all other sequence tests are unreliable. + yield return new SequenceTestCase + { + Id = "SMUG-PIPELINE-SAFE", + Description = "Baseline — two clean GET requests on one keep-alive connection", + Category = TestCategory.Smuggling, + RfcReference = "RFC 9112 §9.3", + Scored = false, + RfcLevel = RfcLevel.Should, + Expected = new ExpectedBehavior + { + Description = "2xx + 2xx" + }, + Steps = + [ + new SequenceStep + { + Label = "First GET", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n") + }, + new SequenceStep + { + Label = "Second GET", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n") + } + ], + Validator = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + // Step 1 must succeed + if (step1.Response?.StatusCode is not (>= 200 and < 300)) + return TestVerdict.Fail; + + // Connection closed after step 1 — server doesn't support pipelining + if (!step2.Executed) + return TestVerdict.Warn; + + // Both steps got 2xx — pipelining works + if (step2.Response?.StatusCode is >= 200 and < 300) + return TestVerdict.Pass; + + return TestVerdict.Warn; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (step1.Response?.StatusCode is not (>= 200 and < 300)) + return $"First GET failed with {step1.Response?.StatusCode.ToString() ?? "no response"}"; + if (!step2.Executed) + return $"First GET returned {step1.Response.StatusCode}, but server closed connection — no pipelining support"; + if (step2.Response?.StatusCode is >= 200 and < 300) + return $"Both GETs returned {step1.Response.StatusCode}/{step2.Response.StatusCode} — pipelining works"; + return $"First GET: {step1.Response.StatusCode}, second GET: {step2.Response?.StatusCode.ToString() ?? "no response"}"; + } + }; + } + private static byte[] MakeRequest(string request) => Encoding.ASCII.GetBytes(request); } diff --git a/src/Http11Probe/TestCases/TestCase.cs b/src/Http11Probe/TestCases/TestCase.cs index 4709070..dfd7046 100644 --- a/src/Http11Probe/TestCases/TestCase.cs +++ b/src/Http11Probe/TestCases/TestCase.cs @@ -2,7 +2,7 @@ namespace Http11Probe.TestCases; -public sealed class TestCase +public sealed class TestCase : ITestCase { public required string Id { get; init; } diff --git a/src/Http11Probe/TestCases/TestResult.cs b/src/Http11Probe/TestCases/TestResult.cs index 4ec543e..6ca23d0 100644 --- a/src/Http11Probe/TestCases/TestResult.cs +++ b/src/Http11Probe/TestCases/TestResult.cs @@ -5,7 +5,7 @@ namespace Http11Probe.TestCases; public sealed class TestResult { - public required TestCase TestCase { get; init; } + public required ITestCase TestCase { get; init; } public required TestVerdict Verdict { get; init; }