polotno-node 3.14.0 → 3.15.0

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.
package/lib/ssrf-guard.js CHANGED
@@ -1,132 +1,20 @@
1
1
  import dns from 'dns/promises';
2
- import net from 'net';
3
- import { polotnoError } from './errors.js';
4
- // Cloud metadata endpoints — AWS/GCP/Azure (169.254.169.254), ECS task role
5
- // (169.254.170.2), and the IPv6 form. Never a legitimate render target.
6
- const METADATA_IPS = new Set([
7
- '169.254.169.254',
8
- '169.254.170.2',
9
- 'fd00:ec2::254',
10
- ]);
11
- // Metadata hostnames (GCP) — caught by name even where they don't resolve.
12
- const METADATA_HOSTS = new Set(['metadata.google.internal', 'metadata.goog']);
13
- // A real metadata A-record resolves in ms; a slow resolver fails open promptly.
14
- const DNS_TIMEOUT_MS = 1500;
15
- // Resolve to addresses; [] on error/timeout (fail open). Never rejects.
16
- const resolveHost = async (host) => {
17
- let timer;
18
- try {
19
- const addresses = await Promise.race([
20
- dns.lookup(host, { all: true }),
21
- new Promise((_, reject) => {
22
- timer = setTimeout(() => reject(new Error('dns-timeout')), DNS_TIMEOUT_MS);
23
- }),
24
- ]);
25
- return addresses.map((a) => a.address);
26
- }
27
- catch {
28
- return [];
29
- }
30
- finally {
31
- clearTimeout(timer);
32
- }
33
- };
34
- const blocked = (url, why) => {
35
- const err = polotnoError('FETCH_FAILED', `Blocked request to ${url}: ${why} (SSRF protection). ` +
36
- `Pass a fetchGuard to override, or blockPrivateNetwork:false to relax.`, { url });
37
- // A deterministic policy rejection — the download loop must not retry it.
38
- err.ssrfBlocked = true;
39
- return err;
40
- };
41
- const ipv4IsPrivate = (ip) => {
42
- const [a, b] = ip.split('.').map(Number);
43
- if (a === 127)
44
- return true; // loopback 127.0.0.0/8
45
- if (a === 10)
46
- return true; // 10.0.0.0/8
47
- if (a === 172 && b >= 16 && b <= 31)
48
- return true; // 172.16.0.0/12
49
- if (a === 192 && b === 168)
50
- return true; // 192.168.0.0/16
51
- if (a === 169 && b === 254)
52
- return true; // link-local 169.254.0.0/16
53
- if (a === 100 && b >= 64 && b <= 127)
54
- return true; // CGNAT 100.64.0.0/10
55
- if (a === 0)
56
- return true; // 0.0.0.0/8
57
- return false;
58
- };
59
- const ipv6IsPrivate = (ip) => {
60
- const v = ip.toLowerCase();
61
- if (v === '::1' || v === '::')
62
- return true; // loopback / unspecified
63
- if (v.startsWith('fe80'))
64
- return true; // link-local
65
- if (v.startsWith('fc') || v.startsWith('fd'))
66
- return true; // unique local
67
- return false;
68
- };
69
- // Unwrap an IPv4-mapped IPv6 address to its dotted-quad form, or null. Handles
70
- // both the dotted (`::ffff:169.254.169.254`) and hex (`::ffff:a9fe:a9fe`)
71
- // spellings — Node's `URL` normalizes the former into the latter, so a guard
72
- // that only knew the dotted form could be bypassed with a bracketed literal.
73
- const unwrapMappedV4 = (addr) => {
74
- const dotted = addr.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
75
- if (dotted)
76
- return dotted[1];
77
- const hex = addr.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
78
- if (hex) {
79
- const hi = parseInt(hex[1], 16);
80
- const lo = parseInt(hex[2], 16);
81
- return `${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`;
82
- }
83
- return null;
84
- };
85
- // Returns a reason string if the address must be blocked, else null.
86
- const addressBlockReason = (ip, blockPrivate) => {
87
- let addr = ip.toLowerCase();
88
- addr = unwrapMappedV4(addr) ?? addr;
89
- if (METADATA_IPS.has(addr))
90
- return 'cloud-metadata endpoint';
91
- if (!blockPrivate)
92
- return null;
93
- if (net.isIPv4(addr) && ipv4IsPrivate(addr))
94
- return 'private/loopback address';
95
- if (net.isIPv6(addr) && ipv6IsPrivate(addr))
96
- return 'private/loopback address';
97
- return null;
98
- };
2
+ import { fetchWithRetry } from '@polotno/core/retry';
3
+ import { assertUrlAllowed as assertWithResolver, urlValidator, } from '@polotno/core/ssrf-guard';
4
+ const resolveHost = (host) => dns.lookup(host, { all: true }).then((all) => all.map((a) => a.address));
99
5
  /**
100
- * Throw a branded FETCH_FAILED error if `urlString` must not be fetched under
101
- * `policy`. Resolves DNS for hostnames and checks every returned address.
102
- * No-throw = allowed.
6
+ * Throw `FETCH_FAILED` (marked `ssrfBlocked: true`) if `url` must not be
7
+ * fetched under `policy`. Hostnames are resolved and every address checked.
103
8
  */
