@hyperframes/engine 0.6.68 → 0.6.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate that a URL is safe to fetch on behalf of customer-supplied
|
|
3
|
+
* compositions. Throws if the URL is non-HTTPS or targets a private/reserved
|
|
4
|
+
* address range (SSRF guard).
|
|
5
|
+
*/
|
|
6
|
+
export declare function assertPublicHttpsUrl(url: string): void;
|
|
1
7
|
export declare function downloadToTemp(url: string, destDir: string, timeoutMs?: number): Promise<string>;
|
|
2
8
|
export declare function isHttpUrl(path: string): boolean;
|
|
3
9
|
//# sourceMappingURL=urlDownloader.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"urlDownloader.d.ts","sourceRoot":"","sources":["../../src/utils/urlDownloader.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"urlDownloader.d.ts","sourceRoot":"","sources":["../../src/utils/urlDownloader.ts"],"names":[],"mappings":"AAyCA;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAiBtD;AASD,wBAAsB,cAAc,CAClC,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,MAAM,EACf,SAAS,GAAE,MAAe,GACzB,OAAO,CAAC,MAAM,CAAC,CA8DjB;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAE/C"}
|
|
@@ -5,6 +5,59 @@ import { Readable } from "stream";
|
|
|
5
5
|
import { finished } from "stream/promises";
|
|
6
6
|
const downloadPathCache = new Map();
|
|
7
7
|
const inFlightDownloads = new Map();
|
|
8
|
+
// SSRF guard: these prefixes identify non-public address space that
|
|
9
|
+
// compositions (customer-supplied) must never be able to reach via the
|
|
10
|
+
// download path. Blocks AWS IMDS (169.254.169.254), loopback, RFC1918,
|
|
11
|
+
// and unspecified addresses. All comparisons are on the raw hostname
|
|
12
|
+
// string; DNS resolution is NOT performed here, so DNS-rebinding bypasses
|
|
13
|
+
// are not closed by this check — that gap is acceptable for the risk level.
|
|
14
|
+
const BLOCKED_HOST_PREFIXES = [
|
|
15
|
+
"169.254.", // link-local / AWS IMDS
|
|
16
|
+
"127.", // loopback IPv4
|
|
17
|
+
"10.", // RFC1918
|
|
18
|
+
"192.168.", // RFC1918
|
|
19
|
+
"0.", // unspecified
|
|
20
|
+
"[::1]", // loopback IPv6
|
|
21
|
+
"[fc", // RFC4193 unique-local IPv6
|
|
22
|
+
"[fd", // RFC4193 unique-local IPv6
|
|
23
|
+
];
|
|
24
|
+
// 172.16.0.0 – 172.31.255.255 (RFC1918)
|
|
25
|
+
const BLOCKED_172_RANGE = { min: 16, max: 31 };
|
|
26
|
+
function isBlockedHost(hostname) {
|
|
27
|
+
const h = hostname.toLowerCase();
|
|
28
|
+
if (h === "localhost")
|
|
29
|
+
return true;
|
|
30
|
+
if (BLOCKED_HOST_PREFIXES.some((p) => h.startsWith(p)))
|
|
31
|
+
return true;
|
|
32
|
+
// 172.16–172.31
|
|
33
|
+
const m = h.match(/^172\.(\d{1,3})\./);
|
|
34
|
+
if (m) {
|
|
35
|
+
const octet = parseInt(m[1] ?? "0", 10);
|
|
36
|
+
if (octet >= BLOCKED_172_RANGE.min && octet <= BLOCKED_172_RANGE.max)
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Validate that a URL is safe to fetch on behalf of customer-supplied
|
|
43
|
+
* compositions. Throws if the URL is non-HTTPS or targets a private/reserved
|
|
44
|
+
* address range (SSRF guard).
|
|
45
|
+
*/
|
|
46
|
+
export function assertPublicHttpsUrl(url) {
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = new URL(url);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
throw new Error(`[URLDownloader] Invalid URL: ${url}`);
|
|
53
|
+
}
|
|
54
|
+
if (parsed.protocol !== "https:") {
|
|
55
|
+
throw new Error(`[URLDownloader] Only HTTPS URLs are permitted in compositions (got ${parsed.protocol}): ${url}`);
|
|
56
|
+
}
|
|
57
|
+
if (isBlockedHost(parsed.hostname)) {
|
|
58
|
+
throw new Error(`[URLDownloader] URL targets a private/reserved address and is not permitted: ${url}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
8
61
|
function getFilenameFromUrl(url) {
|
|
9
62
|
const hash = createHash("md5").update(url).digest("hex").slice(0, 12);
|
|
10
63
|
const urlObj = new URL(url);
|
|
@@ -12,6 +65,10 @@ function getFilenameFromUrl(url) {
|
|
|
12
65
|
return `download_${hash}${ext}`;
|
|
13
66
|
}
|
|
14
67
|
export async function downloadToTemp(url, destDir, timeoutMs = 300000) {
|
|
68
|
+
// Reject non-HTTPS URLs and private/reserved address ranges before
|
|
69
|
+
// touching the cache or filesystem — customer-supplied compositions must
|
|
70
|
+
// not be able to trigger outbound fetches to internal infrastructure.
|
|
71
|
+
assertPublicHttpsUrl(url);
|
|
15
72
|
const cachedPath = downloadPathCache.get(url);
|
|
16
73
|
if (cachedPath && existsSync(cachedPath)) {
|
|
17
74
|
return cachedPath;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"urlDownloader.js","sourceRoot":"","sources":["../../src/utils/urlDownloader.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"urlDownloader.js","sourceRoot":"","sources":["../../src/utils/urlDownloader.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/engine",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.69",
|
|
4
4
|
"description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"linkedom": "^0.18.12",
|
|
22
22
|
"puppeteer": "^24.0.0",
|
|
23
23
|
"puppeteer-core": "^24.39.1",
|
|
24
|
-
"@hyperframes/core": "^0.6.
|
|
24
|
+
"@hyperframes/core": "^0.6.69"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "^25.0.10",
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { assertPublicHttpsUrl } from "./urlDownloader.js";
|
|
3
|
+
|
|
4
|
+
describe("assertPublicHttpsUrl — SSRF guard", () => {
|
|
5
|
+
it("accepts public HTTPS URLs", () => {
|
|
6
|
+
expect(() =>
|
|
7
|
+
assertPublicHttpsUrl("https://gen-os-static.s3.us-east-2.amazonaws.com/fonts/font.ttf"),
|
|
8
|
+
).not.toThrow();
|
|
9
|
+
expect(() => assertPublicHttpsUrl("https://cdn.jsdelivr.net/npm/gsap.min.js")).not.toThrow();
|
|
10
|
+
expect(() => assertPublicHttpsUrl("https://fonts.gstatic.com/s/font.woff2")).not.toThrow();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("rejects http:// (non-HTTPS)", () => {
|
|
14
|
+
expect(() => assertPublicHttpsUrl("http://example.com/font.ttf")).toThrow("Only HTTPS");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("rejects AWS IMDS (169.254.169.254)", () => {
|
|
18
|
+
expect(() =>
|
|
19
|
+
assertPublicHttpsUrl("https://169.254.169.254/latest/meta-data/iam/security-credentials/"),
|
|
20
|
+
).toThrow("private/reserved");
|
|
21
|
+
expect(() => assertPublicHttpsUrl("http://169.254.169.254/latest/user-data")).toThrow();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("rejects loopback (127.x.x.x)", () => {
|
|
25
|
+
expect(() => assertPublicHttpsUrl("https://127.0.0.1/font.ttf")).toThrow("private/reserved");
|
|
26
|
+
expect(() => assertPublicHttpsUrl("https://127.1.2.3/secret")).toThrow("private/reserved");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("rejects localhost", () => {
|
|
30
|
+
expect(() => assertPublicHttpsUrl("https://localhost/font.ttf")).toThrow("private/reserved");
|
|
31
|
+
expect(() => assertPublicHttpsUrl("http://localhost:3000/secret")).toThrow();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("rejects RFC1918 — 10.x", () => {
|
|
35
|
+
expect(() => assertPublicHttpsUrl("https://10.0.0.1/secret")).toThrow("private/reserved");
|
|
36
|
+
expect(() => assertPublicHttpsUrl("https://10.255.255.255/secret")).toThrow("private/reserved");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("rejects RFC1918 — 172.16–172.31", () => {
|
|
40
|
+
expect(() => assertPublicHttpsUrl("https://172.16.0.1/secret")).toThrow("private/reserved");
|
|
41
|
+
expect(() => assertPublicHttpsUrl("https://172.31.255.255/secret")).toThrow("private/reserved");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("allows 172.0–172.15 and 172.32+ (not RFC1918)", () => {
|
|
45
|
+
expect(() => assertPublicHttpsUrl("https://172.15.0.1/font.ttf")).not.toThrow();
|
|
46
|
+
expect(() => assertPublicHttpsUrl("https://172.32.0.1/font.ttf")).not.toThrow();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("rejects RFC1918 — 192.168.x", () => {
|
|
50
|
+
expect(() => assertPublicHttpsUrl("https://192.168.1.1/secret")).toThrow("private/reserved");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("rejects unspecified address (0.x)", () => {
|
|
54
|
+
expect(() => assertPublicHttpsUrl("https://0.0.0.0/secret")).toThrow("private/reserved");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("rejects loopback IPv6 ([::1])", () => {
|
|
58
|
+
expect(() => assertPublicHttpsUrl("https://[::1]/secret")).toThrow("private/reserved");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("rejects invalid URLs", () => {
|
|
62
|
+
expect(() => assertPublicHttpsUrl("not-a-url")).toThrow("Invalid URL");
|
|
63
|
+
expect(() => assertPublicHttpsUrl("")).toThrow("Invalid URL");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -7,6 +7,62 @@ import { finished } from "stream/promises";
|
|
|
7
7
|
const downloadPathCache = new Map<string, string>();
|
|
8
8
|
const inFlightDownloads = new Map<string, Promise<string>>();
|
|
9
9
|
|
|
10
|
+
// SSRF guard: these prefixes identify non-public address space that
|
|
11
|
+
// compositions (customer-supplied) must never be able to reach via the
|
|
12
|
+
// download path. Blocks AWS IMDS (169.254.169.254), loopback, RFC1918,
|
|
13
|
+
// and unspecified addresses. All comparisons are on the raw hostname
|
|
14
|
+
// string; DNS resolution is NOT performed here, so DNS-rebinding bypasses
|
|
15
|
+
// are not closed by this check — that gap is acceptable for the risk level.
|
|
16
|
+
const BLOCKED_HOST_PREFIXES = [
|
|
17
|
+
"169.254.", // link-local / AWS IMDS
|
|
18
|
+
"127.", // loopback IPv4
|
|
19
|
+
"10.", // RFC1918
|
|
20
|
+
"192.168.", // RFC1918
|
|
21
|
+
"0.", // unspecified
|
|
22
|
+
"[::1]", // loopback IPv6
|
|
23
|
+
"[fc", // RFC4193 unique-local IPv6
|
|
24
|
+
"[fd", // RFC4193 unique-local IPv6
|
|
25
|
+
];
|
|
26
|
+
// 172.16.0.0 – 172.31.255.255 (RFC1918)
|
|
27
|
+
const BLOCKED_172_RANGE = { min: 16, max: 31 };
|
|
28
|
+
|
|
29
|
+
function isBlockedHost(hostname: string): boolean {
|
|
30
|
+
const h = hostname.toLowerCase();
|
|
31
|
+
if (h === "localhost") return true;
|
|
32
|
+
if (BLOCKED_HOST_PREFIXES.some((p) => h.startsWith(p))) return true;
|
|
33
|
+
// 172.16–172.31
|
|
34
|
+
const m = h.match(/^172\.(\d{1,3})\./);
|
|
35
|
+
if (m) {
|
|
36
|
+
const octet = parseInt(m[1] ?? "0", 10);
|
|
37
|
+
if (octet >= BLOCKED_172_RANGE.min && octet <= BLOCKED_172_RANGE.max) return true;
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Validate that a URL is safe to fetch on behalf of customer-supplied
|
|
44
|
+
* compositions. Throws if the URL is non-HTTPS or targets a private/reserved
|
|
45
|
+
* address range (SSRF guard).
|
|
46
|
+
*/
|
|
47
|
+
export function assertPublicHttpsUrl(url: string): void {
|
|
48
|
+
let parsed: URL;
|
|
49
|
+
try {
|
|
50
|
+
parsed = new URL(url);
|
|
51
|
+
} catch {
|
|
52
|
+
throw new Error(`[URLDownloader] Invalid URL: ${url}`);
|
|
53
|
+
}
|
|
54
|
+
if (parsed.protocol !== "https:") {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`[URLDownloader] Only HTTPS URLs are permitted in compositions (got ${parsed.protocol}): ${url}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (isBlockedHost(parsed.hostname)) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`[URLDownloader] URL targets a private/reserved address and is not permitted: ${url}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
10
66
|
function getFilenameFromUrl(url: string): string {
|
|
11
67
|
const hash = createHash("md5").update(url).digest("hex").slice(0, 12);
|
|
12
68
|
const urlObj = new URL(url);
|
|
@@ -19,6 +75,11 @@ export async function downloadToTemp(
|
|
|
19
75
|
destDir: string,
|
|
20
76
|
timeoutMs: number = 300000,
|
|
21
77
|
): Promise<string> {
|
|
78
|
+
// Reject non-HTTPS URLs and private/reserved address ranges before
|
|
79
|
+
// touching the cache or filesystem — customer-supplied compositions must
|
|
80
|
+
// not be able to trigger outbound fetches to internal infrastructure.
|
|
81
|
+
assertPublicHttpsUrl(url);
|
|
82
|
+
|
|
22
83
|
const cachedPath = downloadPathCache.get(url);
|
|
23
84
|
if (cachedPath && existsSync(cachedPath)) {
|
|
24
85
|
return cachedPath;
|