From 140546730718b7c109a61743f900dc6ded1283d2 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 16 Feb 2026 14:56:20 +0000 Subject: [PATCH] Add caching tests --- docs/content/docs/capabilities/_index.md | 40 ++ docs/content/docs/capabilities/etag-304.md | 62 ++ docs/content/docs/capabilities/etag-in-304.md | 60 ++ docs/content/docs/capabilities/etag-weak.md | 62 ++ docs/content/docs/capabilities/ims-future.md | 59 ++ docs/content/docs/capabilities/ims-invalid.md | 59 ++ .../docs/capabilities/inm-precedence.md | 61 ++ .../content/docs/capabilities/inm-unquoted.md | 63 ++ .../content/docs/capabilities/inm-wildcard.md | 61 ++ .../docs/capabilities/last-modified-304.md | 60 ++ .../content/docs/rfc-requirement-dashboard.md | 25 +- .../sequence-tests/capabilities/_index.md | 66 ++ docs/static/probe/render.js | 16 +- src/Http11Probe.Cli/Program.cs | 1 + src/Http11Probe/Runner/TestRunner.cs | 11 +- src/Http11Probe/TestCases/SequenceStep.cs | 3 + .../TestCases/Suites/CapabilitiesSuite.cs | 631 ++++++++++++++++++ src/Http11Probe/TestCases/TestCategory.cs | 3 +- 18 files changed, 1333 insertions(+), 10 deletions(-) create mode 100644 docs/content/docs/capabilities/_index.md create mode 100644 docs/content/docs/capabilities/etag-304.md create mode 100644 docs/content/docs/capabilities/etag-in-304.md create mode 100644 docs/content/docs/capabilities/etag-weak.md create mode 100644 docs/content/docs/capabilities/ims-future.md create mode 100644 docs/content/docs/capabilities/ims-invalid.md create mode 100644 docs/content/docs/capabilities/inm-precedence.md create mode 100644 docs/content/docs/capabilities/inm-unquoted.md create mode 100644 docs/content/docs/capabilities/inm-wildcard.md create mode 100644 docs/content/docs/capabilities/last-modified-304.md create mode 100644 docs/content/sequence-tests/capabilities/_index.md create mode 100644 src/Http11Probe/TestCases/Suites/CapabilitiesSuite.cs diff --git a/docs/content/docs/capabilities/_index.md b/docs/content/docs/capabilities/_index.md new file mode 100644 index 0000000..e352e41 --- /dev/null +++ b/docs/content/docs/capabilities/_index.md @@ -0,0 +1,40 @@ +--- +title: Capabilities +description: "Capabilities — Http11Probe documentation" +weight: 12 +sidebar: + open: false +--- + +Capability tests probe optional HTTP features that servers may or may not implement. Unlike compliance tests, these are **unscored** — they map what each server supports rather than what it fails at. + +## Scoring + +All capability tests are **unscored**: + +- **Pass** — Server correctly supports the feature +- **Warn** — Server does not support the feature (not a failure) +- **Fail** — Only for actual errors (unexpected status codes, connection errors) + +## Conditional Requests (Caching) + +These tests check whether the server supports ETag and Last-Modified based conditional requests (RFC 9110 §13). + +{{< cards >}} + {{< card link="etag-304" title="ETAG-304" subtitle="ETag conditional GET returns 304 Not Modified." >}} + {{< card link="last-modified-304" title="LAST-MODIFIED-304" subtitle="Last-Modified conditional GET returns 304 Not Modified." >}} + {{< card link="etag-in-304" title="ETAG-IN-304" subtitle="304 response includes ETag header." >}} + {{< card link="inm-precedence" title="INM-PRECEDENCE" subtitle="If-None-Match takes precedence over If-Modified-Since." >}} + {{< card link="inm-wildcard" title="INM-WILDCARD" subtitle="If-None-Match: * on existing resource returns 304." >}} +{{< /cards >}} + +## Conditional Request Edge Cases + +These tests probe how servers handle invalid or unusual conditional headers — future dates, garbage values, unquoted ETags, and weak comparison (RFC 9110 §13). + +{{< cards >}} + {{< card link="ims-future" title="IMS-FUTURE" subtitle="If-Modified-Since with future date ignored." >}} + {{< card link="ims-invalid" title="IMS-INVALID" subtitle="If-Modified-Since with garbage date ignored." >}} + {{< card link="inm-unquoted" title="INM-UNQUOTED" subtitle="If-None-Match with unquoted ETag." >}} + {{< card link="etag-weak" title="ETAG-WEAK" subtitle="Weak ETag comparison for GET." >}} +{{< /cards >}} diff --git a/docs/content/docs/capabilities/etag-304.md b/docs/content/docs/capabilities/etag-304.md new file mode 100644 index 0000000..3608a10 --- /dev/null +++ b/docs/content/docs/capabilities/etag-304.md @@ -0,0 +1,62 @@ +--- +title: "ETAG-304" +description: "CAP-ETAG-304 capability test documentation" +weight: 10 +--- + +| | | +|---|---| +| **Test ID** | `CAP-ETAG-304` | +| **Category** | Capabilities | +| **Type** | Sequence (2 steps) | +| **Scored** | No | +| **RFC** | [RFC 9110 §13.1.2](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.2) | +| **RFC Level** | SHOULD | +| **Expected** | `304` | + +## What it does + +This is a **sequence test** — it sends two requests on the same TCP connection to test ETag-based conditional request handling. + +### Step 1: Initial GET (capture ETag) + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +Connection: keep-alive\r\n +\r\n +``` + +Captures the `ETag` header from the response for use in step 2. + +### Step 2: Conditional GET (If-None-Match) + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +If-None-Match: "abc123"\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`. + +## What the RFC says + +> "An origin server MUST use the strong comparison function when comparing entity-tags for If-None-Match, because the client intends to use the cached representation." — RFC 9110 §13.1.2 + +> "If the field value is '*', the condition is false if the origin server has a current representation for the target resource." — RFC 9110 §13.1.2 + +## Why it matters + +ETag-based conditional requests are the most reliable caching mechanism in HTTP. They enable efficient revalidation without relying on timestamps, which can be unreliable across servers or after deployments. + +## Verdicts + +- **Pass** — Step 2 returns `304 Not Modified` +- **Warn** — Server does not include ETag in step 1, or returns `200` in step 2 (no conditional support) +- **Fail** — Unexpected error (non-2xx/304 response) + +## Sources + +- [RFC 9110 §13.1.2](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.2) +- [RFC 9110 §8.8.3](https://www.rfc-editor.org/rfc/rfc9110#section-8.8.3) diff --git a/docs/content/docs/capabilities/etag-in-304.md b/docs/content/docs/capabilities/etag-in-304.md new file mode 100644 index 0000000..a4bfea1 --- /dev/null +++ b/docs/content/docs/capabilities/etag-in-304.md @@ -0,0 +1,60 @@ +--- +title: "ETAG-IN-304" +description: "CAP-ETAG-IN-304 capability test documentation" +weight: 12 +--- + +| | | +|---|---| +| **Test ID** | `CAP-ETAG-IN-304` | +| **Category** | Capabilities | +| **Type** | Sequence (2 steps) | +| **Scored** | No | +| **RFC** | [RFC 9110 §15.4.5](https://www.rfc-editor.org/rfc/rfc9110#section-15.4.5) | +| **RFC Level** | SHOULD | +| **Expected** | `304` with ETag | + +## What it does + +This is a **sequence test** — it verifies that a `304 Not Modified` response includes the `ETag` header, allowing clients to update their cached validators. + +### Step 1: Initial GET (capture ETag) + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +Connection: keep-alive\r\n +\r\n +``` + +Captures the `ETag` header from the response. + +### Step 2: Conditional GET (If-None-Match) + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +If-None-Match: "abc123"\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. + +## What the RFC says + +> "A server generating a 304 response MUST generate any of the following header fields that would have been sent in a 200 (OK) response to the same request: ... ETag" — RFC 9110 §15.4.5 + +## Why it matters + +Including the ETag in a `304` response lets clients confirm which representation they have cached and update their stored validator. Without it, clients may lose track of the ETag and fall back to unconditional requests. + +## Verdicts + +- **Pass** — Step 2 returns `304` with an ETag header +- **Warn** — Server does not support ETags, or returns `304` without an ETag header +- **Fail** — Unexpected error (non-2xx/304 response) + +## Sources + +- [RFC 9110 §15.4.5](https://www.rfc-editor.org/rfc/rfc9110#section-15.4.5) +- [RFC 9110 §8.8.3](https://www.rfc-editor.org/rfc/rfc9110#section-8.8.3) diff --git a/docs/content/docs/capabilities/etag-weak.md b/docs/content/docs/capabilities/etag-weak.md new file mode 100644 index 0000000..9ce2a66 --- /dev/null +++ b/docs/content/docs/capabilities/etag-weak.md @@ -0,0 +1,62 @@ +--- +title: "ETAG-WEAK" +description: "CAP-ETAG-WEAK capability test documentation" +weight: 18 +--- + +| | | +|---|---| +| **Test ID** | `CAP-ETAG-WEAK` | +| **Category** | Capabilities | +| **Type** | Sequence (2 steps) | +| **Scored** | No | +| **RFC** | [RFC 9110 §13.1.2](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.2) | +| **RFC Level** | SHOULD | +| **Expected** | `304` | + +## What it does + +This is a **sequence test** — it captures the server's ETag and resends it with a `W/` weak prefix in `If-None-Match` to test whether the server uses the weak comparison function for GET requests. + +### Step 1: Initial GET (capture ETag) + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +Connection: keep-alive\r\n +\r\n +``` + +Captures the `ETag` header from the response. If the ETag is strong (e.g., `"abc123"`), step 2 will prepend `W/` to make it weak (`W/"abc123"`). If already weak, it is sent as-is. + +### Step 2: Conditional GET (If-None-Match: W/etag) + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +If-None-Match: W/"abc123"\r\n +\r\n +``` + +The weak ETag should still match via the weak comparison function, which only compares the opaque-tag portion. + +## What the RFC says + +> "A recipient MUST use the weak comparison function when comparing entity-tags for If-None-Match." — RFC 9110 §13.1.2 + +The weak comparison function is defined as: "two entity-tags are equivalent if their opaque-tags match character-by-character, regardless of either or both being tagged as 'weak'." + +## Why it matters + +GET conditional requests must use weak comparison. A server that only does byte-for-byte matching of the full ETag string (including `W/` prefix) will fail to match weak ETags, causing unnecessary full responses for cacheable content. + +## Verdicts + +- **Pass** — Step 2 returns `304` (weak comparison matched) +- **Warn** — No ETag in step 1, or step 2 returns `200` (server didn't use weak comparison) +- **Fail** — Unexpected error (non-2xx/304 response) + +## Sources + +- [RFC 9110 §13.1.2](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.2) +- [RFC 9110 §8.8.3.2](https://www.rfc-editor.org/rfc/rfc9110#section-8.8.3.2) diff --git a/docs/content/docs/capabilities/ims-future.md b/docs/content/docs/capabilities/ims-future.md new file mode 100644 index 0000000..69b4a43 --- /dev/null +++ b/docs/content/docs/capabilities/ims-future.md @@ -0,0 +1,59 @@ +--- +title: "IMS-FUTURE" +description: "CAP-IMS-FUTURE capability test documentation" +weight: 15 +--- + +| | | +|---|---| +| **Test ID** | `CAP-IMS-FUTURE` | +| **Category** | Capabilities | +| **Type** | Sequence (2 steps) | +| **Scored** | No | +| **RFC** | [RFC 9110 §13.1.3](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.3) | +| **RFC Level** | SHOULD | +| **Expected** | `200` | + +## What it does + +This is a **sequence test** — it sends an `If-Modified-Since` header with a date far in the future to check whether the server correctly ignores it. + +### Step 1: Initial GET (confirm 2xx) + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +Connection: keep-alive\r\n +\r\n +``` + +Verifies the resource exists and returns a success response. + +### Step 2: Conditional GET (If-Modified-Since: future date) + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +If-Modified-Since: Thu, 01 Jan 2099 00:00:00 GMT\r\n +\r\n +``` + +Sends a future date. A compliant server must ignore an `If-Modified-Since` value that is later than the server's current time and return the resource normally. + +## What the RFC says + +> "A recipient MUST ignore If-Modified-Since if the field value is not a valid HTTP-date, or if the field value is a date in the future (compared to the server's current time)." — RFC 9110 §13.1.3 + +## Why it matters + +A server that blindly compares dates without checking whether the date is in the future could incorrectly return `304 Not Modified` for every request with a future timestamp, allowing cache-poisoning or stale-content attacks. + +## Verdicts + +- **Pass** — Step 2 returns `200` (correctly ignores future date) +- **Warn** — Server returns `304` (didn't validate the date against current time) +- **Fail** — Unexpected error (non-2xx/304 response) + +## Sources + +- [RFC 9110 §13.1.3](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.3) diff --git a/docs/content/docs/capabilities/ims-invalid.md b/docs/content/docs/capabilities/ims-invalid.md new file mode 100644 index 0000000..9eacc25 --- /dev/null +++ b/docs/content/docs/capabilities/ims-invalid.md @@ -0,0 +1,59 @@ +--- +title: "IMS-INVALID" +description: "CAP-IMS-INVALID capability test documentation" +weight: 16 +--- + +| | | +|---|---| +| **Test ID** | `CAP-IMS-INVALID` | +| **Category** | Capabilities | +| **Type** | Sequence (2 steps) | +| **Scored** | No | +| **RFC** | [RFC 9110 §13.1.3](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.3) | +| **RFC Level** | SHOULD | +| **Expected** | `200` | + +## What it does + +This is a **sequence test** — it sends an `If-Modified-Since` header with an unparseable garbage value to check whether the server correctly ignores it. + +### Step 1: Initial GET (confirm 2xx) + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +Connection: keep-alive\r\n +\r\n +``` + +Verifies the resource exists and returns a success response. + +### Step 2: Conditional GET (If-Modified-Since: garbage) + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +If-Modified-Since: not-a-date\r\n +\r\n +``` + +Sends a value that is not a valid HTTP-date. A compliant server must ignore the header and return the resource normally. + +## What the RFC says + +> "A recipient MUST ignore If-Modified-Since if the field value is not a valid HTTP-date." — RFC 9110 §13.1.3 + +## Why it matters + +If a server treats an unparseable date as "very old" and returns `304`, it could cause clients to serve stale cached content. Correct behavior is to ignore the invalid header entirely. + +## Verdicts + +- **Pass** — Step 2 returns `200` (correctly ignores invalid date) +- **Warn** — Server returns `304` (treated garbage as a valid date) +- **Fail** — Unexpected error (non-2xx/304 response) + +## Sources + +- [RFC 9110 §13.1.3](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.3) diff --git a/docs/content/docs/capabilities/inm-precedence.md b/docs/content/docs/capabilities/inm-precedence.md new file mode 100644 index 0000000..f2fe8ea --- /dev/null +++ b/docs/content/docs/capabilities/inm-precedence.md @@ -0,0 +1,61 @@ +--- +title: "INM-PRECEDENCE" +description: "CAP-INM-PRECEDENCE capability test documentation" +weight: 13 +--- + +| | | +|---|---| +| **Test ID** | `CAP-INM-PRECEDENCE` | +| **Category** | Capabilities | +| **Type** | Sequence (2 steps) | +| **Scored** | No | +| **RFC** | [RFC 9110 §13.1.2](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.2) | +| **RFC Level** | SHOULD | +| **Expected** | `304` | + +## What it does + +This is a **sequence test** — it sends a conditional GET with both `If-None-Match` (matching ETag) and `If-Modified-Since` (epoch timestamp, guaranteed stale) to verify that the server correctly gives precedence to ETag matching. + +### Step 1: Initial GET (capture ETag) + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +Connection: keep-alive\r\n +\r\n +``` + +Captures the `ETag` header from the response. + +### Step 2: Conditional GET (INM + stale IMS) + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +If-None-Match: "abc123"\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. + +## What the RFC says + +> "A recipient MUST ignore If-Modified-Since if the request contains an If-None-Match header field; the condition in If-None-Match is considered to be a more accurate replacement for the condition in If-Modified-Since." — RFC 9110 §13.1.3 + +## Why it matters + +This is a **MUST**-level requirement in RFC 9110 §13.1.3 for servers that support both mechanisms. If a server evaluates `If-Modified-Since` instead of (or in addition to) `If-None-Match`, clients may get unexpected `200` responses and re-download unchanged resources. + +## Verdicts + +- **Pass** — Step 2 returns `304` (If-None-Match took precedence) +- **Warn** — Server does not support ETags, or returns `200` (If-Modified-Since took precedence) +- **Fail** — Unexpected error (non-2xx/304 response) + +## Sources + +- [RFC 9110 §13.1.2](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.2) +- [RFC 9110 §13.1.3](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.3) diff --git a/docs/content/docs/capabilities/inm-unquoted.md b/docs/content/docs/capabilities/inm-unquoted.md new file mode 100644 index 0000000..2326500 --- /dev/null +++ b/docs/content/docs/capabilities/inm-unquoted.md @@ -0,0 +1,63 @@ +--- +title: "INM-UNQUOTED" +description: "CAP-INM-UNQUOTED capability test documentation" +weight: 17 +--- + +| | | +|---|---| +| **Test ID** | `CAP-INM-UNQUOTED` | +| **Category** | Capabilities | +| **Type** | Sequence (2 steps) | +| **Scored** | No | +| **RFC** | [RFC 9110 §8.8.3](https://www.rfc-editor.org/rfc/rfc9110#section-8.8.3) | +| **RFC Level** | SHOULD | +| **Expected** | `200` | + +## What it does + +This is a **sequence test** — it captures the server's ETag, strips the surrounding quotes, and sends it back unquoted in `If-None-Match` to test whether the server enforces ETag syntax. + +### Step 1: Initial GET (capture ETag) + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +Connection: keep-alive\r\n +\r\n +``` + +Captures the `ETag` header from the response for use in step 2. + +### Step 2: Conditional GET (If-None-Match: unquoted) + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +If-None-Match: abc123\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. + +## What the RFC says + +> `entity-tag = [ weak ] opaque-tag` +> `opaque-tag = DQUOTE *etagc DQUOTE` — RFC 9110 §8.8.3 + +An unquoted value violates the entity-tag syntax. A strict server should not match it. + +## Why it matters + +Accepting unquoted ETags means the server is doing lenient parsing of conditional headers. While not a security vulnerability, it indicates relaxed syntax validation that could mask other parsing issues. + +## Verdicts + +- **Pass** — Step 2 returns `200` (correctly rejects malformed ETag syntax) +- **Warn** — No ETag in step 1 (no ETag support), or step 2 returns `304` (accepted unquoted ETag) +- **Fail** — Unexpected error (non-2xx/304 response) + +## Sources + +- [RFC 9110 §8.8.3](https://www.rfc-editor.org/rfc/rfc9110#section-8.8.3) +- [RFC 9110 §13.1.2](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.2) diff --git a/docs/content/docs/capabilities/inm-wildcard.md b/docs/content/docs/capabilities/inm-wildcard.md new file mode 100644 index 0000000..a83dcc1 --- /dev/null +++ b/docs/content/docs/capabilities/inm-wildcard.md @@ -0,0 +1,61 @@ +--- +title: "INM-WILDCARD" +description: "CAP-INM-WILDCARD capability test documentation" +weight: 14 +--- + +| | | +|---|---| +| **Test ID** | `CAP-INM-WILDCARD` | +| **Category** | Capabilities | +| **Type** | Sequence (2 steps) | +| **Scored** | No | +| **RFC** | [RFC 9110 §13.1.2](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.2) | +| **RFC Level** | SHOULD | +| **Expected** | `304` | + +## What it does + +This is a **sequence test** — it uses the wildcard `*` value in `If-None-Match` to test whether the server recognizes that any current representation matches. + +### Step 1: Initial GET (confirm 2xx) + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +Connection: keep-alive\r\n +\r\n +``` + +Verifies the resource exists and returns a success response. + +### Step 2: Conditional GET (If-None-Match: *) + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +If-None-Match: *\r\n +\r\n +``` + +The wildcard `*` means "match any entity-tag". Since step 1 confirmed the resource exists, the server should return `304 Not Modified`. + +## What the RFC says + +> "If the field value is '*', the condition is false if the origin server has a current representation for the target resource." — RFC 9110 §13.1.2 + +In other words, `If-None-Match: *` means "give me the resource only if it doesn't exist". Since it does exist, the condition is false, and the server should return `304`. + +## Why it matters + +The wildcard `If-None-Match` is primarily used for preventing the "lost update" problem in PUT requests (only create if absent). For GET, it's a useful test of whether the server has a standards-compliant conditional request implementation beyond simple ETag string matching. + +## Verdicts + +- **Pass** — Step 2 returns `304 Not Modified` +- **Warn** — Server returns `200` (ignores `If-None-Match: *`) +- **Fail** — Unexpected error (non-2xx/304 response) + +## Sources + +- [RFC 9110 §13.1.2](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.2) diff --git a/docs/content/docs/capabilities/last-modified-304.md b/docs/content/docs/capabilities/last-modified-304.md new file mode 100644 index 0000000..d8d327f --- /dev/null +++ b/docs/content/docs/capabilities/last-modified-304.md @@ -0,0 +1,60 @@ +--- +title: "LAST-MODIFIED-304" +description: "CAP-LAST-MODIFIED-304 capability test documentation" +weight: 11 +--- + +| | | +|---|---| +| **Test ID** | `CAP-LAST-MODIFIED-304` | +| **Category** | Capabilities | +| **Type** | Sequence (2 steps) | +| **Scored** | No | +| **RFC** | [RFC 9110 §13.1.3](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.3) | +| **RFC Level** | SHOULD | +| **Expected** | `304` | + +## What it does + +This is a **sequence test** — it sends two requests on the same TCP connection to test Last-Modified-based conditional request handling. + +### Step 1: Initial GET (capture Last-Modified) + +```http +GET / HTTP/1.1\r\n +Host: localhost:8080\r\n +Connection: keep-alive\r\n +\r\n +``` + +Captures the `Last-Modified` header from the response for use in step 2. + +### Step 2: Conditional GET (If-Modified-Since) + +```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 +\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`. + +## What the RFC says + +> "A recipient MUST ignore If-Modified-Since if the request contains an If-None-Match header field... The condition in If-Modified-Since is only evaluated if the request is for a safe method." — RFC 9110 §13.1.3 + +## Why it matters + +Last-Modified is the oldest conditional request mechanism in HTTP and remains widely deployed. Unlike ETags, it relies on timestamps, which makes it less precise but simpler to implement for static file servers. + +## Verdicts + +- **Pass** — Step 2 returns `304 Not Modified` +- **Warn** — Server does not include Last-Modified in step 1, or returns `200` in step 2 (no conditional support) +- **Fail** — Unexpected error (non-2xx/304 response) + +## Sources + +- [RFC 9110 §13.1.3](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.3) +- [RFC 9110 §8.8.2](https://www.rfc-editor.org/rfc/rfc9110#section-8.8.2) diff --git a/docs/content/docs/rfc-requirement-dashboard.md b/docs/content/docs/rfc-requirement-dashboard.md index 47ee73e..77385ec 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 194 Http11Probe tests" +description: "Complete RFC 2119 requirement-level analysis for all 203 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** | 30 | Informational — no pass/fail judgement | +| **Unscored** | 39 | Informational — no pass/fail judgement | | **N/A** | 11 | Best-practice / no single RFC verb applies | -**Total: 194 tests** +**Total: 203 tests** --- @@ -222,7 +222,7 @@ Weaker than SHOULD — recommends but does not normatively require. --- -## Unscored Tests (30 tests) +## Unscored Tests (39 tests) These tests are informational — they produce warnings but never fail. @@ -258,6 +258,15 @@ These tests are informational — they produce warnings but never fail. | 28 | `SMUG-OPTIONS-CL-BODY-DESYNC` | Smuggling | [RFC 9110 §9.3.7](https://www.rfc-editor.org/rfc/rfc9110#section-9.3.7) | OPTIONS with body plus follow-up GET to detect unread-body poisoning on persistent connections. | | 29 | `SMUG-EXPECT-100-CL-DESYNC` | Smuggling | [RFC 9110 §10.1.1](https://www.rfc-editor.org/rfc/rfc9110#section-10.1.1) | Expect/continue flow with immediate body plus follow-up GET; highlights whether connection framing remains synchronized. | | 30 | `SMUG-GET-CL-PREFIX-DESYNC` | Smuggling | [RFC 9110 §9.3.1](https://www.rfc-editor.org/rfc/rfc9110#section-9.3.1) | GET with a body containing an incomplete request prefix (missing the blank line). The follow-up write completes it and then sends a normal GET. If multiple responses are observed on step 2, the prefix bytes were likely left unread and executed. | +| 31 | `CAP-ETAG-304` | Capabilities | [RFC 9110 §13.1.2](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.2) | ETag conditional GET — server should return 304 when If-None-Match matches. Caching support is optional. | +| 32 | `CAP-LAST-MODIFIED-304` | Capabilities | [RFC 9110 §13.1.3](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.3) | Last-Modified conditional GET — server should return 304 when If-Modified-Since matches. Caching support is optional. | +| 33 | `CAP-ETAG-IN-304` | Capabilities | [RFC 9110 §15.4.5](https://www.rfc-editor.org/rfc/rfc9110#section-15.4.5) | Checks whether 304 responses include the ETag header, allowing clients to update cached validators. | +| 34 | `CAP-INM-PRECEDENCE` | Capabilities | [RFC 9110 §13.1.2](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.2) | If-None-Match must take precedence over If-Modified-Since when both are present. | +| 35 | `CAP-INM-WILDCARD` | Capabilities | [RFC 9110 §13.1.2](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.2) | If-None-Match: * on an existing resource should return 304 (wildcard matches any representation). | +| 36 | `CAP-IMS-FUTURE` | Capabilities | [RFC 9110 §13.1.3](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.3) | If-Modified-Since with a future date must be ignored — server should return 200, not 304. | +| 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. | --- @@ -321,6 +330,12 @@ These tests don't map to a single RFC 2119 keyword but enforce defensive best pr | Unscored | 1 | | N/A | 2 | +### Capabilities Suite (9 tests) + +| Level | Tests | +|-------|-------| +| Unscored | 9 | + --- ## RFC Section Cross-Reference @@ -351,8 +366,10 @@ These tests don't map to a single RFC 2119 keyword but enforce defensive best pr | RFC 9110 §10.1.1 | 3 | Expect header | | RFC 9110 §6.5 | 5 | Trailer field restrictions | | RFC 9110 §12.5.1 | 1 | Content negotiation (Accept) | +| RFC 9110 §13.1 | 4 | Conditional requests (ETag, If-None-Match, If-Modified-Since) | | RFC 9110 §14.2 | 3 | Range requests | | RFC 9110 §15.2 | 1 | 1xx status codes | +| RFC 9110 §15.4.5 | 1 | 304 Not Modified response requirements | | RFC 9110 §15.5.6 | 1 | 405 Method Not Allowed | | RFC 9110 §15.5.16 | 1 | 415 Unsupported Media Type | | RFC 6455 | 2 | WebSocket handshake | diff --git a/docs/content/sequence-tests/capabilities/_index.md b/docs/content/sequence-tests/capabilities/_index.md new file mode 100644 index 0000000..af43be7 --- /dev/null +++ b/docs/content/sequence-tests/capabilities/_index.md @@ -0,0 +1,66 @@ +--- +title: Capabilities +layout: wide +toc: false +--- + +## Capabilities (Sequence Tests) + +These tests probe optional HTTP features that servers may or may not implement. All capability tests are **unscored** — they show what each server supports, not what it fails at. A `Warn` result means the server does not support the feature, not that it is non-compliant. + + +
+
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/static/probe/render.js b/docs/static/probe/render.js index 3c82044..cf55b5b 100644 --- a/docs/static/probe/render.js +++ b/docs/static/probe/render.js @@ -467,7 +467,16 @@ window.ProbeRender = (function () { 'NORM-SP-BEFORE-COLON-CL': '/Http11Probe/docs/normalization/sp-before-colon-cl/', 'NORM-TAB-IN-NAME': '/Http11Probe/docs/normalization/tab-in-name/', 'NORM-CASE-TE': '/Http11Probe/docs/normalization/case-te/', - 'NORM-UNDERSCORE-TE': '/Http11Probe/docs/normalization/underscore-te/' + 'NORM-UNDERSCORE-TE': '/Http11Probe/docs/normalization/underscore-te/', + 'CAP-ETAG-304': '/Http11Probe/docs/capabilities/etag-304/', + 'CAP-LAST-MODIFIED-304': '/Http11Probe/docs/capabilities/last-modified-304/', + 'CAP-ETAG-IN-304': '/Http11Probe/docs/capabilities/etag-in-304/', + 'CAP-INM-PRECEDENCE': '/Http11Probe/docs/capabilities/inm-precedence/', + 'CAP-INM-WILDCARD': '/Http11Probe/docs/capabilities/inm-wildcard/', + 'CAP-IMS-FUTURE': '/Http11Probe/docs/capabilities/ims-future/', + 'CAP-IMS-INVALID': '/Http11Probe/docs/capabilities/ims-invalid/', + 'CAP-INM-UNQUOTED': '/Http11Probe/docs/capabilities/inm-unquoted/', + 'CAP-ETAG-WEAK': '/Http11Probe/docs/capabilities/etag-weak/' }; function testUrl(tid) { @@ -727,7 +736,7 @@ window.ProbeRender = (function () { el.innerHTML = html; } - var CAT_LABELS = { Compliance: 'Compliance', Smuggling: 'Smuggling', MalformedInput: 'Malformed Input', Normalization: 'Normalization' }; + var CAT_LABELS = { Compliance: 'Compliance', Smuggling: 'Smuggling', MalformedInput: 'Malformed Input', Normalization: 'Normalization', Capabilities: 'Capabilities' }; function renderTable(targetId, categoryKey, ctx, testIdFilter, tableLabel) { injectScrollStyle(); @@ -1251,7 +1260,8 @@ window.ProbeRender = (function () { { label: 'Compliance', categories: ['Compliance'] }, { label: 'Smuggling', categories: ['Smuggling'] }, { label: 'Malformed Input', categories: ['MalformedInput'] }, - { label: 'Normalization', categories: ['Normalization'] } + { label: 'Normalization', categories: ['Normalization'] }, + { label: 'Capabilities', categories: ['Capabilities'] } ]; var html = '
'; diff --git a/src/Http11Probe.Cli/Program.cs b/src/Http11Probe.Cli/Program.cs index 61ffcd5..ed1c8b4 100644 --- a/src/Http11Probe.Cli/Program.cs +++ b/src/Http11Probe.Cli/Program.cs @@ -64,6 +64,7 @@ testCases.AddRange(SmugglingSuite.GetSequenceTestCases()); testCases.AddRange(MalformedInputSuite.GetTestCases()); testCases.AddRange(NormalizationSuite.GetTestCases()); + testCases.AddRange(CapabilitiesSuite.GetSequenceTestCases()); var runner = new TestRunner(options); diff --git a/src/Http11Probe/Runner/TestRunner.cs b/src/Http11Probe/Runner/TestRunner.cs index 7296325..3035957 100644 --- a/src/Http11Probe/Runner/TestRunner.cs +++ b/src/Http11Probe/Runner/TestRunner.cs @@ -178,10 +178,17 @@ private async Task RunSequenceAsync(SequenceTestCase seq, TestContex var parts = step.SendPartsFactory?.Invoke(context); if (parts is null) { - if (step.PayloadFactory is null) + Func? effectiveFactory = null; + + if (step.DynamicPayloadFactory is not null) + effectiveFactory = ctx => step.DynamicPayloadFactory(ctx, stepResults); + else if (step.PayloadFactory is not null) + effectiveFactory = step.PayloadFactory; + + if (effectiveFactory is null) throw new InvalidOperationException($"Sequence step '{label}' has no payload factory."); - parts = [new SequenceSendPart { PayloadFactory = step.PayloadFactory }]; + parts = [new SequenceSendPart { PayloadFactory = effectiveFactory }]; } var partPayloads = new List<(byte[] Bytes, TimeSpan DelayAfter, string? PartLabel)>(); diff --git a/src/Http11Probe/TestCases/SequenceStep.cs b/src/Http11Probe/TestCases/SequenceStep.cs index a358bc5..b63d840 100644 --- a/src/Http11Probe/TestCases/SequenceStep.cs +++ b/src/Http11Probe/TestCases/SequenceStep.cs @@ -5,6 +5,9 @@ public sealed class SequenceStep // One-shot send (existing behavior) public Func? PayloadFactory { get; init; } + // Dynamic payload that can reference previous step results (e.g., for conditional requests). + public Func, byte[]>? DynamicPayloadFactory { get; init; } + // Multi-part send with optional delays between parts (used for pause-based desync / partial sends). public Func>? SendPartsFactory { get; init; } public string? Label { get; init; } diff --git a/src/Http11Probe/TestCases/Suites/CapabilitiesSuite.cs b/src/Http11Probe/TestCases/Suites/CapabilitiesSuite.cs new file mode 100644 index 0000000..a440f7d --- /dev/null +++ b/src/Http11Probe/TestCases/Suites/CapabilitiesSuite.cs @@ -0,0 +1,631 @@ +using System.Text; +using Http11Probe.Client; + +namespace Http11Probe.TestCases.Suites; + +public static class CapabilitiesSuite +{ + private static byte[] MakeRequest(string raw) => Encoding.ASCII.GetBytes(raw); + + private static string? GetHeader(StepResult step, string headerName) + { + if (step.Response?.Headers is null) return null; + foreach (var kv in step.Response.Headers) + { + if (string.Equals(kv.Key, headerName, StringComparison.OrdinalIgnoreCase)) + return kv.Value; + } + return null; + } + + public static IEnumerable GetSequenceTestCases() + { + // ── CAP-ETAG-304 ────────────────────────────────────────────── + yield return new SequenceTestCase + { + Id = "CAP-ETAG-304", + Description = "ETag conditional GET returns 304 Not Modified", + Category = TestCategory.Capabilities, + Scored = false, + RfcLevel = RfcLevel.Should, + RfcReference = "RFC 9110 §13.1.2", + Expected = new ExpectedBehavior { Description = "304" }, + Steps = + [ + new SequenceStep + { + Label = "Initial GET (capture ETag)", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nConnection: keep-alive\r\n\r\n") + }, + new SequenceStep + { + Label = "Conditional GET (If-None-Match)", + DynamicPayloadFactory = (ctx, previousSteps) => + { + var etag = GetHeader(previousSteps[0], "ETag") ?? "\"no-etag\""; + return MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nIf-None-Match: {etag}\r\n\r\n"); + } + } + ], + Validator = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (!step1.Executed || step1.Response is null) + return TestVerdict.Error; + + if (step1.Response.StatusCode is < 200 or >= 300) + return TestVerdict.Error; + + var etag = GetHeader(step1, "ETag"); + if (etag is null) + return TestVerdict.Warn; // No ETag support + + if (!step2.Executed || step2.Response is null) + return TestVerdict.Warn; // Connection closed before step 2 + + if (step2.Response.StatusCode == 304) + return TestVerdict.Pass; + + if (step2.Response.StatusCode is >= 200 and < 300) + return TestVerdict.Warn; // Server ignores If-None-Match + + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + if (!step1.Executed || step1.Response is null) return "Step 1 failed"; + var etag = GetHeader(step1, "ETag"); + if (etag is null) return "No ETag header in response"; + var step2 = steps[1]; + if (!step2.Executed || step2.Response is null) return "Connection closed before conditional request"; + return $"ETag: {etag} → {step2.Response.StatusCode}"; + } + }; + + // ── CAP-LAST-MODIFIED-304 ───────────────────────────────────── + yield return new SequenceTestCase + { + Id = "CAP-LAST-MODIFIED-304", + Description = "Last-Modified conditional GET returns 304 Not Modified", + Category = TestCategory.Capabilities, + Scored = false, + RfcLevel = RfcLevel.Should, + RfcReference = "RFC 9110 §13.1.3", + Expected = new ExpectedBehavior { Description = "304" }, + Steps = + [ + new SequenceStep + { + Label = "Initial GET (capture Last-Modified)", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nConnection: keep-alive\r\n\r\n") + }, + new SequenceStep + { + Label = "Conditional GET (If-Modified-Since)", + DynamicPayloadFactory = (ctx, previousSteps) => + { + var lm = GetHeader(previousSteps[0], "Last-Modified") ?? "Thu, 01 Jan 2099 00:00:00 GMT"; + return MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nIf-Modified-Since: {lm}\r\n\r\n"); + } + } + ], + Validator = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (!step1.Executed || step1.Response is null) + return TestVerdict.Error; + + if (step1.Response.StatusCode is < 200 or >= 300) + return TestVerdict.Error; + + var lm = GetHeader(step1, "Last-Modified"); + if (lm is null) + return TestVerdict.Warn; // No Last-Modified support + + if (!step2.Executed || step2.Response is null) + return TestVerdict.Warn; + + if (step2.Response.StatusCode == 304) + return TestVerdict.Pass; + + if (step2.Response.StatusCode is >= 200 and < 300) + return TestVerdict.Warn; // Server ignores If-Modified-Since + + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + if (!step1.Executed || step1.Response is null) return "Step 1 failed"; + var lm = GetHeader(step1, "Last-Modified"); + if (lm is null) return "No Last-Modified header in response"; + var step2 = steps[1]; + if (!step2.Executed || step2.Response is null) return "Connection closed before conditional request"; + return $"Last-Modified: {lm} → {step2.Response.StatusCode}"; + } + }; + + // ── CAP-ETAG-IN-304 ────────────────────────────────────────── + yield return new SequenceTestCase + { + Id = "CAP-ETAG-IN-304", + Description = "304 response includes ETag header", + Category = TestCategory.Capabilities, + Scored = false, + RfcLevel = RfcLevel.Should, + RfcReference = "RFC 9110 §15.4.5", + Expected = new ExpectedBehavior { Description = "304 with ETag" }, + Steps = + [ + new SequenceStep + { + Label = "Initial GET (capture ETag)", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nConnection: keep-alive\r\n\r\n") + }, + new SequenceStep + { + Label = "Conditional GET (If-None-Match)", + DynamicPayloadFactory = (ctx, previousSteps) => + { + var etag = GetHeader(previousSteps[0], "ETag") ?? "\"no-etag\""; + return MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nIf-None-Match: {etag}\r\n\r\n"); + } + } + ], + Validator = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (!step1.Executed || step1.Response is null) + return TestVerdict.Error; + + if (step1.Response.StatusCode is < 200 or >= 300) + return TestVerdict.Error; + + var etag = GetHeader(step1, "ETag"); + if (etag is null) + return TestVerdict.Warn; // No ETag support at all + + if (!step2.Executed || step2.Response is null) + return TestVerdict.Warn; + + if (step2.Response.StatusCode != 304) + return TestVerdict.Warn; // No conditional support + + var etagIn304 = GetHeader(step2, "ETag"); + return etagIn304 is not null ? TestVerdict.Pass : TestVerdict.Warn; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + if (!step1.Executed || step1.Response is null) return "Step 1 failed"; + var etag = GetHeader(step1, "ETag"); + if (etag is null) return "No ETag support"; + var step2 = steps[1]; + if (!step2.Executed || step2.Response is null) return "Connection closed before conditional request"; + if (step2.Response.StatusCode != 304) return $"Step 2 returned {step2.Response.StatusCode} (no conditional support)"; + var etagIn304 = GetHeader(step2, "ETag"); + return etagIn304 is not null ? $"304 includes ETag: {etagIn304}" : "304 response missing ETag header"; + } + }; + + // ── CAP-INM-PRECEDENCE ──────────────────────────────────────── + yield return new SequenceTestCase + { + Id = "CAP-INM-PRECEDENCE", + Description = "If-None-Match takes precedence over If-Modified-Since", + Category = TestCategory.Capabilities, + Scored = false, + RfcLevel = RfcLevel.Should, + RfcReference = "RFC 9110 §13.1.2", + Expected = new ExpectedBehavior { Description = "304" }, + Steps = + [ + new SequenceStep + { + Label = "Initial GET (capture ETag)", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nConnection: keep-alive\r\n\r\n") + }, + new SequenceStep + { + Label = "Conditional GET (INM + stale IMS)", + DynamicPayloadFactory = (ctx, previousSteps) => + { + var etag = GetHeader(previousSteps[0], "ETag") ?? "\"no-etag\""; + // Use epoch as IMS — far in the past, so IMS alone would NOT produce 304. + return MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nIf-None-Match: {etag}\r\nIf-Modified-Since: Thu, 01 Jan 1970 00:00:00 GMT\r\n\r\n"); + } + } + ], + Validator = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (!step1.Executed || step1.Response is null) + return TestVerdict.Error; + + if (step1.Response.StatusCode is < 200 or >= 300) + return TestVerdict.Error; + + var etag = GetHeader(step1, "ETag"); + if (etag is null) + return TestVerdict.Warn; // No ETag support + + if (!step2.Executed || step2.Response is null) + return TestVerdict.Warn; + + if (step2.Response.StatusCode == 304) + return TestVerdict.Pass; // INM matched, IMS ignored + + if (step2.Response.StatusCode is >= 200 and < 300) + return TestVerdict.Warn; // Server used IMS (stale) and ignored INM + + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + if (!step1.Executed || step1.Response is null) return "Step 1 failed"; + var etag = GetHeader(step1, "ETag"); + if (etag is null) return "No ETag support"; + var step2 = steps[1]; + if (!step2.Executed || step2.Response is null) return "Connection closed before conditional request"; + if (step2.Response.StatusCode == 304) return "If-None-Match took precedence (correct)"; + if (step2.Response.StatusCode is >= 200 and < 300) return "If-Modified-Since took precedence (INM ignored)"; + return $"Unexpected: {step2.Response.StatusCode}"; + } + }; + + // ── CAP-INM-WILDCARD ────────────────────────────────────────── + yield return new SequenceTestCase + { + Id = "CAP-INM-WILDCARD", + Description = "If-None-Match: * on existing resource returns 304", + Category = TestCategory.Capabilities, + Scored = false, + RfcLevel = RfcLevel.Should, + RfcReference = "RFC 9110 §13.1.2", + Expected = new ExpectedBehavior { Description = "304" }, + Steps = + [ + new SequenceStep + { + Label = "Initial GET (confirm 2xx)", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nConnection: keep-alive\r\n\r\n") + }, + new SequenceStep + { + Label = "Conditional GET (If-None-Match: *)", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nIf-None-Match: *\r\n\r\n") + } + ], + Validator = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (!step1.Executed || step1.Response is null) + return TestVerdict.Error; + + if (step1.Response.StatusCode is < 200 or >= 300) + return TestVerdict.Error; + + if (!step2.Executed || step2.Response is null) + return TestVerdict.Warn; + + if (step2.Response.StatusCode == 304) + return TestVerdict.Pass; + + if (step2.Response.StatusCode is >= 200 and < 300) + return TestVerdict.Warn; // Server ignores wildcard + + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + if (!step1.Executed || step1.Response is null) return "Step 1 failed"; + if (step1.Response.StatusCode is < 200 or >= 300) return $"Step 1: {step1.Response.StatusCode}"; + var step2 = steps[1]; + if (!step2.Executed || step2.Response is null) return "Connection closed before conditional request"; + if (step2.Response.StatusCode == 304) return "Wildcard If-None-Match recognized"; + if (step2.Response.StatusCode is >= 200 and < 300) return "Server ignores If-None-Match: *"; + return $"Unexpected: {step2.Response.StatusCode}"; + } + }; + + // ── CAP-IMS-FUTURE ────────────────────────────────────────────── + yield return new SequenceTestCase + { + Id = "CAP-IMS-FUTURE", + Description = "If-Modified-Since with future date ignored", + Category = TestCategory.Capabilities, + Scored = false, + RfcLevel = RfcLevel.Should, + RfcReference = "RFC 9110 §13.1.3", + Expected = new ExpectedBehavior { Description = "200" }, + Steps = + [ + new SequenceStep + { + Label = "Initial GET (confirm 2xx)", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nConnection: keep-alive\r\n\r\n") + }, + new SequenceStep + { + Label = "Conditional GET (If-Modified-Since: future date)", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nIf-Modified-Since: Thu, 01 Jan 2099 00:00:00 GMT\r\n\r\n") + } + ], + Validator = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (!step1.Executed || step1.Response is null) + return TestVerdict.Error; + + if (step1.Response.StatusCode is < 200 or >= 300) + return TestVerdict.Error; + + if (!step2.Executed || step2.Response is null) + return TestVerdict.Warn; + + if (step2.Response.StatusCode is >= 200 and < 300) + return TestVerdict.Pass; // Server correctly ignores future IMS + + if (step2.Response.StatusCode == 304) + return TestVerdict.Warn; // Server didn't validate date + + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + if (!step1.Executed || step1.Response is null) return "Step 1 failed"; + if (step1.Response.StatusCode is < 200 or >= 300) return $"Step 1: {step1.Response.StatusCode}"; + var step2 = steps[1]; + if (!step2.Executed || step2.Response is null) return "Connection closed before conditional request"; + if (step2.Response.StatusCode is >= 200 and < 300) return "Correctly ignored future If-Modified-Since"; + if (step2.Response.StatusCode == 304) return "Server returned 304 for future date (didn't validate)"; + return $"Unexpected: {step2.Response.StatusCode}"; + } + }; + + // ── CAP-IMS-INVALID ───────────────────────────────────────────── + yield return new SequenceTestCase + { + Id = "CAP-IMS-INVALID", + Description = "If-Modified-Since with garbage date ignored", + Category = TestCategory.Capabilities, + Scored = false, + RfcLevel = RfcLevel.Should, + RfcReference = "RFC 9110 §13.1.3", + Expected = new ExpectedBehavior { Description = "200" }, + Steps = + [ + new SequenceStep + { + Label = "Initial GET (confirm 2xx)", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nConnection: keep-alive\r\n\r\n") + }, + new SequenceStep + { + Label = "Conditional GET (If-Modified-Since: garbage)", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nIf-Modified-Since: not-a-date\r\n\r\n") + } + ], + Validator = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (!step1.Executed || step1.Response is null) + return TestVerdict.Error; + + if (step1.Response.StatusCode is < 200 or >= 300) + return TestVerdict.Error; + + if (!step2.Executed || step2.Response is null) + return TestVerdict.Warn; + + if (step2.Response.StatusCode is >= 200 and < 300) + return TestVerdict.Pass; // Server correctly ignores invalid date + + if (step2.Response.StatusCode == 304) + return TestVerdict.Warn; // Server treated garbage as valid + + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + if (!step1.Executed || step1.Response is null) return "Step 1 failed"; + if (step1.Response.StatusCode is < 200 or >= 300) return $"Step 1: {step1.Response.StatusCode}"; + var step2 = steps[1]; + if (!step2.Executed || step2.Response is null) return "Connection closed before conditional request"; + if (step2.Response.StatusCode is >= 200 and < 300) return "Correctly ignored invalid If-Modified-Since"; + if (step2.Response.StatusCode == 304) return "Server returned 304 for garbage date (treated as valid)"; + return $"Unexpected: {step2.Response.StatusCode}"; + } + }; + + // ── CAP-INM-UNQUOTED ──────────────────────────────────────────── + yield return new SequenceTestCase + { + Id = "CAP-INM-UNQUOTED", + Description = "If-None-Match with unquoted ETag", + Category = TestCategory.Capabilities, + Scored = false, + RfcLevel = RfcLevel.Should, + RfcReference = "RFC 9110 §8.8.3", + Expected = new ExpectedBehavior { Description = "200" }, + Steps = + [ + new SequenceStep + { + Label = "Initial GET (capture ETag)", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nConnection: keep-alive\r\n\r\n") + }, + new SequenceStep + { + Label = "Conditional GET (If-None-Match: unquoted)", + DynamicPayloadFactory = (ctx, previousSteps) => + { + var etag = GetHeader(previousSteps[0], "ETag"); + if (etag is null) + return MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nIf-None-Match: no-etag\r\n\r\n"); + + // Strip surrounding quotes (and optional W/ prefix) + var stripped = etag; + if (stripped.StartsWith("W/")) + stripped = stripped[2..]; + if (stripped.StartsWith('"') && stripped.EndsWith('"')) + stripped = stripped[1..^1]; + + return MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nIf-None-Match: {stripped}\r\n\r\n"); + } + } + ], + Validator = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (!step1.Executed || step1.Response is null) + return TestVerdict.Error; + + if (step1.Response.StatusCode is < 200 or >= 300) + return TestVerdict.Error; + + var etag = GetHeader(step1, "ETag"); + if (etag is null) + return TestVerdict.Warn; // No ETag support + + if (!step2.Executed || step2.Response is null) + return TestVerdict.Warn; + + if (step2.Response.StatusCode is >= 200 and < 300) + return TestVerdict.Pass; // Correctly rejects malformed ETag syntax + + if (step2.Response.StatusCode == 304) + return TestVerdict.Warn; // Accepted unquoted ETag + + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + if (!step1.Executed || step1.Response is null) return "Step 1 failed"; + var etag = GetHeader(step1, "ETag"); + if (etag is null) return "No ETag support"; + var step2 = steps[1]; + if (!step2.Executed || step2.Response is null) return "Connection closed before conditional request"; + if (step2.Response.StatusCode is >= 200 and < 300) return "Correctly rejected unquoted ETag syntax"; + if (step2.Response.StatusCode == 304) return "Accepted unquoted ETag (lenient parsing)"; + return $"Unexpected: {step2.Response.StatusCode}"; + } + }; + + // ── CAP-ETAG-WEAK ─────────────────────────────────────────────── + yield return new SequenceTestCase + { + Id = "CAP-ETAG-WEAK", + Description = "Weak ETag comparison for GET", + Category = TestCategory.Capabilities, + Scored = false, + RfcLevel = RfcLevel.Should, + RfcReference = "RFC 9110 §13.1.2", + Expected = new ExpectedBehavior { Description = "304" }, + Steps = + [ + new SequenceStep + { + Label = "Initial GET (capture ETag)", + PayloadFactory = ctx => MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nConnection: keep-alive\r\n\r\n") + }, + new SequenceStep + { + Label = "Conditional GET (If-None-Match: W/etag)", + DynamicPayloadFactory = (ctx, previousSteps) => + { + var etag = GetHeader(previousSteps[0], "ETag"); + if (etag is null) + return MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nIf-None-Match: W/\"no-etag\"\r\n\r\n"); + + // If already weak, send as-is; if strong, prepend W/ + var weakEtag = etag.StartsWith("W/") ? etag : $"W/{etag}"; + + return MakeRequest( + $"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nIf-None-Match: {weakEtag}\r\n\r\n"); + } + } + ], + Validator = steps => + { + var step1 = steps[0]; + var step2 = steps[1]; + + if (!step1.Executed || step1.Response is null) + return TestVerdict.Error; + + if (step1.Response.StatusCode is < 200 or >= 300) + return TestVerdict.Error; + + var etag = GetHeader(step1, "ETag"); + if (etag is null) + return TestVerdict.Warn; // No ETag support + + if (!step2.Executed || step2.Response is null) + return TestVerdict.Warn; + + if (step2.Response.StatusCode == 304) + return TestVerdict.Pass; // Weak comparison matched + + if (step2.Response.StatusCode is >= 200 and < 300) + return TestVerdict.Warn; // Server didn't match weak ETag + + return TestVerdict.Fail; + }, + BehavioralAnalyzer = steps => + { + var step1 = steps[0]; + if (!step1.Executed || step1.Response is null) return "Step 1 failed"; + var etag = GetHeader(step1, "ETag"); + if (etag is null) return "No ETag support"; + var step2 = steps[1]; + if (!step2.Executed || step2.Response is null) return "Connection closed before conditional request"; + var weakEtag = etag.StartsWith("W/") ? etag : $"W/{etag}"; + if (step2.Response.StatusCode == 304) return $"Weak comparison matched: {weakEtag} → 304"; + if (step2.Response.StatusCode is >= 200 and < 300) return $"Weak comparison not matched: {weakEtag} → {step2.Response.StatusCode}"; + return $"Unexpected: {step2.Response.StatusCode}"; + } + }; + } +} diff --git a/src/Http11Probe/TestCases/TestCategory.cs b/src/Http11Probe/TestCases/TestCategory.cs index 232d1f7..87e77d7 100644 --- a/src/Http11Probe/TestCases/TestCategory.cs +++ b/src/Http11Probe/TestCases/TestCategory.cs @@ -7,5 +7,6 @@ public enum TestCategory MalformedInput, ResourceLimits, Injection, - Normalization + Normalization, + Capabilities }