104
- export const assertUrlAllowed = async (urlString, policy = {}) => {
105
- if (policy.fetchGuard) {
106
- const ok = await policy.fetchGuard(urlString);
107
- if (!ok)
108
- throw blocked(urlString, 'rejected by fetchGuard');
109
- return;
110
- }
111
- let parsed;
112
- try {
113
- parsed = new URL(urlString);
114
- }
115
- catch {
116
- // Malformed URL — let the caller's fetch surface the real error.
117
- return;
118
- }
119
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
120
- return;
121
- // Strip IPv6 brackets: new URL('http://[::1]/').hostname === '[::1]'.
122
- const host = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase();
123
- if (METADATA_HOSTS.has(host))
124
- throw blocked(urlString, 'metadata hostname');
125
- const addresses = net.isIP(host) ? [host] : await resolveHost(host);
126
- const blockPrivate = !!policy.blockPrivateNetwork;
127
- for (const ip of addresses) {
128
- const reason = addressBlockReason(ip, blockPrivate);
129
- if (reason)
130
- throw blocked(urlString, reason);
131
- }
132
- };
9
+ export const assertUrlAllowed = (url, policy = {}) => assertWithResolver(url, policy, resolveHost);
10
+ /**
11
+ * One `fetch` with `assertUrlAllowed` applied to the URL and every redirect
12
+ * hop (at most 5). Resolves with an ok response; a blocked URL, an HTTP error
13
+ * or a timeout rejects with `FETCH_FAILED`. `init.timeout` bounds the wait for
14
+ * the response headers (default 30 s), not the body.
15
+ */
16
+ export const guardedFetch = (url, policy = {}, init = {}) => fetchWithRetry(url, {
17
+ ...init,
18
+ retries: 1,
19
+ validateUrl: urlValidator(policy, resolveHost),
20
+ });
package/lib/types.d.ts CHANGED
@@ -29,6 +29,34 @@ export interface RenderOptions {
29
29
  onProgress?: (progress: number) => void;
30
30
  /** Path to save CPU profile (.cpuprofile) - loadable in Chrome DevTools */
31
31
  profilePath?: string;
32
+ /**
33
+ * SSRF hardening for every URL the render requests: each http(s) request
34
+ * the render page sends (images, SVGs, fonts, media, redirect hops) and the
35
+ * video export's server-side media downloads. Cloud-metadata endpoints (e.g.
36
+ * 169.254.169.254) are blocked whatever this is set to — whether named by
37
+ * literal IP, a known metadata hostname, or a hostname that resolves to one
38
+ * (best-effort; a resolve-then-fetch guard leaves a small DNS-rebinding
39
+ * window). Set this to ALSO reject loopback, link-local and private (RFC1918
40
+ * / ULA) addresses — enable it when the design JSON is untrusted and the
41
+ * renderer has no legitimate reason to reach internal hosts. Default:
42
+ * `false` (localhost and private hosts allowed).
43
+ */
44
+ blockPrivateNetwork?: boolean;
45
+ /**
46
+ * Full override for the SSRF check: return `false` to reject a URL. When
47
+ * provided it is the sole authority — the built-in IP checks are skipped.
48
+ * It sees every http(s) URL the render requests, redirect hops included,
49
+ * except the client's own services: the Polotno license check and Google
50
+ * Fonts.
51
+ */
52
+ fetchGuard?: (url: string) => boolean | Promise<boolean>;
53
+ /**
54
+ * Serve `file:` sources named in the design (default: `true`). Set `false`
55
+ * when the design JSON is untrusted: a design with any `file:` source is
56
+ * then rejected with `DESIGN_INVALID` before rendering. Media that a video
57
+ * export downloads itself is still served.
58
+ */
59
+ allowLocalFiles?: boolean;
32
60
  }
