Two advisories this week describe the same bug in different clothes. Apprise, the Python notification library, resent users' API keys and custom auth headers to whatever host a server redirected it to (CVE-2026-59180, fixed in 1.11.0). Dio's HTTP/2 adapter for Dart and Flutter did the same with Authorization, Cookie and Proxy-Authorization (CVE-2026-103030, fixed in dio_http2_adapter 2.8.0). In both cases a server the app already trusted could send one 302 and collect the credentials.
Most teams assume their HTTP client handles this. So we tested nine common clients against a local redirect lab, with four kinds of secret header and two kinds of redirect. Every one of them forwarded a custom API key header to a different host. This guide is for engineers who call APIs with secrets in headers. It covers what each client strips by default, why custom headers are the gap, a fix you can drop into Python code, and the lab, so you can test your own stack.
The short answer
HTTP clients strip only a fixed list of headers when a redirect leaves the original server, and the list differs by client. In our tests on 17 October 2026, all nine clients resent a custom X-Api-Key header to a new host. Python's urllib.request and wget resent Authorization and Cookie too. Go's default client kept them when only the port changed, and httpx and curl kept Proxy-Authorization. If you send secrets in headers, turn off automatic redirects for those calls, or strip every secret header yourself when the scheme, host or port changes.
What the two advisories found
Apprise (CVE-2026-59180). Apprise sends notifications to dozens of services. Its plugins and its HTTP attachment and config loaders called requests with the library's default, which follows redirects, and passed secrets in headers=. requests drops Authorization when the host changes, but it has no way to know that X-Gotify-Key or a user-defined +Header= value is a secret. The advisory, reported by gistrec, notes the attacker doesn't need to control the Apprise URL, only a response from a destination the operator already trusts, for example after a service takeover. It affects versions up to 1.10.0 and was fixed in 1.11.0, released 29 May 2026. GitHub's database added it on 14 October.
Dio (CVE-2026-103030). When Dio uses Http2Adapter, the adapter followed Location headers by copying the request options with the full original header map, and did no origin check. followRedirects is on by default, so any cross-origin 302 delivered Authorization, Cookie and Proxy-Authorization verbatim. Versions 1.0.0 to 2.7.1 of dio_http2_adapter are affected, and 2.8.0 is fixed. GitHub rates it 7.5 (high). Credit to EQSTLab and useworld.
Neither is new as a class. requests fixed its own Authorization leak in 2018 (CVE-2018-18074), urllib3 fixed Proxy-Authorization in 2024 (CVE-2024-37891), and NVD lists a wget Authorization issue, CVE-2021-31879. What's changed is where the secrets live: more and more APIs take keys in custom headers that no client treats as sensitive.
What nine clients do by default
The lab runs two local servers. The first answers every request with a 302. One path redirects to the same host on a different port, and the other redirects to a different host name. The second server records which test headers arrive. Each client sends the same four placeholder secrets with default redirect settings (httpx needs follow_redirects=True, since it doesn't follow by default):
$ python3 redirect_lab.py serve &
$ ./run.sh && python3 redirect_lab.py report
client port change only host change
requests-2.34.2 X-Api-Key X-Api-Key
httpx-0.28.1 Proxy-Authorization, X-Api-Key Proxy-Authorization, X-Api-Key
urllib3-2.8.0 X-Api-Key X-Api-Key
urllib.request-3.14 Authorization, Cookie, Proxy-Authorization, X-Api-Key Authorization, Cookie, Proxy-Authorization, X-Api-Key
aiohttp-3.14.3 X-Api-Key X-Api-Key
node-fetch-v26.7.0 X-Api-Key X-Api-Key
go-go1.27.1 Authorization, Cookie, Proxy-Authorization, X-Api-Key X-Api-Key
curl-8.7.1-L Proxy-Authorization, X-Api-Key Proxy-Authorization, X-Api-Key
wget-1.25.0 Authorization, Cookie, Proxy-Authorization, X-Api-Key Authorization, Cookie, Proxy-Authorization, X-Api-Key
requests+SameOriginSecrets none none
What the table shows:
X-Api-Keyleaked everywhere. No client can know a custom header is a secret, so none strips it. This is the Apprise bug, and it's the default behavior of every client we tried.urllib.requestforwards everything. Its redirect handler copies all request headers exceptContent-LengthandContent-Type. Code built on the standard library alone has no protection.- Go checks the host name, not the port.
net/httpstripsAuthorization,Cookie,Proxy-Authorizationand the related headers only when the new host isn't the same domain or a subdomain. A redirect to another port on the same host keeps them, and so does a redirect to any subdomain. httpxandcurlkeepProxy-Authorization.httpxstripsAuthorizationon an origin change and always rebuildsCookiefrom its jar.curltreats a-H "Proxy-Authorization: ..."header like any other custom header.wget1.25.0 kept all four when they were passed with--header. NVD's entry for CVE-2021-31879 lists versions through 1.21.1. We didn't test credentials passed with--userand--password.requests, urllib3, aiohttp and Node'sfetchstrip the three standard headers on both kinds of change.
These are defaults. Most clients let you change them, and some are documented: Go's Client documentation describes the domain rule, and curl --location-trusted turns its protection off. The point is that the default you inherit differs from library to library, and none of them covers a header you invented.
Fix it in your code
The safest fix for calls that carry secrets is not to follow redirects automatically. An API you call with a key rarely needs to redirect you, and a redirect you didn't expect is worth an error, not a retry. In requests that's allow_redirects=False. In httpx it's the default. In Go, set CheckRedirect to return http.ErrUseLastResponse. In Node, pass redirect: "manual" or redirect: "error".
Where you do need redirects, strip every secret header yourself when the origin changes. Origin means scheme, host and port together, not the host name alone. For requests, override rebuild_auth, which runs on every redirect:
"""A requests Session that drops every secret header when a redirect changes origin."""
from urllib.parse import urlsplit
import requests
SECRET_HEADERS = {"authorization", "cookie", "proxy-authorization", "x-api-key"} # add yours
def origin(url):
u = urlsplit(url)
return u.scheme, u.hostname, u.port or {"http": 80, "https": 443}[u.scheme]
class SameOriginSecrets(requests.Session):
def rebuild_auth(self, prepared_request, response):
if origin(prepared_request.url) != origin(response.request.url):
for h in list(prepared_request.headers):
if h.lower() in SECRET_HEADERS:
del prepared_request.headers[h]
super().rebuild_auth(prepared_request, response)
if __name__ == "__main__":
H = {"Authorization": "Bearer PLACEHOLDER", "Cookie": "session=PLACEHOLDER",
"Proxy-Authorization": "Basic UExBQ0VIT0xERVI=", "X-Api-Key": "PLACEHOLDER"}
s = SameOriginSecrets()
for case in ("same-host", "other-host"):
s.get(f"http://127.0.0.1:18081/{case}/requests+SameOriginSecrets", headers=H)
The last row of the table above is this session: nothing reached the other port or the other host. Keep SECRET_HEADERS next to the code that adds the headers, so a new key header doesn't skip the list. For the other clients the hook differs: CheckRedirect in Go, a manual loop in Node, an event hook or manual loop in httpx. The rule is the same.
Test your own stack
Copy the lab and add the client your services use. The full lab is three short files, all local. The servers listen on 127.0.0.1:18081 and port 18082, and nothing leaves the machine:
#!/usr/bin/env python3
"""Local lab: which secret headers does an HTTP client resend after a redirect?
Starts an origin on 127.0.0.1:18081 and a target on port 18082 (all interfaces,
IPv4 and IPv6). /same-host redirects to 127.0.0.1:18082 (only the port changes),
/other-host redirects to localhost:18082 (the host name changes). The target
records which of the test headers arrived. Placeholder secrets only.
Usage: redirect_lab.py serve | redirect_lab.py report
"""
import json, socket, sys, threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
LOG = Path(__file__).with_name("received.jsonl")
SECRETS = {"Authorization": "Bearer PLACEHOLDER", "Cookie": "session=PLACEHOLDER",
"Proxy-Authorization": "Basic UExBQ0VIT0xERVI=", "X-Api-Key": "PLACEHOLDER"}
class Origin(BaseHTTPRequestHandler):
def do_GET(self):
path, _, client = self.path.lstrip("/").partition("/")
host = {"same-host": "127.0.0.1", "other-host": "localhost"}[path]
self.send_response(302)
self.send_header("Location", f"http://{host}:18082/{path}/{client}")
self.send_header("Content-Length", "0")
self.end_headers()
def log_message(self, *a):
pass
class Target(BaseHTTPRequestHandler):
def do_GET(self):
_, case, client = self.path.split("/", 2)
got = sorted(h for h in SECRETS if self.headers.get(h))
with LOG.open("a") as f:
f.write(json.dumps({"client": client, "case": case, "leaked": got}) + "\n")
self.send_response(200)
self.send_header("Content-Length", "2")
self.end_headers()
self.wfile.write(b"ok")
def log_message(self, *a):
pass
class DualStack(ThreadingHTTPServer):
address_family = socket.AF_INET6
def server_bind(self):
self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
super().server_bind()
def serve():
LOG.write_text("")
threading.Thread(target=DualStack(("::", 18082), Target).serve_forever, daemon=True).start()
ThreadingHTTPServer(("127.0.0.1", 18081), Origin).serve_forever()
def report():
rows = {}
for line in LOG.read_text().splitlines():
r = json.loads(line)
rows.setdefault(r["client"], {})[r["case"]] = r["leaked"]
print(f"{'client':28} {'port change only':55} {'host change'}")
for client, cases in rows.items():
fmt = lambda c: ", ".join(cases.get(c, ["(no request)"])) or "none"
print(f"{client:28} {fmt('same-host'):55} {fmt('other-host')}")
if __name__ == "__main__":
serve() if sys.argv[1:] == ["serve"] else report()
"""Send the four placeholder secrets through each Python client, default redirect settings."""
import asyncio, urllib.request
import aiohttp, httpx, requests, urllib3
H = {"Authorization": "Bearer PLACEHOLDER", "Cookie": "session=PLACEHOLDER",
"Proxy-Authorization": "Basic UExBQ0VIT0xERVI=", "X-Api-Key": "PLACEHOLDER"}
BASE = "http://127.0.0.1:18081"
async def aio(url):
async with aiohttp.ClientSession() as s:
async with s.get(url, headers=H) as r:
await r.read()
for case in ("same-host", "other-host"):
requests.get(f"{BASE}/{case}/requests-{requests.__version__}", headers=H)
httpx.get(f"{BASE}/{case}/httpx-{httpx.__version__}", headers=H, follow_redirects=True)
urllib3.request("GET", f"{BASE}/{case}/urllib3-{urllib3.__version__}", headers=H)
urllib.request.urlopen(urllib.request.Request(f"{BASE}/{case}/urllib.request-3.14", headers=H)).read()
asyncio.run(aio(f"{BASE}/{case}/aiohttp-{aiohttp.__version__}"))
#!/bin/sh
# Run every client against the lab (start redirect_lab.py serve first).
cd "$(dirname "$0")"
H1='Authorization: Bearer PLACEHOLDER'; H2='Cookie: session=PLACEHOLDER'
H3='Proxy-Authorization: Basic UExBQ0VIT0xERVI='; H4='X-Api-Key: PLACEHOLDER'
CV=$(curl --version | awk 'NR==1{print $2}'); WV=$(wget --version | awk 'NR==1{print $3}')
python3 clients.py
node client.mjs
(cd goclient && go run .)
for c in same-host other-host; do
curl -s -L -H "$H1" -H "$H2" -H "$H3" -H "$H4" "http://127.0.0.1:18081/$c/curl-$CV-L" >/dev/null
wget -q -O /dev/null --header="$H1" --header="$H2" --header="$H3" --header="$H4" "http://127.0.0.1:18081/$c/wget-$WV"
done
The Node and Go clients are a few lines each and follow the same pattern: send the four headers to both paths with default settings. To test your own code rather than a raw library, point a staging config at the lab's origin and read received.jsonl. Anything other than "none" in the host-change column is a header your code will hand to whoever controls a redirect.
What to do
- Upgrade the two libraries. Apprise to 1.11.0 or later, and
dio_http2_adapterto 2.8.0 or later. - Find calls that send secrets in headers and follow redirects. Search for API key headers next to
requests,urllib.request,http.DefaultClient,fetchand shelled-outcurl -Lorwget. Each one is a candidate. - Turn off redirects on those calls, or strip by origin as shown above.
- Don't rely on
Authorizationbeing special. If an API offers a choice,Authorizationgets more protection from clients than a custom header. If it doesn't, your code has to supply that protection. - Treat a port change as a new origin. Go and some proxies don't, and internal services often differ only by port.
For JavaScript, our guide to follow-redirects vulnerabilities covers the npm package many Node clients rely on, and our post on the 2018 requests Authorization leak shows how long this class has been around.
The fair view
The client authors aren't wrong to keep the list short. A client that stripped every header on redirect would break proxies, content negotiation and tracing. And many redirects are legitimate: a service moves, a CDN fronts an API, HTTP upgrades to HTTPS. The fetch standard, which Node follows, removes Authorization on cross-origin redirects for the same reason: it's the one header everyone agrees is a credential.
But the result is that the security of a header depends on its name and on which library sends it. Apprise and Dio are useful reminders that "the HTTP client handles redirects" is a guess, not a guarantee. The lab takes a few minutes to run, and it replaces the guess with a table.