fix(cli): re-validate SSRF denylist on redirects + harden isPrivateUrl (#1212)

## Summary

- Adds `safeFetch`, a redirect-aware wrapper around `fetch` that re-runs the SSRF denylist on every hop before following a redirect.
- Routes `fetchBuffer` and the Lottie media fetch through `safeFetch` so redirect chains can't bounce through a public URL to reach an internal or cloud-metadata host.
- Hardens `isPrivateUrl` to also block `0.0.0.0` / `0.0.0.0/8`, IPv6 loopback (`::1`), IPv4-mapped (`::ffff:…`), unique-local (`fc00::/7`), and link-local (`fe80::/10`) ranges.

## Security

**F-002 MED** — `fetchBuffer` followed redirects without re-checking the denylist on the destination. A `30x` redirect from an allowlisted public URL to `169.254.169.254` or an internal host would succeed, leaking the response to the caller (e.g. captured page assets written to local disk).

**F-003 MED** — `isPrivateUrl` did not cover `0.0.0.0` (maps to localhost on most OSes), IPv6 loopback, or IPv6 private ranges. An asset URL using those addresses would bypass the denylist. Alternate IPv4 encodings (decimal/octal/hex) are already normalized to dotted-quad by WHATWG URL parsing and remain blocked.

## Test plan

- [x] Unit tests cover redirect-chain blocking (redirect to metadata IP rejected)
- [x] Unit tests cover new `isPrivateUrl` address forms (`0.0.0.0`, `::1`, `fc00::1`, `fe80::1`, `::ffff:192.168.1.1`)
- [x] Existing fetch and asset-download tests pass
This commit is contained in:
Vance Ingalls
2026-06-05 17:01:00 -07:00
committed by GitHub
parent bacfb17538
commit 1f37920fe1
4 changed files with 182 additions and 26 deletions
+4 -6
View File
@@ -10,7 +10,7 @@
import type { Browser, Page } from "puppeteer-core";
import { mkdirSync, writeFileSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { isPrivateUrl } from "./assetDownloader.js";
import { safeFetch } from "./assetDownloader.js";
/** Discovered Lottie item from network interception or DOM scan. */
export interface DiscoveredLottie {
@@ -42,14 +42,12 @@ export async function saveLottieAnimations(
// Already have the JSON data from network interception
jsonData = JSON.stringify(lottieItem.data);
} else if (lottieItem.url) {
// SSRF guard — don't fetch private/internal URLs
if (isPrivateUrl(lottieItem.url)) continue;
// Download the file
const res = await fetch(lottieItem.url, {
// SSRF guard — safeFetch re-checks the denylist on every redirect hop
const res = await safeFetch(lottieItem.url, {
signal: AbortSignal.timeout(10000),
headers: { "User-Agent": "HyperFrames/1.0" },
});
if (!res.ok) continue;
if (!res || !res.ok) continue;
const buf = Buffer.from(await res.arrayBuffer());
if (lottieItem.url.endsWith(".lottie")) {