33
61
  export interface PDFRenderOptions extends RenderOptions {
34
62
  dpi?: number;
@@ -67,26 +95,9 @@ export interface VideoRenderOptions extends RenderOptions {
67
95
  * pre-download remote video/audio sources into a temp folder and serve them to the
68
96
  * renderer from there, for more reliable and faster loading. Set `skipDownloads: true`
69
97
  * to disable that and let the browser load the original URLs itself. Local (`file:`)
70
- * sources are always served, whatever this is set to.
98
+ * sources are served whatever this is set to; `allowLocalFiles` controls them.
71
99
  */
72
100
  skipDownloads?: boolean;
73
- /**
74
- * SSRF hardening for the server-side media downloads above. Cloud-metadata
75
- * endpoints (e.g. 169.254.169.254) are blocked by default — whether named by
76
- * literal IP, a known metadata hostname, or a hostname that resolves to one
77
- * (best-effort; a resolve-then-fetch guard leaves a small DNS-rebinding
78
- * window). Set this to ALSO reject loopback, link-local and private (RFC1918 /
79
- * ULA) addresses — enable it when the design JSON is untrusted and the
80
- * renderer has no legitimate reason to reach internal hosts. Default: `false`
81
- * (localhost and private hosts allowed).
82
- */
83
- blockPrivateNetwork?: boolean;
84
- /**
85
- * Full override for the download SSRF check: return `false` to reject a URL.
86
- * When provided it is the sole authority — the built-in IP checks are skipped.
87
- * Redirects are validated per-hop with the same rule.
88
- */
89
- fetchGuard?: (url: string) => boolean | Promise<boolean>;
90
101
  }
91
102
  export type RenderJSON = DesignInput;
92
103
  export interface BrowserOptions {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polotno-node",
3
- "version": "3.14.0",
3
+ "version": "3.15.0",
4
4
  "description": "Polotno workflow from NodeJS",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "type": "module",
@@ -21,6 +21,11 @@
21
21
  "types": "./lib/errors.d.ts",
22
22
  "import": "./lib/errors.js",
23
23
  "default": "./lib/errors.js"
24
+ },
25
+ "./ssrf-guard": {
26
+ "types": "./lib/ssrf-guard.d.ts",
27
+ "import": "./lib/ssrf-guard.js",
28
+ "default": "./lib/ssrf-guard.js"
24
29
  }
25
30
  },
26
31
  "files": [
@@ -35,9 +40,8 @@
35
40
  "@polotno/video-export"
36
41
  ],
37
42
  "devDependencies": {
38
- "@polotno/core": "0.18.0",
39
43
  "@polotno/design-fixtures": "0.0.0",
40
- "@polotno/video-export": "0.5.0",
44
+ "@polotno/video-export": "0.5.1",
41
45
  "@types/node": "^26.6.5",
42
46
  "dotenv": "^18.0.8",
43
47
  "jspdf": "^4.2.1",
@@ -45,7 +49,7 @@
45
49
  "mediabunny": "^1.61.6",
46
50
  "pixelmatch": "^7.2.0",
47
51
  "pngjs": "^7.0.0",
48
- "polotno": "4.17.0",
52
+ "polotno": "4.18.0",
49
53
  "react": "^19.3.1",
50
54
  "react-dom": "^19.3.1",
51
55
  "tmp": "^0.2.7",
@@ -62,7 +66,8 @@
62
66
  },
63
67
  "author": "Anton Lavrenov",
64
68
  "dependencies": {
65
- "@polotno/schema": "0.9.0",
69
+ "@polotno/core": "0.19.0",
70
+ "@polotno/schema": "0.9.1",
66
71
  "@sparticuz/chromium": "^153.0.0",
67
72
  "puppeteer": "25.14.0",
68
73
  "puppeteer-core": "25.14.0"
@@ -74,7 +79,7 @@
74
79
  "readmeFilename": "README.md",
75
80
  "scripts": {
76
81
  "build": "rm -rf ./lib ./dist && tsc -p tsconfig.json && vite build ./client/ --base=./ --outDir ../dist --emptyOutDir --logLevel error",
77
- "typecheck": "tsc --noEmit -p tsconfig.json",
82
+ "typecheck": "node ../../scripts/typecheck.mjs --noEmit -p tsconfig.json",
78
83
  "test": "if [ -z \"$TURBO_HASH\" ]; then pnpm exec turbo run build --filter=polotno-node --output-logs=new-only --log-order=stream; fi && vitest run test/unit test/static",
79
84
  "test:update-goldens": "rm -f test/static/goldens/*.png test/static/goldens/matrix/*.png && pnpm run test",
80
85
  "test:video": "if [ -z \"$TURBO_HASH\" ]; then pnpm exec turbo run build --filter=polotno-node --output-logs=new-only; fi && vitest run test/video/video.test.mjs",