@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":"AAgBA,wBAAsB,cAAc,CAClC,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,MAAM,EACf,SAAS,GAAE,MAAe,GACzB,OAAO,CAAC,MAAM,CAAC,CAyDjB;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAE/C"}
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":"AAAA,OAAO,EAAE,iBAAiB,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,IAAI,CAAC;AAC9D,OAAO,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAC;AACpC,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AACrC,OAAO,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AAClC,OAAO,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAE3C,MAAM,iBAAiB,GAAG,IAAI,GAAG,EAAkB,CAAC;AACpD,MAAM,iBAAiB,GAAG,IAAI,GAAG,EAA2B,CAAC;AAE7D,SAAS,kBAAkB,CAAC,GAAW;IACrC,MAAM,IAAI,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IACtE,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;IAC5B,MAAM,GAAG,GAAG,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,MAAM,CAAC;IAC/C,OAAO,YAAY,IAAI,GAAG,GAAG,EAAE,CAAC;AAClC,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,GAAW,EACX,OAAe,EACf,YAAoB,MAAM;IAE1B,MAAM,UAAU,GAAG,iBAAiB,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAC9C,IAAI,UAAU,IAAI,UAAU,CAAC,UAAU,CAAC,EAAE,CAAC;QACzC,OAAO,UAAU,CAAC;IACpB,CAAC;IACD,MAAM,QAAQ,GAAG,iBAAiB,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAC5C,IAAI,QAAQ,EAAE,CAAC;QACb,OAAO,QAAQ,CAAC;IAClB,CAAC;IAED,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC,EAAE,CAAC;QACzB,SAAS,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC1C,CAAC;IAED,MAAM,QAAQ,GAAG,kBAAkB,CAAC,GAAG,CAAC,CAAC;IACzC,MAAM,SAAS,GAAG,IAAI,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;IAE1C,IAAI,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QAC1B,iBAAiB,CAAC,GAAG,CAAC,GAAG,EAAE,SAAS,CAAC,CAAC;QACtC,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,MAAM,eAAe,GAAG,CAAC,KAAK,IAAI,EAAE;QAClC,IAAI,CAAC;YACH,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;YACzC,MAAM,SAAS,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,SAAS,CAAC,CAAC;YAElE,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC;YACjE,YAAY,CAAC,SAAS,CAAC,CAAC;YAExB,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;gBACjB,MAAM,IAAI,KAAK,CAAC,QAAQ,QAAQ,CAAC,MAAM,KAAK,QAAQ,CAAC,UAAU,EAAE,CAAC,CAAC;YACrE,CAAC;YAED,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC;gBACnB,MAAM,IAAI,KAAK,CAAC,wBAAwB,CAAC,CAAC;YAC5C,CAAC;YAED,MAAM,UAAU,GAAG,iBAAiB,CAAC,SAAS,CAAC,CAAC;YAChD,8DAA8D;YAC9D,MAAM,cAAc,GAAG,QAAQ,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAW,CAAC,CAAC;YAC9D,MAAM,QAAQ,CAAC,cAAc,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC;YAEhD,iBAAiB,CAAC,GAAG,CAAC,GAAG,EAAE,SAAS,CAAC,CAAC;YACtC,OAAO,SAAS,CAAC;QACnB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,OAAO,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YACjE,IAAI,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC;gBAChC,MAAM,IAAI,KAAK,CAAC,0CAA0C,SAAS,GAAG,IAAI,MAAM,GAAG,EAAE,CAAC,CAAC;YACzF,CAAC;YACD,MAAM,IAAI,KAAK,CAAC,oCAAoC,OAAO,EAAE,CAAC,CAAC;QACjE,CAAC;gBAAS,CAAC;YACT,iBAAiB,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QAChC,CAAC;IACH,CAAC,CAAC,EAAE,CAAC;IACL,iBAAiB,CAAC,GAAG,CAAC,GAAG,EAAE,eAAe,CAAC,CAAC;IAC5C,OAAO,eAAe,CAAC;AACzB,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,IAAY;IACpC,OAAO,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC,IAAI,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC;AACnE,CAAC"}
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.68",
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.68"
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;