{
  "flavors": {
    "ok": "The control, fully correct and minimal: badhttp_ok=1; Path=/cookies; Max-Age=3600. Store it, return it to /cookies/* for an hour.",
    "echo": "The readback. Sets nothing; returns the Cookie header you sent (raw, plus base64 of its UTF-8 re-encoding) and the parsed pairs in order, duplicates preserved. Three notes: an upstream hop joins multiple Cookie header lines with \"; \" before the Worker sees them; the runtime replaces bytes that are not valid UTF-8 with U+FFFD — so EF BF BD in the base64 means your client sent raw non-UTF-8 bytes; and a Cookie header over 8,199 bytes is silently dropped upstream of the Worker (observed live: 8,199 arrives intact, 8,200 never arrives, the request otherwise succeeds).",
    "folded": "Two cookies folded into ONE Set-Cookie header, comma-separated: \"badhttp_folded_a=1, badhttp_folded_b=2\". bis §3 forbids folding Set-Cookie (RFC 6265 had it at SHOULD NOT); the parse algorithm yields ONE cookie whose value is \"1, badhttp_folded_b=2\". A client that splits on commas invents a second cookie.",
    "many": "?count= separate Set-Cookie headers (1-20, default 10), badhttp_many_01 onward, zero-padded. Tests per-response cookie handling and ordering.",
    "duplicate": "The same name twice with different paths: badhttp_dup=deep; Path=/cookies/echo and badhttp_dup=shallow; Path=/cookies. Two distinct cookies. A jar keyed on name alone silently loses one.",
    "on-redirect": "A 302 to /cookies/echo that carries Set-Cookie: badhttp_redirect=1 ON the redirect itself. A historic bug class: clients that drop Set-Cookie on 3xx responses. One shot: curl -sL -c jar -b jar.",
    "delete": "The cleanup: one expiring Set-Cookie for every cookie this family can plant, each with the exact Path (and Domain, and prefix-required attributes) it was set with — RFC 6265 §5.3 removes a cookie only on a name+domain+path match, so a deletion that is casual about attributes deletes nothing. Uses both idioms: Expires in 1970 and Max-Age=0.",
    "conflicting-expiry": "badhttp_conflict=alive with BOTH Expires in 1970 AND Max-Age=3600. §5.3 step 3 consults Max-Age before Expires (prose in §4.1.2.2: Max-Age has precedence). A client honoring Expires deletes a cookie that should live an hour.",
    "bad-expires": "Expires in ISO 8601 (2027-08-23T12:00:00Z), which the cookie-date algorithm (§5.1.1) cannot parse — \"-\" is a delimiter and no month token survives. The attribute is ignored and the cookie becomes a session cookie. A homegrown jar that feeds Expires to a general date parser mints a 2027 expiry instead; /cookies/delete clears it either way.",
    "far-future": "Expires in the year 9999 (Fri, 01 Jan 9999 00:00:00 GMT). bis §5.5 says user agents SHOULD cap cookie lifetime (400 days recommended); CLI jars mostly predate the cap. /cookies/delete removes it.",
    "wrong-domain": "Domain=example.com on a cookie set by this host. The Domain does not domain-match the request host, so the whole cookie MUST be ignored (§5.3 step 6). Jar-observable: it must simply never appear. Max-Age is 300 s so a jar that wrongly keeps it is only polluted briefly.",
    "public-suffix": "Domain=dev — a public suffix. A cookie scoped to a whole TLD is a supercookie; a jar configured with a public-suffix list ignores it (§5.3 step 5 — conditional on that configuration; plain RFC 6265 without a PSL would accept it, since badhttp.dev domain-matches dev). On this host the single-label Domain also trips the older no-embedded-dot heuristic, so PSL-free jars reject it too; only a jar with neither guard stores it — and would then send it back here, so /cookies/echo can catch it. Max-Age 300 s bounds the damage.",
    "domain": "The accepted-Domain pair: badhttp_domain_dot with Domain=.<this host> (leading dot) and badhttp_domain with Domain=<this host>. §5.2.3 strips the leading %x2E, so both become identical domain cookies (host-only flag off) — a classic divergence between jar generations and jar file formats. Meaningful on badhttp.dev itself.",
    "path-prefix": "Path=/cookie — one letter short of /cookies. Path-matching (§5.1.4) requires the prefix to end at a \"/\" boundary, so this cookie must NEVER be sent to /cookies/*. A naive prefix-matcher sends it anyway.",
    "name-prefixes": "Four prefixed cookies (bis §4.1.3, §5.4 — the prefixes do not exist in RFC 6265): __Host-badhttp_good (valid: Path=/, Secure, no Domain), __Host-badhttp_bad (invalid: Path is not /), __Secure-badhttp_good (valid: Secure), __Secure-badhttp_bad (invalid: no Secure). A bis client stores exactly the two _good ones; an RFC-6265-only jar conformantly stores all four. Meaningful over HTTPS only.",
    "quoted": "badhttp_quoted=\"hello world\" (a DQUOTE-wrapped value with a space) and badhttp_semi=\"semi;colon\" (a semicolon inside the quotes — but the parser splits on \";\" before it ever sees quotes, so the stored value is \"semi with an unclosed quote). What does your client send back — quotes kept, stripped, re-added?",
    "utf8": "A value of raw UTF-8: badhttp_utf8=☃ (the bytes e2 98 83 on the wire; the runtime UTF-8-encodes header strings, which is itself a platform quirk worth knowing). Outside the cookie-octet grammar; real servers do it anyway. Jars differ: store raw, percent-encode, or drop. The echo base64 field shows exactly what came back.",
    "nameless": "Two nameless shapes at different paths so both can coexist: a Set-Cookie with no \"=\" at all (badhttp-just-a-value, default path /cookies) and one that starts with \"=\" (=badhttp_empty_name; Path=/cookies/echo). RFC 6265 §5.2 ignores both — step 2 (no \"=\") and step 5 (empty name); the bis parse stores each as a value with an empty name. Generations of jars really do differ here.",
    "huge": "One cookie whose name plus value sum to exactly ?bytes= bytes (64-8192, default 4096), padded with x. bis §5.6 step 5 says a client MUST ignore the cookie when name+value exceed 4096 bytes (RFC 6265 §6.1 has only a SHOULD-support floor, measured including attributes). So 4096 survives a conformant jar and 4097 must not. The echo round trip tells you your client's cap."
  },
  "usage": "/cookies/{flavor}; /cookies/echo is the readback",
  "politeness": "Every cookie name starts with badhttp_ (or __Host-badhttp_ / __Secure-badhttp_) — except the two nameless-flavor cookies, which have no name at all and carry the badhttp marker in their value. No Max-Age or Expires runs past 3600 s except far-future, whose date is the test (the two reject-me flavors are down at 300 s); bad-expires and the nameless pair carry no usable expiry and live as session cookies. Cookies are scoped Path=/cookies wherever the test allows; /cookies/delete expires every cookie the family can plant, exact paths and all; no cookie value is ever derived from your request.",
  "observability": "Most flavors are visible in the /cookies/echo round trip or in your jar file. wrong-domain and public-suffix are negative tests: the bug is their cookie existing at all. path-prefix is stored correctly by a conformant jar; the bug is it appearing in a Cookie header here. Every response body repeats the Set-Cookie values it carried (set) and what your request carried (received).",
  "spec": "RFC 6265 and draft-ietf-httpbis-rfc6265bis-22 (\"bis\"); each flavor names the rule it bends.",
  "limits": {
    "max_count": 20,
    "max_bytes": 8192,
    "echoed_cookie_header_cap": 16384
  },
  "witness": {
    "measured": "2026-09-30T15:46:06Z",
    "observations": 136,
    "data": "https://badhttp.dev/clients.jsonl",
    "data_note": "Every observation is a row of /clients.jsonl with family \"cookies\", joined to /corpus.jsonl by corpus_id; /clients indexes them with the jar legend, the outcome legend and the per-flavor disagreement. The findings below are a reading of those rows, not a second source.",
    "what_this_is": "Eight real HTTP clients, each with a fresh jar per flavor (Python urllib3 has no jar at all), run through GET /cookies/{flavor}, GET /cookies/echo, GET /cookies/delete and GET /cookies/echo again on this exact date. It is a DATED CAPTURE, not a live measurement, and it describes WHAT CAME BACK to /cookies/echo — never a verdict on a client. Returning nothing is the specified answer on wrong-domain and path-prefix, and the recommended one on public-suffix (for a jar with a public-suffix list, §5.3 step 5); a client with no jar returning nothing anywhere is a capability, not a defect, and its rows say jar_kind: no-jar. Where the harness could enumerate the jar, the row also carries what the jar recorded (domain, path, host_only, secure, expires), which is how a cookie that was stored but not sent is told apart from one that was never stored. Re-run it and move the date rather than letting it stale.",
    "clients": [
      "curl 8.7.1",
      "Go net/http go1.27.0",
      "Node fetch (undici) + tough-cookie node v26.10.0 / undici 8.10.2 / tough-cookie 6.0.2",
      "Python urllib.request python 3.14.7 stdlib",
      "Python requests 2.34.2 (python 3.14.7)",
      "Python httpx 0.28.1 (python 3.14.7)",
      "Python urllib3 2.8.0 (python 3.14.7)",
      "Python aiohttp 3.14.3 (python 3.14.7)"
    ],
    "findings": [
      "ok (the control): all 7 clients with a jar stored badhttp_ok and sent it back to /cookies/echo. Python urllib3 has no cookie jar at all, so its echo carried nothing on every flavor: a capability of the library, not a bug, and its rows say jar_kind: no-jar.",
      "folded (two cookies comma-joined into one Set-Cookie): curl, Go net/http, Node fetch (undici) + tough-cookie, Python urllib.request, Python requests, Python httpx stored one cookie, badhttp_folded_a, with the comma and the fake second cookie inside its value, as the bis parse says; Python aiohttp split on the comma and returned a second cookie named badhttp_folded_b that this server never set.",
      "many (ten separate Set-Cookie headers): all 7 clients with a jar returned all ten, all of them in creation order. duplicate (the same name on two paths): curl, Go net/http, Node fetch (undici) + tough-cookie, Python urllib.request, Python requests, Python httpx sent both badhttp_dup cookies to /cookies/echo, every one with the longer path (deep) first as §5.4 asks; the rest: only deep (Python aiohttp).",
      "on-redirect (Set-Cookie on the 302 itself): all 7 clients with a jar kept the cookie set on the redirect and presented it on the follow-up.",
      "conflicting-expiry (Expires in 1970 and Max-Age=3600 on one cookie): all 7 clients with a jar kept the cookie, so Max-Age won. Python aiohttp nevertheless recorded the 1970 date in the jar entry it exposed while still sending the cookie. bad-expires (an ISO 8601 date): all 7 clients with a jar sent it back; in the jar it is a session cookie (curl, Node fetch (undici) + tough-cookie, Python urllib.request, Python requests, Python httpx); jar not enumerable (Go net/http); expiry not exposed by the jar (Python aiohttp).",
      "far-future (Expires in the year 9999; bis §5.5 recommends a 400-day cap): all 7 clients with a jar sent it back; in the jar: the year 9999 kept (curl, Node fetch (undici) + tough-cookie, Python urllib.request, Python requests, Python httpx, Python aiohttp); jar not enumerable (Go net/http).",
      "wrong-domain (Domain=example.com) and public-suffix (Domain=dev): no client returned the wrong-domain cookie; Python aiohttp returned the supercookie scoped to the whole TLD. Returning nothing is what §5.3 step 6 requires on wrong-domain; on public-suffix it is what a jar with a public-suffix list does (§5.3 step 5, which says user agents SHOULD use one), and plain RFC 6265 without a list would store it.",
      "domain (Domain=.badhttp.dev and Domain=badhttp.dev): all 7 clients with a jar returned both; of the 6 whose jar the harness could enumerate, all recorded both as domain cookies (host_only: false), the leading dot making no difference. path-prefix (Path=/cookie, one letter short): no client sent it to /cookies/echo (/cookie does not path-match /cookies/echo); every enumerable jar had stored it.",
      "name-prefixes (__Host- and __Secure-, one valid and one invalid each): returned __Host-badhttp_good and __Host-badhttp_bad and __Secure-badhttp_good and __Secure-badhttp_bad (Go net/http, Python urllib.request, Python requests, Python httpx, Python aiohttp); returned __Host-badhttp_good and __Secure-badhttp_good (curl, Node fetch (undici) + tough-cookie). The two-cookie answer is the bis §4.1.3 rule; the four-cookie answer is RFC 6265 without prefix rules, so the two answers follow two different specifications.",
      "quoted (a DQUOTE-wrapped value with a space, and a semicolon inside quotes): badhttp_quoted came back as \"hello world\" and badhttp_semi as \"semi (curl, Node fetch (undici) + tough-cookie, Python urllib.request, Python requests, Python httpx, Python aiohttp); badhttp_quoted came back as \"hello world\" and badhttp_semi not returned (Go net/http).",
      "utf8 (a raw ☃, bytes e2 98 83, in the value): bytes e2 98 83 (curl, Node fetch (undici) + tough-cookie, Python urllib.request, Python requests); not returned (Go net/http, Python aiohttp); Python httpx raised after the setter response arrived (status 200, 1 Set-Cookie), with no echo recorded ('ascii' codec can't encode character '\\u2603' in position 13: ordinal not in range(128)).",
      "nameless (a Set-Cookie with no \"=\" at all, and one starting with \"=\"): Python urllib.request, Python requests, Python httpx sent the no-equals line back bare, which this server reads as a nameless value — and their jars record it as a cookie NAMED badhttp-just-a-value with no value at all; no client sent back the line that starts with \"=\"; Node fetch (undici) + tough-cookie refused both loudly (jar_rejections on the row); curl, Go net/http, Python aiohttp stored neither and raised nothing. http.cookiejar parses a value with no \"=\" as a name and writes it back without one (the same behaviour v0.6.0 recorded on 2026-08-23), which is why /cookies/delete carries a deletion for that name.",
      "huge (name+value exactly 4096 bytes, the bis §5.6 limit): all 7 clients with a jar stored and returned it.",
      "delete (one expiring Set-Cookie per plantable cookie, exact attributes): after the cleanup, curl, Go net/http, Node fetch (undici) + tough-cookie, Python urllib.request, Python requests, Python httpx had none of the planted cookies left on any flavor; still present afterwards: Python aiohttp (ok).",
      "Also after the cleanup: Python aiohttp (badhttp_ok) carried a cookie that no flavor in that session had planted — a deletion tombstone from /cookies/delete stored as a live cookie instead of removing one. badhttp_ok is the one deletion in that list that uses the Expires=1970 idiom; the other 44 use Max-Age=0 and took effect, so the jar that kept it acted on Max-Age and not on an Expires in the past.",
      "1 of 136 observations ended in the client raising or the transport failing: Python httpx on utf8. Every other row is four responses this server sent, each read back from the wire with its x-badhttp-version."
    ],
    "what_the_oracle_cannot_tell_you": "/cookies/echo says what one request carried. It cannot say what the jar holds (a cookie stored and withheld looks the same as one never stored), which is why rows carry jar_entries where the client exposes its jar and jar_enumerable: false where it does not; it cannot say what the client refused loudly, which is why jar_rejections exists; and it cannot see a cookie the client dropped at parse time. Each row is a description of these four responses on one date.",
    "reproduce": "Start the row's client with a fresh jar, GET the row's url following redirects, GET /cookies/echo and compare its cookies with the row's echo.cookies; then GET /cookies/delete and /cookies/echo again. Pace the calls against the zone limit of 100 per 10 s, and treat a response without x-badhttp-version as no observation. The harnesses that produced the rows are in scripts/cookies-witness/ in the source."
  }
}
