{
  "family": "crosshost",
  "about": "What your HTTP client does with credentials when a redirect crosses a host boundary. Each flavor is a starting URL that redirects across a specific boundary (or, for the same-origin control, across none) and lands on an oracle that reports which credential headers arrived.",
  "hosts": {
    "canonical": "https://badhttp.dev",
    "alt": "https://alt.badhttp.dev",
    "relationship": "alt.badhttp.dev is one label deeper than badhttp.dev: a different host, the same registrable domain, the same Worker and the same zone certificate.",
    "why_two": "A client decides whether to keep forwarding Authorization, Proxy-Authorization and Cookie by comparing the redirect target against where it started. The comparisons differ between clients and are only observable across real, separately-named hosts. You cannot see this from a listener on 127.0.0.1: an agent framework’s SSRF filter rejects a loopback target at hop zero, so the code under test never runs.",
    "what_alt_serves": "alt.badhttp.dev serves this family’s endpoints and nothing else. It carries X-Robots-Tag: noindex and a robots.txt that disallows everything: it is a fixture, not a second copy of the site."
  },
  "no_open_redirect": "Redirect targets come from a frozen table of the two hosts above, selected by flavor name. No endpoint in this family accepts a redirect target, or any part of one, from the caller: there is no ?url=, ?next= or ?to=, and every Location is checked against a fixed pattern before it is sent. There is no open redirect here, by construction.",
  "oracle": {
    "url": "https://badhttp.dev/crosshost/land",
    "also": "https://alt.badhttp.dev/crosshost/land",
    "reports": [
      "authorization",
      "proxy_authorization",
      "cookie",
      "x_api_key"
    ],
    "x_api_key_note": "x-api-key is reported because no client measured here stripped it at any boundary: it arrived on every witness row, including every row on which Authorization and Cookie were stripped. The credential headers these clients treat as sensitive are the standard ones.",
    "no_echo": "Nothing you sent is echoed here — not the value, not a prefix, not a hash. A digest of a weak credential is a cracking target, not a mitigation. You know what you sent; presence, scheme and byte length are enough to tell you whether it survived the hop."
  },
  "test_credentials": {
    "basic": "Basic YWdlbnQ6Y29ycmVjdA==",
    "bearer": "Bearer badhttp-token-ok",
    "x-api-key": "badhttp-key-ok",
    "warning": "These are public and fake, exactly as in /auth. Send these and nothing else. If a credential that is not one of these reaches the landing endpoint, the response says so and tells you to rotate it."
  },
  "edge_note": "Every response from this server carries an x-badhttp-version header. If a response you get while following one of these chains does NOT carry it, the Worker never ran: you hit the zone rate limit (100 requests per 10 s per IP) and are reading Cloudflare’s own 429, not a badhttp response. Each hop is a separate request against that budget. Treat a missing x-badhttp-version as no observation and retry after a pause — recording it as \"the credential did not survive\" would be a false reading, and this family is only worth having if its readings are true.",
  "reading_this": "These fields describe what THIS SERVER RECEIVED on the final hop. They are not a verdict on your client. Whether forwarding a credential across a given boundary is a bug depends on the boundary and on what your client documents: RFC 9110 §15.4 says nothing at all about credentials on redirects, and the Fetch standard’s cross-origin stripping rule governs browsers, not command line clients. Several of the behaviours you can observe here are deliberate, documented choices by their authors. What this server can tell you truthfully is which bytes arrived.",
  "witness": {
    "measured": "2026-09-17T20:56:39Z",
    "observations": 72,
    "data": "https://badhttp.dev/clients.jsonl",
    "data_note": "Every observation is a row of /clients.jsonl with family \"crosshost\", joined to /corpus.jsonl by corpus_id; /clients indexes them with 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 run against these exact URLs on this exact date. It is a DATED CAPTURE, not a live measurement, and it describes WHAT THE CALLER RECEIVED — it is never a verdict on a client. Several of the behaviours below are deliberate, documented choices by their authors, and RFC 9110 §15.4 says nothing about credentials on redirects at all, so there is no conformance being measured here. 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) node v26.8.1 / undici 8.10.0",
      "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": [
      "X-Api-Key arrived intact on 72 of 72 observations, including all 46 on which Authorization was stripped. The harnesses did not send Proxy-Authorization, so the oracle's proxy_authorization: false on every row is not an observation of stripping.",
      "Go net/http forwarded Authorization and the hand-set Cookie across to-subdomain, boomerang, scheme-upgrade, scheme-downgrade, port-change, relative-authority and stripped both on from-subdomain. Its shouldCopyHeaderOnRedirect() forwards to the initial host or a subdomain of it by documented design; it compares URL.Hostname(), which is why the port change is invisible to it, and it does not examine the scheme.",
      "On scheme-downgrade (https to http, same host) Go net/http, Python urllib.request carried Authorization onto a plaintext hop, and the landing response records transport_was_encrypted: false. Nothing is malfunctioning: Go's comparison is a hostname comparison and a scheme change does not change the hostname; urllib makes no comparison at all.",
      "Python urllib (stdlib) forwarded Authorization and Cookie on 8 of 8 header flavors. Its HTTPRedirectHandler copies the caller-set headers with no host comparison of any kind. Credentials its own auth handlers generate go in a separate store that is not copied, so this affects headers the caller set, which is the common case for API clients.",
      "curl restored both headers on boomerang: absent at the subdomain, present again when the chain returned to the origin it started from. curl compares each hop against the ORIGINAL origin and regenerates the header rather than carrying a mutated map. Every other client that stripped at the subdomain stayed stripped on the way back; a client that never stripped at the subdomain has nothing to restore.",
      "Python requests, Python httpx kept Authorization on scheme-upgrade (http to https, same host, default ports) while stripping it at the subdomain; curl, Node fetch (undici), Python urllib3, Python aiohttp stripped it here too. Both carry a carve-out their own source comments describe as backwards compatibility rather than a security judgement. Both also dropped the hand-set Cookie header on EVERY redirect, the same-origin control included, because they clear it before any origin comparison and re-derive from their jar.",
      "port-change: Go net/http, Python urllib.request forwarded to badhttp.dev:8443; curl, Node fetch (undici), Python requests, Python httpx, Python urllib3, Python aiohttp treated the port change as a different origin. relative-authority: every client resolved the protocol-relative Location to alt.badhttp.dev and then applied exactly the policy it applied on to-subdomain (8 of 8 agree with themselves), so no client here showed the resolution-versus-policy split that flavor exists to catch.",
      "jar: of the three cookies the first hop sets before crossing to alt.badhttp.dev, badhttp_domain (Domain=badhttp.dev) arrived from curl, Go net/http, Python urllib.request, Python requests, Python httpx, Python aiohttp. badhttp_hostonly (no Domain attribute) arrived from Python urllib.request, Python requests, Python httpx, and __Host-badhttp_lock from Python urllib.request, Python requests, Python httpx. Those are the three clients built on Python's http.cookiejar, and none of curl, Go or aiohttp: the stdlib's DefaultCookiePolicy returns a cookie set without a Domain attribute to any host under it unless DomainStrictNonDomain is set (the default is DomainLiberal), and cookiejar.py contains no handling of the __Host- or __Secure- prefixes at all; aiohttp's own jar tracks host-only cookies. Node fetch and urllib3 have no jar, and nothing arrived.",
      "Node fetch (undici) stripped Authorization and the Cookie header on 7 of 7 boundary flavors and kept both on the same-origin control — subdomain, port and scheme changes alike, boomerang not restored. That is the Fetch standard's rule, under which an origin is scheme, host and port, applied outside a browser."
    ],
    "what_the_oracle_cannot_tell_you": "The landing response alone cannot distinguish \"I followed a redirect and my client forwarded this\" from \"I typed the landing URL directly with a credential on it\". A reading is only evidence if the harness recorded which starting URL it used and that it followed a redirect, which is why every row carries its start url, the url it finally landed on, whether a redirect was followed and, where the client exposes a count, hops_followed (Node fetch exposes only a boolean, so its rows carry null there), and why the reproduce line below starts from the flavor urls and not from /crosshost/land. Any table built from oracle bodies alone is unverifiable.",
    "reproduce": "Send Authorization, Cookie and X-Api-Key with the published test values at each flavor url and read /crosshost/land. Pace the requests: each flavor is 2 or 3 requests against a zone limit of 100 per 10 s, and a response missing x-badhttp-version is the edge, not this server. Treat that as no observation and retry. The harnesses that produced the rows are in scripts/crosshost-witness/ in the source."
  },
  "prior_art_and_advisories": {
    "note": "These flavors characterize behaviour; they are not exploits, and badhttp is a neutral place to see what your client does rather than an attacker-controlled host. The advisories below are the reason the boundaries are worth measuring. Each was read from the issuing project’s own advisory page.",
    "redirect_credential_leaks": [
      "CVE-2018-1000007 — curl 6.0 to 7.57.0: a custom Authorization header forwarded to any redirect target.",
      "CVE-2022-27776 — curl 4.9 to 7.82.0: Authorization and Cookie leaked on a redirect to the SAME hostname with a different port or scheme; the same-host check compared only the hostname, for 22 years.",
      "CVE-2022-27774 — curl 4.9 to 7.82.0: credentials leaked on a cross-protocol or different-port redirect.",
      "CVE-2024-11053 — curl 7.76.0 to 8.11.0: a .netrc entry for the redirect TARGET unlocked the FIRST host’s password.",
      "CVE-2026-3783 — curl 7.33.0 to 8.18.0: an OAuth2 bearer token forwarded to a different hostname via .netrc.",
      "CVE-2018-20060 — urllib3 below 1.23: Authorization not removed on a cross-origin redirect. urllib3 defines cross-origin as a difference in host, port OR scheme.",
      "CVE-2023-32681 — requests: Proxy-Authorization leaked to a destination server across an https redirect.",
      "CVE-2022-0235 — node-fetch, and CVE-2022-0155 — follow-redirects: before these fixes both forwarded credentials to ANY redirect target; the fixes added the same one-way subdomain carve-out Go documents."
    ],
    "not_reproducible_here": "curl CVE-2026-11856 and CVE-2026-7168 are cross-origin Digest and proxy-Digest state leaks, and they are NOT redirect bugs: they need one libcurl easy handle reused for a second transfer to a different origin, and curl’s advisory states the command line tool is unaffected. No redirect fixture can reproduce them, and this one does not claim to."
  },
  "specs": {
    "RFC 9110 §15.4": "Redirection. Says nothing about credentials on redirect: the behaviour this family measures is convention and per-client policy, not conformance.",
    "RFC 6265 §5.1.3": "Cookie domain-matching, which the jar flavor exercises.",
    "Fetch standard, HTTP-redirect fetch": "Strips Authorization on a cross-origin redirect. Governs browsers; the clients measured here are not bound by it."
  },
  "flavors": {
    "same-origin": {
      "url": "https://badhttp.dev/crosshost/same-origin",
      "starts_on": "badhttp.dev",
      "scheme": "https",
      "redirect_hops": 1,
      "about": "The control: one redirect that does not leave badhttp.dev. Every client that sends credentials at all sends them to the second hop, so a run where this flavor shows nothing means the credential never left your client and the other rows are measuring your configuration, not the boundary. Read this one first."
    },
    "to-subdomain": {
      "url": "https://badhttp.dev/crosshost/to-subdomain",
      "starts_on": "badhttp.dev",
      "scheme": "https",
      "redirect_hops": 1,
      "about": "badhttp.dev redirects to alt.badhttp.dev — a different host, one label deeper, same registrable domain. Most clients compare hostnames exactly and strip Authorization here. Go net/http does not: shouldCopyHeaderOnRedirect() forwards its sensitive headers when the destination is the initial host or a subdomain of it, which its own documentation states as intended. This is the flavor the family exists for, and the one a loopback fixture cannot produce.",
      "related": [
        "CVE-2018-1000007 (curl 6.0-7.57.0: a custom Authorization header was forwarded to any redirect target; the fix restricted it to the original host and made cross-host forwarding opt-in via --location-trusted)"
      ]
    },
    "from-subdomain": {
      "url": "https://alt.badhttp.dev/crosshost/from-subdomain",
      "starts_on": "alt.badhttp.dev",
      "scheme": "https",
      "redirect_hops": 1,
      "about": "The same boundary in the other direction: alt.badhttp.dev redirects to badhttp.dev. The rule that forwards apex-to-subdomain does not run backwards — a parent is not a subdomain of its child — so a client that kept its credentials on to-subdomain may well strip them here. Start this one on the alt host; its URL is not on badhttp.dev.",
      "related": [
        "GHSA-hq3h-g68c-hp78 / CVE-2026-55553 (node urllib, 2026-08-25): the researcher built a three-container rig with distinct hostnames rather than a loopback pair, stating that the redirect had to cross a clear origin boundary for the test to mean anything."
      ]
    },
    "boomerang": {
      "url": "https://badhttp.dev/crosshost/boomerang",
      "starts_on": "badhttp.dev",
      "scheme": "https",
      "redirect_hops": 2,
      "about": "Two redirects and three requests, out to alt.badhttp.dev and back to badhttp.dev, ending where it began. Clients that compare each hop against the ORIGINAL origin and regenerate the header can restore a credential they had already dropped; clients that carry a header map and delete from it, or that latch a strip decision once and never revisit it, cannot. Same start and end origin, opposite answers.",
      "related": [
        "Not a vulnerability, a design difference: curl re-evaluates each hop against the ORIGINAL origin (Curl_auth_allowed_to_origin), while Go latches a strip decision once (client.go stripSensitiveHeaders) and several clients mutate a carried header map. Same start and end origin, different answers."
      ]
    },
    "scheme-upgrade": {
      "url": "http://badhttp.dev/crosshost/scheme-upgrade",
      "starts_on": "badhttp.dev",
      "scheme": "http",
      "redirect_hops": 1,
      "about": "Same hostname, plaintext to TLS: http://badhttp.dev redirects to https://badhttp.dev. Several clients treat the scheme as part of the origin and strip here; Python requests carries a documented carve-out that keeps credentials across exactly this hop on default ports, kept for backwards compatibility and commented as such in its source. Start this one over http.",
      "related": [
        "CVE-2018-20060 (urllib3 < 1.23): Authorization was not removed on a cross-origin redirect, where urllib3 defines cross-origin as a difference in host, PORT OR SCHEME.",
        "Python requests keeps credentials across exactly this hop by a documented carve-out in should_strip_auth(), commented as backwards compatibility rather than a security judgement."
      ]
    },
    "scheme-downgrade": {
      "url": "https://badhttp.dev/crosshost/scheme-downgrade",
      "starts_on": "badhttp.dev",
      "scheme": "https",
      "redirect_hops": 1,
      "about": "The dangerous direction: https://badhttp.dev redirects to http://badhttp.dev, same hostname, TLS to plaintext. A client that compares only the hostname sends the credential over the wire in the clear. Use the public test credentials here and nothing else — the second hop is not encrypted, and this server says so in the response.",
      "related": [
        "CVE-2022-27776 (curl 4.9-7.82.0, 2022-04-27): a redirect to the SAME hostname on a different port or scheme leaked both Authorization and Cookie, because the same-host check compared only the hostname. Introduced in curl 4.9 (2000) and unfixed for 22 years.",
        "CVE-2025-14524 (curl 7.33.0-8.17.0): an OAuth2 bearer token leaked on a cross-protocol redirect, including to the same hostname on a different protocol and port."
      ]
    },
    "port-change": {
      "url": "https://badhttp.dev/crosshost/port-change",
      "starts_on": "badhttp.dev",
      "scheme": "https",
      "redirect_hops": 1,
      "about": "Same hostname, same scheme, different port: badhttp.dev redirects to badhttp.dev:8443, which Cloudflare serves for this zone under the same certificate. This is the axis a hostname-only comparison structurally cannot see — Go net/http compares URL.Hostname(), which strips the port, so a port change is invisible to it, while clients that compare a (scheme, host, port) tuple treat it as a different origin. Nothing about the destination is less trustworthy than the origin here; the point is only which clients notice the difference at all.",
      "related": [
        "CVE-2022-27776 (curl 4.9-7.82.0): a redirect to the same hostname on a different port leaked both Authorization and Cookie, because the same-host check compared only the hostname.",
        "urllib3 (CVE-2018-20060) defines a cross-origin redirect as a difference in host, PORT or scheme, and compares a (scheme, host, port) tuple."
      ]
    },
    "relative-authority": {
      "url": "https://badhttp.dev/crosshost/relative-authority",
      "starts_on": "badhttp.dev",
      "scheme": "https",
      "redirect_hops": 1,
      "about": "The host changes without the Location naming a scheme: a protocol-relative reference (RFC 3986 §4.2 network-path, Location: //alt.badhttp.dev/crosshost/land). Resolving it against the current URL replaces the authority entirely. This tests URL RESOLUTION rather than header policy, and the two live in different code paths in most clients — a client can compare origins correctly and still fail to notice this one changed hosts. The target is a fixed constant this server holds; it is never built from anything a caller sends.",
      "related": [
        "CVE-2026-54603 (ruby oauth2, 2026-07-28): resolving a protocol-relative reference against a base URL replaces the authority, so a client whose credential-stripping logic runs off a different code path than its URL resolution never notices the host changed."
      ]
    },
    "jar": {
      "url": "https://badhttp.dev/crosshost/jar",
      "starts_on": "badhttp.dev",
      "scheme": "https",
      "redirect_hops": 1,
      "about": "The cookie half of the boundary, and a different question from the header half. This hop sets three cookies before redirecting to alt.badhttp.dev: badhttp_hostonly (no Domain attribute, so it belongs to badhttp.dev alone), badhttp_domain (Domain=badhttp.dev, which by rfc6265bis §5.1.3 domain-matching also covers subdomains, so it legitimately travels), and __Host-badhttp_lock, whose prefix requires Secure, Path=/ and no Domain and in exchange locks the cookie to exactly the host that set it. Only badhttp_domain should arrive. A jar that also sends the host-only one is ignoring the host-only flag; a jar that sends the __Host- one has broken the only guarantee that prefix makes. A client with no jar, or one that forwards the Cookie header it was handed rather than recomputing per host, gives a different answer again.",
      "related": [
        "rfc6265bis-22 §5.1.3 (domain-matching) and §5.7 (storage model): a cookie with no Domain attribute is host-only and must not travel; a __Host- cookie is locked to the host that set it."
      ]
    }
  }
}
