{
  "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 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 it is in no client’s sensitive-header list. The stripping rules protect three header names; the API key scheme most services actually use is not one of them, so it crosses every boundary here. That is worth knowing before you rely on it.",
    "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-10",
    "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 1.27 net/http",
      "Node 26 fetch (undici)",
      "python urllib 3.14 (stdlib)",
      "requests 2.34.2",
      "httpx 0.28.1",
      "urllib3 2.7.0",
      "aiohttp 3.14.3"
    ],
    "findings": [
      "X-Api-Key arrived intact on every flavor for all eight clients — 64 of 64 observations. The stripping rules in every one of these clients protect three header names (Authorization, Proxy-Authorization, Cookie); the API-key convention most services actually use is not among them, so it crosses every boundary in this family.",
      "Go net/http forwarded Authorization and Cookie across every boundary here EXCEPT subdomain-to-apex. 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.",
      "The sharpest consequence of that last clause, measured on scheme-downgrade: Go carried the credential across an https-to-http redirect on the same host and put it on the wire in cleartext, and the landing response confirms it with transport_was_encrypted: false. Nothing in Go is malfunctioning — the comparison it documents is a hostname comparison, and a scheme change does not change the hostname.",
      "boomerang ends with the credential present for both curl and Go, for opposite reasons, which is why the pair of directional flavors matters: curl DROPPED it at the subdomain and regenerated it on return, while Go never dropped it at all because hop one was a subdomain match. Only from-subdomain separates those two stories.",
      "python urllib (stdlib) forwarded Authorization and Cookie on all eight 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. It compares each hop against the ORIGINAL origin and regenerates the header rather than carrying a mutated map, so a chain that leaves badhttp.dev and returns to it arrives with the credential it had dropped in between. Every other client that stripped on the way out stayed stripped on the way back.",
      "requests and httpx kept Authorization on scheme-upgrade (http to https, same host, default ports) where urllib3 and aiohttp stripped it. Both carry a carve-out their own source comments describe as backwards compatibility rather than a security judgement.",
      "requests and httpx dropped an explicitly-set Cookie header on EVERY redirect, including the same-origin control, because they clear it before any origin comparison and re-derive from their jar."
    ],
    "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 how many hops it followed, which is 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."
  },
  "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."
      ]
    }
  }
}
