polotno-node 3.2.0 → 3.4.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/instance.js CHANGED
@@ -2,13 +2,14 @@ import path from 'path';
2
2
  import fs from 'fs';
3
3
  import os from 'os';
4
4
  import crypto from 'crypto';
5
- import { execSync } from 'child_process';
5
+ import { execFileSync } from 'child_process';
6
6
  import { pathToFileURL, fileURLToPath } from 'url';
7
7
  import { Readable } from 'stream';
8
8
  import { pipeline } from 'stream/promises';
9
9
  import { assertValidDesign } from '@polotno/schema';
10
10
  import * as fontCache from './font-cache.js';
11
11
  import { polotnoError, rehydrateError } from './errors.js';
12
+ import { assertUrlAllowed } from './ssrf-guard.js';
12
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
14
  // Compiled to lib/instance.js; the client bundle is published to ../dist.
14
15
  const DEFAULT_CLIENT = `file:${path.join(__dirname, '..', 'dist', 'index.html')}`;
@@ -57,34 +58,55 @@ const isDataOrFileSrc = (src) => {
57
58
  src.startsWith('file:') ||
58
59
  src.startsWith('blob:'));
59
60
  };
60
- const guessExtension = (urlString, contentType) => {
61
- try {
62
- const u = new URL(urlString);
63
- const ext = path.extname(u.pathname || '');
64
- if (ext)
65
- return ext;
66
- }
67
- catch (e) {
68
- // ignore; fallback to content-type
69
- }
61
+ const CONTENT_TYPE_EXTENSION = {
62
+ 'video/mp4': '.mp4',
63
+ 'video/webm': '.webm',
64
+ 'video/quicktime': '.mov',
65
+ 'audio/mpeg': '.mp3',
66
+ 'audio/mp4': '.m4a',
67
+ 'audio/wav': '.wav',
68
+ 'audio/x-wav': '.wav',
69
+ 'audio/ogg': '.ogg',
70
+ 'audio/webm': '.webm',
71
+ };
72
+ // The temp file's extension is later passed to ffmpeg/ffprobe and used by
73
+ // Chromium for the `file://` MIME. Take it ONLY from the response content-type,
74
+ // never the URL — a URL-derived extension like `x.$(touch pwned)` is how the
75
+ // original RCE injected a shell command (CWE-78). The name is otherwise a UUID.
76
+ export const guessExtension = (contentType, kind) => {
70
77
  const ct = String(contentType || '')
71
78
  .split(';')[0]
72
79
  .trim()
73
80
  .toLowerCase();
74
- const map = {
75
- 'video/mp4': '.mp4',
76
- 'video/webm': '.webm',
77
- 'video/quicktime': '.mov',
78
- 'audio/mpeg': '.mp3',
79
- 'audio/mp4': '.m4a',
80
- 'audio/wav': '.wav',
81
- 'audio/x-wav': '.wav',
82
- 'audio/ogg': '.ogg',
83
- 'audio/webm': '.webm',
84
- };
85
- return map[ct] || '.bin';
81
+ return CONTENT_TYPE_EXTENSION[ct] || (kind === 'audio' ? '.m4a' : '.mp4');
86
82
  };
87
- const downloadUrlToFile = async (urlString, destinationPath) => {
83
+ // Follow redirects by hand, re-running the SSRF guard on every hop. `fetch`
84
+ // follows redirects transparently, so a guard that only checked the original
85
+ // URL would be trivially bypassed by a public URL that 302s to an internal
86
+ // address. `redirect: 'manual'` hands us each 3xx so we can validate its
87
+ // Location before chasing it.
88
+ const MAX_REDIRECTS = 5;
89
+ const guardedFetch = async (startUrl, policy) => {
90
+ let url = startUrl;
91
+ for (let hop = 0;; hop++) {
92
+ await assertUrlAllowed(url, policy);
93
+ const res = await fetch(url, { redirect: 'manual' });
94
+ if (res.status >= 300 && res.status < 400) {
95
+ const location = res.headers.get('location');
96
+ if (!location)
97
+ return res; // 3xx without a target — let caller handle it
98
+ if (hop >= MAX_REDIRECTS) {
99
+ throw polotnoError('FETCH_FAILED', `Too many redirects while downloading: ${startUrl}`, { url: startUrl });
100
+ }
101
+ // Resolve relative Location against the current URL, drop the body.
102
+ url = new URL(location, url).toString();
103
+ await res.body?.cancel?.().catch(() => { });
104
+ continue;
105
+ }
106
+ return res;
107
+ }
108
+ };
109
+ const downloadUrlToFile = async (urlString, destinationPath, policy = {}) => {
88
110
  if (typeof fetch !== 'function') {
89
111
  throw polotnoError('FETCH_FAILED', 'Global fetch API is not available in this Node.js runtime. Please use Node 18+.', { url: urlString });
90
112
  }
@@ -95,7 +117,7 @@ const downloadUrlToFile = async (urlString, destinationPath) => {
95
117
  try {
96
118
  // Ensure no partial file exists from previous attempts
97
119
  await fs.promises.rm(destinationPath, { force: true });
98
- const res = await fetch(urlString);
120
+ const res = await guardedFetch(urlString, policy);
99
121
  if (!res.ok) {
100
122
  // Best-effort read of error body (small) for debugging.
101
123
  let bodyPreview = '';
@@ -126,6 +148,10 @@ const downloadUrlToFile = async (urlString, destinationPath) => {
126
148
  }
127
149
  catch (e) {
128
150
  lastError = e;
151
+ // A blocked-by-policy (SSRF) rejection is deterministic — surface it now
152
+ // instead of burning the retry budget re-resolving the same bad host.
153
+ if (e?.ssrfBlocked)
154
+ throw e;
129
155
  if (attempt < MAX_ATTEMPTS) {
130
156
  const delay = BASE_DELAY_MS * Math.pow(2, attempt - 1);
131
157
  await new Promise((r) => setTimeout(r, delay));
@@ -140,7 +166,17 @@ const downloadUrlToFile = async (urlString, destinationPath) => {
140
166
  // for Chromium headless compatibility, or null if it is already render-safe.
141
167
  const getVideoNormalizationReason = (filePath) => {
142
168
  try {
143
- const raw = execSync(`ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,codec_tag_string,pix_fmt -of json "${filePath}"`, { encoding: 'utf8' });
169
+ const raw = execFileSync('ffprobe', [
170
+ '-v',
171
+ 'error',
172
+ '-select_streams',
173
+ 'v:0',
174
+ '-show_entries',
175
+ 'stream=codec_name,codec_tag_string,pix_fmt',
176
+ '-of',
177
+ 'json',
178
+ filePath,
179
+ ], { encoding: 'utf8' });
144
180
  const info = JSON.parse(raw);
145
181
  const vs = info?.streams?.[0];
146
182
  if (!vs)
@@ -162,7 +198,7 @@ const getVideoNormalizationReason = (filePath) => {
162
198
  };
163
199
  const isFfmpegAvailable = () => {
164
200
  try {
165
- execSync('ffmpeg -version', { stdio: 'ignore' });
201
+ execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' });
166
202
  return true;
167
203
  }
168
204
  catch {
@@ -171,7 +207,17 @@ const isFfmpegAvailable = () => {
171
207
  };
172
208
  const getAudioCodec = (filePath) => {
173
209
  try {
174
- const result = execSync(`ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of csv=p=0 "${filePath}"`, { encoding: 'utf8' });
210
+ const result = execFileSync('ffprobe', [
211
+ '-v',
212
+ 'error',
213
+ '-select_streams',
214
+ 'a:0',
215
+ '-show_entries',
216
+ 'stream=codec_name',
217
+ '-of',
218
+ 'csv=p=0',
219
+ filePath,
220
+ ], { encoding: 'utf8' });
175
221
  return result.trim();
176
222
  }
177
223
  catch {
@@ -179,11 +225,9 @@ const getAudioCodec = (filePath) => {
179
225
  }
180
226
  };
181
227
  const convertAudioToAac = (inputPath, outputPath) => {
182
- execSync(`ffmpeg -y -i "${inputPath}" -c:v copy -c:a aac "${outputPath}"`, {
183
- stdio: 'ignore',
184
- });
228
+ execFileSync('ffmpeg', ['-y', '-i', inputPath, '-c:v', 'copy', '-c:a', 'aac', outputPath], { stdio: 'ignore' });
185
229
  };
186
- const prepareLocalMediaForVideoExport = async (json) => {
230
+ export const prepareLocalMediaForVideoExport = async (json, policy = {}) => {
187
231
  const clonedJson = JSON.parse(JSON.stringify(json));
188
232
  const pointers = collectMediaSourcePointers(clonedJson);
189
233
  const rewriteMap = new Map();
@@ -225,15 +269,29 @@ const prepareLocalMediaForVideoExport = async (json) => {
225
269
  ? crypto.randomUUID()
226
270
  : crypto.randomBytes(16).toString('hex');
227
271
  const tmpPath = path.join(tempDir, tmpBase);
228
- const contentType = await downloadUrlToFile(src, tmpPath);
229
- const ext = guessExtension(src, contentType);
272
+ const contentType = await downloadUrlToFile(src, tmpPath, policy);
273
+ const kind = videoUrls.has(src) ? 'video' : 'audio';
274
+ const ext = guessExtension(contentType, kind);
230
275
  let finalPath = tmpPath + ext;
231
276
  await fs.promises.rename(tmpPath, finalPath);
232
277
  if (videoUrls.has(src)) {
233
278
  const reason = getVideoNormalizationReason(finalPath);
234
279
  if (reason) {
235
280
  const normalizedPath = finalPath + '.normalized.mp4';
236
- execSync(`ffmpeg -y -i "${finalPath}" -c:v libx264 -pix_fmt yuv420p -c:a aac -movflags +faststart "${normalizedPath}"`, { stdio: 'ignore' });
281
+ execFileSync('ffmpeg', [
282
+ '-y',
283
+ '-i',
284
+ finalPath,
285
+ '-c:v',
286
+ 'libx264',
287
+ '-pix_fmt',
288
+ 'yuv420p',
289
+ '-c:a',
290
+ 'aac',
291
+ '-movflags',
292
+ '+faststart',
293
+ normalizedPath,
294
+ ], { stdio: 'ignore' });
237
295
  finalPath = normalizedPath;
238
296
  }
239
297
  }
@@ -607,7 +665,10 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
607
665
  await assertValidJson(json);
608
666
  const shouldDownloadMedia = !hasCustomClientUrl && !(attrs && attrs.skipDownloads);
609
667
  const prepared = shouldDownloadMedia
610
- ? await prepareLocalMediaForVideoExport(json)
668
+ ? await prepareLocalMediaForVideoExport(json, {
669
+ blockPrivateNetwork: attrs?.blockPrivateNetwork,
670
+ fetchGuard: attrs?.fetchGuard,
671
+ })
611
672
  : { json, cleanup: async () => { } };
612
673
  const writeStream = fs.createWriteStream(outputPath);
613
674
  try {
@@ -0,0 +1,31 @@
1
+ import type { BrowserOptions } from './types.js';
2
+ export declare const args: string[];
3
+ /** The one platform `@sparticuz/chromium` builds its binary for. */
4
+ export declare const BUNDLED_CHROMIUM_PLATFORM = "linux";
5
+ /**
6
+ * Do we launch the chromium binary that `@sparticuz/chromium` ships?
7
+ *
8
+ * Only on its platform, and only when the caller has not named a browser of
9
+ * their own. macOS and Windows run puppeteer's bundled Chromium; a caller who
10
+ * passes `executablePath` — a container's google-chrome-stable, playwright's
11
+ * build — runs that.
12
+ */
13
+ export declare function usesBundledChromium(options?: BrowserOptions, platform?: string): boolean;
14
+ /**
15
+ * The flags to launch with. `@sparticuz/chromium`'s args are tuned for the
16
+ * binary it ships (`--single-process`, `--in-process-gpu`, swiftshader ANGLE)
17
+ * and belong to it alone: given to a system Chrome they range from wasteful to
18
+ * fatal — `--single-process` in particular can leave Chrome dying mid-handshake,
19
+ * which surfaces only as "Protocol error (Target.setDiscoverTargets): Target
20
+ * closed". So they travel with the binary. Our own args, which keep rendering
21
+ * identical to the editor's canvas, always apply, and the caller's
22
+ * `browserArgs` win over both.
23
+ */
24
+ export declare function resolveBrowserArgs(options?: BrowserOptions, platform?: string): string[];
25
+ /**
26
+ * The browser to launch: the caller's, else the bundled chromium where we use
27
+ * it, else puppeteer's own. Resolving the bundled one is not free — it inflates
28
+ * chromium, the fonts and swiftshader into /tmp — so we only ask for it when it
29
+ * is the one we launch.
30
+ */
31
+ export declare function resolveExecutablePath(options?: BrowserOptions, platform?: string): Promise<string | undefined>;
@@ -0,0 +1,124 @@
1
+ import os from 'os';
2
+ import sparticuz from '@sparticuz/chromium';
3
+ // TODO: that is a weird hack, but it works
4
+ const chrome = sparticuz.default || sparticuz;
5
+ export const args = [
6
+ 'about:blank',
7
+ '--disable-web-security',
8
+ '--allow-file-access-from-files',
9
+ '--allow-pre-commit-input',
10
+ '--autoplay-policy=user-gesture-required',
11
+ '--disable-background-networking',
12
+ '--disable-background-timer-throttling',
13
+ '--disable-backgrounding-occluded-windows',
14
+ '--disable-breakpad',
15
+ '--disable-component-extensions-with-background-pages',
16
+ '--disable-client-side-phishing-detection',
17
+ '--disable-component-update',
18
+ '--disable-default-apps',
19
+ '--disable-dev-shm-usage',
20
+ '--proxy-server=direct://',
21
+ '--proxy-bypass-list=*',
22
+ '--disable-domain-reliability',
23
+ '--disable-features=AudioServiceOutOfProcess,IsolateOrigins,site-per-process,Translate,BackForwardCache,AvoidUnnecessaryBeforeUnloadCheckSync,IntensiveWakeUpThrottling',
24
+ '--disable-extensions',
25
+ '--disable-hang-monitor',
26
+ '--disable-ipc-flooding-protection',
27
+ '--disable-notifications',
28
+ '--disable-offer-store-unmasked-wallet-cards',
29
+ '--disable-popup-blocking',
30
+ '--disable-print-preview',
31
+ '--disable-site-isolation-trials',
32
+ '--disable-prompt-on-repost',
33
+ '--disable-renderer-backgrounding',
34
+ '--disable-setuid-sandbox',
35
+ '--disable-speech-api',
36
+ '--disable-sync',
37
+ // Use srgb for identical output across Linux/macOS/Windows and to match how CSS colors/images are defined/assumed on the web.
38
+ '--force-color-profile=srgb',
39
+ '--hide-scrollbars',
40
+ '--metrics-recording-only',
41
+ '--mute-audio',
42
+ '--no-default-browser-check',
43
+ '--no-first-run',
44
+ '--enable-automation',
45
+ '--no-pings',
46
+ '--no-sandbox',
47
+ '--no-zygote',
48
+ '--password-store=basic',
49
+ '--allow-running-insecure-content',
50
+ // Disable accessibility features to reduce resource usage and improve performance.
51
+ '--disable-renderer-accessibility',
52
+ // stable locale → font fallback/hyphenation
53
+ '--lang=en-US',
54
+ // this line breaks the rendering on most of OS
55
+ // it doesn't allow creating of pages
56
+ //'--use-gl=swiftshader',
57
+ '--disable-gpu',
58
+ '--use-mock-keychain',
59
+ '--enable-blink-features=IdleDetection',
60
+ '--intensive-wake-up-throttling-policy=0',
61
+ '--font-render-hinting=none',
62
+ '--ignore-gpu-blocklist',
63
+ // don't use this flag, because it makes renderign less accurate
64
+ // see https://community.polotno.com/c/ask/the-ability-to-exclude-args-from-minimal_args-in-polotno-node
65
+ // '--disable-font-subpixel-positioning',
66
+ '--text-rendering=geometricprecision',
67
+ ];
68
+ /**
69
+ * Merge browser argument arrays, deduping by flag name (the part before `=`).
70
+ * Later arrays take precedence on conflict.
71
+ */
72
+ function mergeArgs(...argArrays) {
73
+ const argMap = new Map();
74
+ for (const argArray of argArrays) {
75
+ for (const arg of argArray) {
76
+ const flagMatch = arg.match(/^(--[^=]+)/);
77
+ const flagName = flagMatch ? flagMatch[1] : arg;
78
+ argMap.set(flagName, arg);
79
+ }
80
+ }
81
+ return Array.from(argMap.values());
82
+ }
83
+ /** The one platform `@sparticuz/chromium` builds its binary for. */
84
+ export const BUNDLED_CHROMIUM_PLATFORM = 'linux';
85
+ /**
86
+ * Do we launch the chromium binary that `@sparticuz/chromium` ships?
87
+ *
88
+ * Only on its platform, and only when the caller has not named a browser of
89
+ * their own. macOS and Windows run puppeteer's bundled Chromium; a caller who
90
+ * passes `executablePath` — a container's google-chrome-stable, playwright's
91
+ * build — runs that.
92
+ */
93
+ export function usesBundledChromium(options = {}, platform = os.platform()) {
94
+ return platform === BUNDLED_CHROMIUM_PLATFORM && !options.executablePath;
95
+ }
96
+ /**
97
+ * The flags to launch with. `@sparticuz/chromium`'s args are tuned for the
98
+ * binary it ships (`--single-process`, `--in-process-gpu`, swiftshader ANGLE)
99
+ * and belong to it alone: given to a system Chrome they range from wasteful to
100
+ * fatal — `--single-process` in particular can leave Chrome dying mid-handshake,
101
+ * which surfaces only as "Protocol error (Target.setDiscoverTargets): Target
102
+ * closed". So they travel with the binary. Our own args, which keep rendering
103
+ * identical to the editor's canvas, always apply, and the caller's
104
+ * `browserArgs` win over both.
105
+ */
106
+ export function resolveBrowserArgs(options = {}, platform = os.platform()) {
107
+ const { browserArgs = [] } = options;
108
+ return usesBundledChromium(options, platform)
109
+ ? mergeArgs(chrome.args, args, browserArgs)
110
+ : mergeArgs(args, browserArgs);
111
+ }
112
+ /**
113
+ * The browser to launch: the caller's, else the bundled chromium where we use
114
+ * it, else puppeteer's own. Resolving the bundled one is not free — it inflates
115
+ * chromium, the fonts and swiftshader into /tmp — so we only ask for it when it
116
+ * is the one we launch.
117
+ */
118
+ export async function resolveExecutablePath(options = {}, platform = os.platform()) {
119
+ if (options.executablePath)
120
+ return options.executablePath;
121
+ return usesBundledChromium(options, platform)
122
+ ? await chrome.executablePath()
123
+ : undefined;
124
+ }
@@ -0,0 +1,15 @@
1
+ export interface FetchPolicy {
2
+ /** Also block loopback, link-local and private (RFC1918 / ULA) addresses. */
3
+ blockPrivateNetwork?: boolean;
4
+ /**
5
+ * Full override. Return `false` to reject the URL. When provided, the
6
+ * built-in IP checks are skipped and this is the sole authority.
7
+ */
8
+ fetchGuard?: (url: string) => boolean | Promise<boolean>;
9
+ }
10
+ /**
11
+ * Throw a branded FETCH_FAILED error if `urlString` must not be fetched under
12
+ * `policy`. Resolves DNS for hostnames and checks every returned address.
13
+ * No-throw = allowed.
14
+ */
15
+ export declare const assertUrlAllowed: (urlString: string, policy?: FetchPolicy) => Promise<void>;
@@ -0,0 +1,132 @@
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
+ };
99
+ /**
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.
103
+ */
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
+ };
package/lib/types.d.ts CHANGED
@@ -37,11 +37,28 @@ export interface VideoRenderOptions extends RenderOptions {
37
37
  * URLs for more reliable and faster loading. Set `skipDownloads: true` to disable that.
38
38
  */
39
39
  skipDownloads?: boolean;
40
+ /**
41
+ * SSRF hardening for the server-side media downloads above. Cloud-metadata
42
+ * endpoints (e.g. 169.254.169.254) are blocked by default — whether named by
43
+ * literal IP, a known metadata hostname, or a hostname that resolves to one
44
+ * (best-effort; a resolve-then-fetch guard leaves a small DNS-rebinding
45
+ * window). Set this to ALSO reject loopback, link-local and private (RFC1918 /
46
+ * ULA) addresses — enable it when the design JSON is untrusted and the
47
+ * renderer has no legitimate reason to reach internal hosts. Default: `false`
48
+ * (localhost and private hosts allowed).
49
+ */
50
+ blockPrivateNetwork?: boolean;
51
+ /**
52
+ * Full override for the download SSRF check: return `false` to reject a URL.
53
+ * When provided it is the sole authority — the built-in IP checks are skipped.
54
+ * Redirects are validated per-hop with the same rule.
55
+ */
56
+ fetchGuard?: (url: string) => boolean | Promise<boolean>;
40
57
  }
41
58
  export type RenderJSON = DesignInput;
42
59
  export interface BrowserOptions {
43
60
  browserArgs?: string[];
44
- headless?: 'new' | boolean;
61
+ headless?: boolean | 'shell';
45
62
  protocolTimeout?: number;
46
63
  ignoreHTTPSErrors?: boolean;
47
64
  executablePath?: string;
@@ -52,6 +69,11 @@ export interface InstanceOptions {
52
69
  useParallelPages?: boolean;
53
70
  browserArgs?: string[];
54
71
  browser?: Browser;
72
+ /**
73
+ * Launch this browser binary instead of the one we would pick — a container's
74
+ * google-chrome-stable, say. Ignored when you pass your own `browser`.
75
+ */
76
+ executablePath?: string;
55
77
  /** Intercept every page request (e.g. to block or rewrite asset loads). */
56
78
  requestInterceptor?: (request: any) => void;
57
79
  /** Cache Google Fonts responses for the process lifetime and serve them to every page (default: true) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polotno-node",
3
- "version": "3.2.0",
3
+ "version": "3.4.0",
4
4
  "description": "Polotno workflow from NodeJS",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "type": "module",
@@ -35,24 +35,24 @@
35
35
  "@polotno/video-export"
36
36
  ],
37
37
  "devDependencies": {
38
- "@types/node": "^26.1.1",
39
- "mediabunny": "^1.50.8",
40
- "axios": "^1.18.1",
38
+ "@types/node": "^26.1.2",
39
+ "mediabunny": "^1.52.3",
40
+ "axios": "^1.19.0",
41
41
  "dotenv": "^17.4.2",
42
42
  "fluent-ffmpeg": "^2.1.3",
43
43
  "jspdf": "^4.2.1",
44
44
  "konva": "^10.3.0",
45
45
  "pixelmatch": "^7.2.0",
46
46
  "pngjs": "^7.0.0",
47
- "react": "^19.2.7",
48
- "react-dom": "^19.2.7",
47
+ "react": "^19.2.8",
48
+ "react-dom": "^19.2.8",
49
49
  "tmp": "^0.2.7",
50
50
  "typescript": "~6.0.3",
51
- "vite": "^8.1.4",
51
+ "vite": "^8.2.1",
52
52
  "vitest": "^4.1.10",
53
53
  "web-audio-api": "^1.5.3",
54
- "polotno": "4.6.0",
55
- "@polotno/video-export": "0.2.2"
54
+ "@polotno/video-export": "0.2.3",
55
+ "polotno": "4.8.0"
56
56
  },
57
57
  "overrides": {
58
58
  "@polotno/video-export": {
@@ -63,9 +63,9 @@
63
63
  "author": "Anton Lavrenov",
64
64
  "dependencies": {
65
65
  "@sparticuz/chromium": "^149.0.0",
66
- "puppeteer": "25.3.0",
67
- "puppeteer-core": "25.3.0",
68
- "@polotno/schema": "0.4.0"
66
+ "puppeteer": "25.5.0",
67
+ "puppeteer-core": "25.5.0",
68
+ "@polotno/schema": "0.6.0"
69
69
  },
70
70
  "bugs": {
71
71
  "url": "https://github.com/polotno-project/polotno-node/issues"
@@ -75,7 +75,7 @@
75
75
  "scripts": {
76
76
  "build": "rm -rf ./lib ./dist && tsc -p tsconfig.json && vite build ./client/ --base=./ --outDir ../dist --emptyOutDir --logLevel error",
77
77
  "typecheck": "tsc --noEmit -p tsconfig.json",
78
- "test": "pnpm exec turbo run build --filter=polotno-node && vitest run test/unit test/static/static.test.mjs",
78
+ "test": "pnpm exec turbo run build --filter=polotno-node --output-logs=new-only --log-order=stream && vitest run test/unit test/static/static.test.mjs",
79
79
  "test:video": "pnpm exec turbo run build --filter=polotno-node && vitest run test/video/video.test.mjs",
80
80
  "test:all": "pnpm exec turbo run build --filter=polotno-node && vitest run test/unit test/static/static.test.mjs test/video/video.test.mjs",
81
81
  "dev": "vite ./client.html"