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/README.md +37 -7
- package/dist/assets/{index-BZiOShMo.js → index-DO5siyU6.js} +137 -137
- package/dist/assets/{index.es-C6iz1CEp.js → index.es-NZjPkaXm.js} +1 -1
- package/dist/assets/{jspdf.es.min-DcBWbXDt.js → jspdf.es.min-Cjjh_5Ab.js} +3 -3
- package/dist/assets/{lib-Blxs_aC-.js → lib-IzftT-th.js} +1 -1
- package/dist/index.html +1 -1
- package/lib/errors.d.ts +5 -0
- package/lib/font-cache.d.ts +1 -0
- package/lib/font-cache.js +23 -3
- package/lib/instance.d.ts +6 -1
- package/lib/instance.js +191 -125
- package/lib/ssrf-guard.d.ts +15 -13
- package/lib/ssrf-guard.js +17 -129
- package/lib/types.d.ts +29 -18
- package/package.json +11 -6
package/lib/ssrf-guard.js
CHANGED
|
@@ -1,132 +1,20 @@
|
|
|
1
1
|
import dns from 'dns/promises';
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
|
|
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
|
|
101
|
-
* `policy`.
|
|
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 =
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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/
|
|
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": "
|
|
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",
|