This is a valet handing your API key to the next parking lot because the first one pointed across the street
CVE-2025-21620 is a cross-origin redirect credential leak in Deno fetch(). If a Deno app sends an Authorization header to one origin and that origin responds with a redirect to a different origin, vulnerable Deno versions can carry the same Authorization header into the follow-up request. GitHub lists affected Deno versions as <1.46.4 and <2.1.2; the clearly documented fix line is 2.1.2, and the advisory also states the lower-level deno_fetch crate is fixed in 0.204.0.
The vendor's 7.5/HIGH score overstates enterprise urgency. This is not a direct pre-auth exploit against an exposed listener; it is a conditional secret-leak bug that only matters when your application performs authenticated outbound fetch() calls, follows redirects, and can be steered into an attacker-controlled or attacker-compromised redirector. That's meaningful, but it is not the same operational class as internet-reachable RCE.
4 steps from start to impact.
Get into the outbound request path
fetch() request. In practice that means controlling the initial URL, compromising the upstream service, or abusing an application feature that proxies or forwards requests. The weaponized primitive is Deno's own fetch() redirect handling as described in the GitHub advisory.- The target app uses Deno
fetch()for outbound HTTP(S) requests - The request includes an
Authorizationheader - The attacker can control the target URL or the first-hop server response
- Many Deno workloads never send bearer tokens to attacker-influenced URLs
- Well-designed integrations hardcode upstream hosts instead of taking user-supplied URLs
- Egress filtering or
--allow-nethost restrictions can block arbitrary destinations
Force a cross-origin redirect
30x redirect to a different origin, such as a second server they control. Vulnerable Deno follows the redirect and incorrectly preserves the original Authorization header. The advisory's reproduction uses Deno.serve() on two local ports to demonstrate the leak.- The application allows redirects or does not set
redirect: "manual" - The first-hop origin can emit a cross-origin redirect
- Some applications disable redirects for authenticated calls
- Some upstream APIs never redirect, so the bug is dead code in those paths
- Middleware or custom wrappers may already strip sensitive headers on redirect
Capture the leaked secret
Authorization header. This is confidentiality impact only; the bug itself does not modify application state or crash the runtime. The practical blast radius equals the value and scope of the leaked credential.- The redirected destination is attacker-controlled or monitored by the attacker
- The leaked credential is still valid and useful
- Short-lived tokens, audience-bound tokens, or mTLS-backed auth reduce reuse value
- Some tokens only authorize narrow API actions
- Credential rotation and anomaly detection may contain follow-on abuse
Attempt downstream abuse
- The leaked token grants access to a reachable downstream service
- The downstream service does not enforce stronger binding than possession of the token
- Scoped OAuth tokens, IP allowlists, or proof-of-possession designs can blunt reuse
- Mature cloud/API monitoring can catch unusual token use quickly
- Many service tokens are low-value or environment-specific
The supporting signals.
| In-the-wild status | No evidence surfaced in reviewed sources that CVE-2025-21620 is being exploited in the wild. It is not in CISA KEV. |
|---|---|
| Proof-of-concept availability | Public reproduction is available in the GitHub advisory itself using Deno.serve() plus fetch() redirect behavior. I did not find a separate weaponized exploit repo in reviewed primary sources. |
| EPSS | User-supplied EPSS is 0.00263, which is very low and consistent with a bug that needs a narrow application workflow rather than broad internet exposure. |
| KEV status | Not listed in the CISA KEV catalog. |
| CVSS vector reality check | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N is technically defensible for the leak event, but it hides the real prerequisite: the attacker must get into a token-bearing outbound request path first. |
| Affected versions | GitHub marks Deno as affected in versions <1.46.4 and <2.1.2; the advisory also lists cargo deno_fetch as affected until 0.204.0. |
| Fixed versions | Clearly documented fix: Deno 2.1.2. The GHSA page also references a 1.46.4 boundary, but I did not find a public v1.46.4 release page in reviewed sources, so operationally anchor on 2.1.2+ or vendor-confirmed downstream backports. |
| Scanning / exposure data | There is no meaningful internet-wide banner/signature story here. This is a runtime behavior issue, so Shodan/Censys-style exposure counts are not a useful prioritization input; SCA, SBOM, and endpoint inventory are. |
| Disclosure | Published 2025-01-06 by GitHub CNA; CISA included it in the weekly bulletin released 2025-01-13. |
| Reporter | Credited reporter in the GitHub advisory: rexxars. |
noisgate verdict.
The decisive downgrade factor is attacker position: this is not a straight shot from the internet into a listening Deno service, but a leak that requires the attacker to land inside a very specific authenticated outbound request flow. That sharply reduces exposed population compared with the vendor's CVSS baseline, even though leaked bearer tokens can still hurt when the application pattern exists.
Why this verdict
- Downgrade for attacker position: exploitation requires influence over an authenticated outbound
fetch()path, not merely network reachability to a Deno host - Downgrade for exposure population: only apps that both send sensitive
Authorizationheaders and follow cross-origin redirects are actually exposed - Downgrade for security friction:
--allow-netscoping, fixed upstream hostnames, egress controls, and manual redirect handling break many real deployments - Severity floor stays at MEDIUM: if the leaked credential is a real bearer/API token, downstream compromise can be immediate even though the CVE itself is only a confidentiality bug
Why not higher?
There is no code execution, no integrity impact, and no availability impact in the vulnerability itself. More importantly, the exploit chain assumes a prior foothold into the application's request-routing logic or a compromised/attacker-controlled upstream redirector, which is a major real-world narrowing compared with classic pre-auth remote bugs.
Why not lower?
A leaked Authorization header is not cosmetic. In service-to-service environments, those headers often carry bearer tokens, PATs, or API keys that can immediately unlock downstream data or tenant actions, so this is more than backlog trivia when the vulnerable call pattern exists.
What to do — in priority order.
- Disable redirect following on authenticated fetches — For code paths that attach
Authorization, set redirect handling to manual or explicitly re-issue redirected requests only after stripping sensitive headers. There is no mitigation SLA for a MEDIUM verdict, so use this where patching will be delayed and otherwise go straight to the normal remediation window. - Constrain outbound destinations — Use Deno
--allow-nethost scoping plus network egress policy so the runtime cannot talk to arbitrary attacker-chosen hosts. This reduces the reachable population of the bug and is worth enforcing as a hardening baseline even though there is no separate noisgate mitigation deadline for MEDIUM. - Inventory token-bearing integrations — Find Deno services that send bearer tokens or API keys with
fetch()to third-party APIs, especially any path influenced by user-supplied URLs or webhook targets. Those are the only systems that deserve priority inside the 365-day remediation window. - Rotate exposed high-value tokens if suspicious redirects are found — If proxy, API gateway, or app telemetry shows unexpected cross-origin 30x behavior on authenticated calls, treat the credential as exposed and rotate it immediately. This is incident handling, not routine patch prioritization.
MFAdoes not help because the leaked artifact is typically a bearer/API token used non-interactivelyWAF rulesdo not meaningfully solve this because the vulnerable behavior happens in the application's outbound HTTP client, not at the inbound web edgeTLS everywhereis not sufficient because the problem is header forwarding across origins, not transport interception
Crowdsourced verification payload.
Run this on the target host that has Deno installed. Invoke it as python3 verify_cve_2025_21620.py /path/to/deno or just python3 verify_cve_2025_21620.py if deno is on PATH; no elevated privileges are required, and the script only opens loopback ports 3001 and 3002 for a local functional test.
#!/usr/bin/env python3
# verify_cve_2025_21620.py
# Functional verifier for CVE-2025-21620 (Deno fetch Authorization leak on cross-origin redirect)
# Exit codes: 0=PATCHED, 1=VULNERABLE, 2=UNKNOWN
import os
import re
import shutil
import subprocess
import sys
import tempfile
EXIT_PATCHED = 0
EXIT_VULNERABLE = 1
EXIT_UNKNOWN = 2
def out(msg):
print(msg)
def find_deno(arg_path=None):
if arg_path:
return arg_path
return shutil.which("deno")
def parse_version(text):
m = re.search(r"deno\s+(\d+)\.(\d+)\.(\d+)", text)
if not m:
return None
return tuple(int(x) for x in m.groups())
def semver_lt(a, b):
return a < b
def version_based_fallback(ver):
if ver is None:
return None
major, minor, patch = ver
if major == 1:
return "VULNERABLE" if semver_lt(ver, (1, 46, 4)) else "PATCHED"
if major == 2:
return "VULNERABLE" if semver_lt(ver, (2, 1, 2)) else "PATCHED"
if major > 2:
return "PATCHED"
return None
def get_version(deno):
try:
cp = subprocess.run([deno, "--version"], capture_output=True, text=True, timeout=10)
text = (cp.stdout or "") + "\n" + (cp.stderr or "")
return parse_version(text), text.strip()
except Exception:
return None, ""
def functional_test(deno):
js = r'''
const ac = new AbortController();
const server1 = Deno.serve({ hostname: "127.0.0.1", port: 3001, signal: ac.signal }, (_req) => {
return new Response(null, {
status: 302,
headers: { "location": "http://127.0.0.1:3002/redirected" },
});
});
const server2 = Deno.serve({ hostname: "127.0.0.1", port: 3002, signal: ac.signal }, (req) => {
const body = JSON.stringify({
url: req.url,
hasAuth: req.headers.has("authorization"),
authValue: req.headers.get("authorization") || "",
});
return new Response(body, {
status: 200,
headers: { "content-type": "application/json" },
});
});
async function main() {
await new Promise((r) => setTimeout(r, 300));
try {
const response = await fetch("http://127.0.0.1:3001/", {
headers: { authorization: "Bearer noisgate-test-token" },
});
const body = await response.json();
console.log(JSON.stringify(body));
} finally {
ac.abort();
setTimeout(() => Deno.exit(0), 50);
}
}
main();
'''
with tempfile.TemporaryDirectory() as td:
path = os.path.join(td, "cve_2025_21620_check.ts")
with open(path, "w", encoding="utf-8") as f:
f.write(js)
try:
cp = subprocess.run(
[deno, "run", "--quiet", "--allow-net=127.0.0.1:3001,127.0.0.1:3002", path],
capture_output=True,
text=True,
timeout=20,
)
except Exception as e:
return None, f"functional test execution failed: {e}"
stdout = (cp.stdout or "").strip()
stderr = (cp.stderr or "").strip()
if cp.returncode != 0 and not stdout:
return None, f"functional test failed rc={cp.returncode} stderr={stderr}"
m = re.search(r'\{.*"hasAuth"\s*:\s*(true|false).*\}', stdout, re.S)
if not m:
return None, f"unable to parse functional test output: {stdout or stderr}"
leaked = m.group(1) == "true"
return ("VULNERABLE" if leaked else "PATCHED"), stdout
def main():
deno = find_deno(sys.argv[1] if len(sys.argv) > 1 else None)
if not deno:
out("UNKNOWN: deno binary not found")
sys.exit(EXIT_UNKNOWN)
ver, raw_ver = get_version(deno)
func_result, detail = functional_test(deno)
if func_result == "VULNERABLE":
out(f"VULNERABLE: functional test reproduced Authorization header leak ({raw_ver})")
sys.exit(EXIT_VULNERABLE)
if func_result == "PATCHED":
out(f"PATCHED: functional test did not leak Authorization header ({raw_ver})")
sys.exit(EXIT_PATCHED)
fallback = version_based_fallback(ver)
if fallback == "VULNERABLE":
out(f"VULNERABLE: functional test inconclusive, but installed version appears vulnerable ({raw_ver})")
sys.exit(EXIT_VULNERABLE)
if fallback == "PATCHED":
out(f"PATCHED: functional test inconclusive, but installed version is at/above known fixed baseline ({raw_ver})")
sys.exit(EXIT_PATCHED)
out(f"UNKNOWN: could not complete functional verification; detail={detail}; version={raw_ver}")
sys.exit(EXIT_UNKNOWN)
if __name__ == "__main__":
main()
If you remember one thing.
Sources
What defenders are saying.
Crowdsourced verification outputs.
Results submitted by users who ran the verification payload against their environment.