mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(capture): share the render host policy and block localhost aliases (#3844)
This commit is contained in:
@@ -22,6 +22,11 @@ describe("isPrivateUrl — SSRF denylist (security: F-003)", () => {
|
||||
"http://172.16.0.1/",
|
||||
"http://192.168.1.1/",
|
||||
"http://169.254.169.254/", // cloud metadata
|
||||
"http://100.64.0.1/",
|
||||
"http://192.0.0.1/",
|
||||
"http://198.18.0.1/",
|
||||
"http://224.0.0.1/",
|
||||
"http://240.0.0.1/",
|
||||
]) {
|
||||
expect(isPrivateUrl(u), u).toBe(true);
|
||||
}
|
||||
@@ -38,6 +43,8 @@ describe("isPrivateUrl — SSRF denylist (security: F-003)", () => {
|
||||
"http://[::ffff:169.254.169.254]/", // IPv4-mapped metadata
|
||||
"http://[fd00::1]/", // unique-local fc00::/7
|
||||
"http://[fe80::1]/", // link-local fe80::/10
|
||||
"http://[fec0::1]/",
|
||||
"http://[ff02::1]/",
|
||||
]) {
|
||||
expect(isPrivateUrl(u), u).toBe(true);
|
||||
}
|
||||
@@ -56,6 +63,8 @@ describe("isPrivateUrl — SSRF denylist (security: F-003)", () => {
|
||||
|
||||
it("allows ordinary public URLs", () => {
|
||||
expect(isPrivateUrl("https://example.com/logo.png")).toBe(false);
|
||||
expect(isPrivateUrl("http://example.com./logo.png")).toBe(false);
|
||||
expect(isPrivateUrl("http://notlocalhost.example/logo.png")).toBe(false);
|
||||
expect(isPrivateUrl("https://cdn.jsdelivr.net/a.svg")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -96,6 +105,31 @@ describe("safeFetch — re-validates the denylist on every redirect hop (securit
|
||||
expect(await res?.text()).toBe("ok");
|
||||
});
|
||||
|
||||
it("blocks localhost and internal DNS aliases initially and after redirects", async () => {
|
||||
for (const host of [
|
||||
"localhost.",
|
||||
"foo.localhost",
|
||||
"FOO.LOCALHOST.",
|
||||
"db.internal.",
|
||||
"svc.local.",
|
||||
]) {
|
||||
const target = `http://${host}/private`;
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: target },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
expect(isPrivateUrl(target), target).toBe(true);
|
||||
expect(await safeFetch(target), target).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(await safeFetch("https://public.example/asset"), target).toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null when the initial URL is private", async () => {
|
||||
const fetchMock = vi.fn(async () => new Response("ok"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* resolution) as the single source of truth for images. Favicon links are passed separately.
|
||||
*/
|
||||
|
||||
import { isBlockedNetworkHost } from "@hyperframes/engine";
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join, extname } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
@@ -626,61 +627,17 @@ export async function downloadAndRewriteFonts(
|
||||
return { css: rewritten, drops };
|
||||
}
|
||||
|
||||
// Reserved/loopback/private IPv4 blocks as [firstOctet, secondOctetLo, secondOctetHi].
|
||||
const PRIVATE_V4_BLOCKS: ReadonlyArray<readonly [number, number, number]> = [
|
||||
[0, 0, 255], // 0.0.0.0/8 (incl. 0.0.0.0, which routes to localhost)
|
||||
[10, 0, 255], // 10.0.0.0/8
|
||||
[127, 0, 255], // 127.0.0.0/8 loopback
|
||||
[172, 16, 31], // 172.16.0.0/12
|
||||
[192, 168, 168], // 192.168.0.0/16
|
||||
[169, 254, 254], // 169.254.0.0/16 link-local (cloud metadata)
|
||||
];
|
||||
|
||||
/** True for a dotted-quad IPv4 literal in a loopback/private/reserved range. */
|
||||
function isPrivateIpv4(host: string): boolean {
|
||||
const octets = host.split(".").map(Number);
|
||||
if (octets.length !== 4) return false;
|
||||
const [a, b] = octets as [number, number, number, number];
|
||||
return PRIVATE_V4_BLOCKS.some(([first, lo, hi]) => a === first && b >= lo && b <= hi);
|
||||
}
|
||||
|
||||
/** True for a bracketed IPv6 hostname in a loopback/private/reserved range. */
|
||||
function isPrivateIpv6(bracketed: string): boolean {
|
||||
const addr = bracketed.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
if (addr === "::1" || addr === "::") return true; // loopback / unspecified
|
||||
const mapped = /^::ffff:(.+)$/.exec(addr); // IPv4-mapped ::ffff:a.b.c.d or ::ffff:hhhh:hhhh
|
||||
if (mapped) {
|
||||
const tail = mapped[1]!;
|
||||
if (tail.includes(".")) return isPrivateIpv4(tail);
|
||||
const hex = tail.split(":");
|
||||
if (hex.length === 2) {
|
||||
const n = ((parseInt(hex[0]!, 16) << 16) | parseInt(hex[1]!, 16)) >>> 0;
|
||||
return isPrivateIpv4(
|
||||
[(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join("."),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (/^f[cd]/.test(addr)) return true; // fc00::/7 unique-local
|
||||
if (/^fe[89ab]/.test(addr)) return true; // fe80::/10 link-local
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block requests to private/internal hosts to prevent SSRF. WHATWG URL parsing
|
||||
* canonicalizes alternate IPv4 encodings (decimal/octal/hex) to dotted-quad
|
||||
* before we see them, so only dotted IPv4 and bracketed IPv6 literals reach the
|
||||
* classifiers below.
|
||||
* shared engine classifier.
|
||||
*/
|
||||
export function isPrivateUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") return true; // no file:, etc.
|
||||
const hostname = u.hostname;
|
||||
if (hostname === "localhost") return true;
|
||||
if (hostname.endsWith(".internal") || hostname.endsWith(".local")) return true;
|
||||
if (hostname.startsWith("[")) return isPrivateIpv6(hostname);
|
||||
if (/^\d+(\.\d+){3}$/.test(hostname)) return isPrivateIpv4(hostname);
|
||||
return false;
|
||||
return isBlockedNetworkHost(u.hostname);
|
||||
} catch {
|
||||
return true; // reject unparseable URLs
|
||||
}
|
||||
|
||||
@@ -301,6 +301,7 @@ export {
|
||||
|
||||
export {
|
||||
assertPublicHttpsUrl,
|
||||
isBlockedNetworkHost,
|
||||
downloadToTemp,
|
||||
fetchPublicHttpsText,
|
||||
isHttpUrl,
|
||||
|
||||
@@ -155,6 +155,19 @@ describe("assertPublicHttpsUrl — SSRF guard", () => {
|
||||
expect(() => assertPublicHttpsUrl("http://localhost:3000/secret")).toThrow();
|
||||
});
|
||||
|
||||
it("rejects localhost aliases and local/internal suffixes", () => {
|
||||
for (const host of [
|
||||
"localhost.",
|
||||
"foo.localhost",
|
||||
"FOO.LOCALHOST.",
|
||||
"svc.local.",
|
||||
"db.internal.",
|
||||
]) {
|
||||
expect(() => assertPublicHttpsUrl(`https://${host}/asset`), host).toThrow("private/reserved");
|
||||
}
|
||||
expect(() => assertPublicHttpsUrl("https://example.com./asset")).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects RFC1918 — 10.x", () => {
|
||||
expect(() => assertPublicHttpsUrl("https://10.0.0.1/secret")).toThrow("private/reserved");
|
||||
expect(() => assertPublicHttpsUrl("https://10.255.255.255/secret")).toThrow("private/reserved");
|
||||
|
||||
@@ -226,9 +226,19 @@ for (const [network, prefix] of [
|
||||
NON_PUBLIC_IPV6_ADDRESSES.addSubnet(network, prefix, "ipv6");
|
||||
}
|
||||
|
||||
function isBlockedHost(hostname: string): boolean {
|
||||
const h = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
||||
if (h === "localhost") return true;
|
||||
/** Literal-host policy shared by rendering and capture; DNS resolution remains trusted. */
|
||||
export function isBlockedNetworkHost(hostname: string): boolean {
|
||||
const h = hostname
|
||||
.toLowerCase()
|
||||
.replace(/\.$/, "")
|
||||
.replace(/^\[|\]$/g, "");
|
||||
if (
|
||||
h === "localhost" ||
|
||||
h.endsWith(".localhost") ||
|
||||
h.endsWith(".local") ||
|
||||
h.endsWith(".internal")
|
||||
)
|
||||
return true;
|
||||
const addressType = isIP(h);
|
||||
if (addressType === 0) return false;
|
||||
return addressType === 4
|
||||
@@ -251,7 +261,7 @@ export function assertPublicHttpsUrl(url: string): void {
|
||||
if (parsed.protocol !== "https:") {
|
||||
throw new Error(`[URLDownloader] Only HTTPS URLs are permitted in compositions`);
|
||||
}
|
||||
if (isBlockedHost(parsed.hostname)) {
|
||||
if (isBlockedNetworkHost(parsed.hostname)) {
|
||||
throw new Error("[URLDownloader] URL targets a private/reserved address and is not permitted");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user