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/dist/index.html CHANGED
@@ -3,7 +3,7 @@
3
3
  <title>Canvas editor</title>
4
4
  <style>body{margin:0;padding:0}</style>
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <script type="module" crossorigin src="./assets/index-BZiOShMo.js"></script>
6
+ <script type="module" crossorigin src="./assets/index-DO5siyU6.js"></script>
7
7
  <link rel="modulepreload" crossorigin href="./assets/rolldown-runtime-XKyaHtl9.js">
8
8
  </head>
9
9
  <body>
package/lib/errors.d.ts CHANGED
@@ -51,6 +51,11 @@ export interface PolotnoErrorDetailsMap {
51
51
  reason: 'busy';
52
52
  } | {
53
53
  reason: 'pdfx';
54
+ } | {
55
+ reason: 'empty-output';
56
+ pageId?: string;
57
+ mimeType: string;
58
+ pixelRatio: number;
54
59
  }) & RenderContext;
55
60
  IMAGE_FAILED: ({
56
61
  reason: 'load';
@@ -1,3 +1,4 @@
1
+ export declare const isHttpsHost: (url: string, hosts: readonly string[]) => boolean;
1
2
  declare const tryRespond: (request: any) => boolean;
2
3
  declare const capture: (response: any) => Promise<void>;
3
4
  export { tryRespond, capture };
package/lib/font-cache.js CHANGED
@@ -13,9 +13,29 @@ import crypto from 'crypto';
13
13
  // 1. an in-memory Map for the lifetime of the process, and
14
14
  // 2. a disk cache shared between processes and across runs (e.g. parallel
15
15
  // test workers), so even a cold process doesn't re-download.
16
+ //
17
+ // A cached URL is answered before the caller's requestInterceptor, so only
18
+ // Google's own hosts may enter: a design names any URL it likes.
16
19
  const FONT_HOSTS = ['fonts.googleapis.com', 'fonts.gstatic.com'];
17
- const isFontUrl = (url) => FONT_HOSTS.some((host) => url.includes(host));
20
+ export const isHttpsHost = (url, hosts) => {
21
+ try {
22
+ const { protocol, hostname } = new URL(url);
23
+ return protocol === 'https:' && hosts.includes(hostname);
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ };
29
+ const isFontUrl = (url) => isHttpsHost(url, FONT_HOSTS);
18
30
  const memoryCache = new Map(); // url -> { contentType, body }
31
+ // Distinct Google URLs are unbounded (any `text=` query is a 200).
32
+ const MAX_MEMORY_ENTRIES = 1000;
33
+ const remember = (url, entry) => {
34
+ if (memoryCache.size >= MAX_MEMORY_ENTRIES) {
35
+ memoryCache.delete(memoryCache.keys().next().value);
36
+ }
37
+ memoryCache.set(url, entry);
38
+ };
19
39
  const FONT_CACHE_DIR = path.join(os.tmpdir(), 'polotno-node-font-cache');
20
40
  const FONT_CACHE_TTL = 7 * 24 * 60 * 60 * 1000;
21
41
  const fontCacheDiskPath = (url) => path.join(FONT_CACHE_DIR, crypto.createHash('sha1').update(url).digest('hex') + '.json');
@@ -62,7 +82,7 @@ const tryRespond = (request) => {
62
82
  if (!cached) {
63
83
  cached = readFontCacheFromDisk(url);
64
84
  if (cached) {
65
- memoryCache.set(url, cached);
85
+ remember(url, cached);
66
86
  }
67
87
  }
68
88
  if (!cached)
@@ -97,7 +117,7 @@ const capture = async (response) => {
97
117
  contentType: response.headers()['content-type'] || '',
98
118
  body,
99
119
  };
100
- memoryCache.set(url, entry);
120
+ remember(url, entry);
101
121
  writeFontCacheToDisk(url, entry);
102
122
  }
103
123
  catch (e) {
package/lib/instance.d.ts CHANGED
@@ -14,6 +14,12 @@ declare global {
14
14
  }
15
15
  }
16
16
  export { URL_FIELDS, forEveryUrlField } from '@polotno/schema/asset-urls';
17
+ /**
18
+ * `allowLocalFiles: false`: reject a design that names any `file:` source,
19
+ * before anything is downloaded or rendered. A design that may not read this
20
+ * host's disk is invalid input, so the error is DESIGN_INVALID.
21
+ */
22
+ export declare const assertNoLocalFiles: (json: any) => void;
17
23
  /**
18
24
  * Rewrite every `file:` source in a design to a URL the client can load with
19
25
  * any API. Designs without one — the common case — come back untouched, so this
@@ -22,7 +28,6 @@ export { URL_FIELDS, forEveryUrlField } from '@polotno/schema/asset-urls';
22
28
  */
23
29
  export declare const serveLocalFiles: (json: any) => {
24
30
  json: any;
25
- serving: boolean;
26
31
  release: () => void;
27
32
  };
28
33
  export declare const guessExtension: (contentType: any, kind: any) => any;
package/lib/instance.js CHANGED
@@ -2,16 +2,17 @@ import path from 'path';
2
2
  import fs from 'fs';
3
3
  import os from 'os';
4
4
  import crypto from 'crypto';
5
- import { execFileSync } from 'child_process';
5
+ import { spawn } 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 { forEveryUrlField } from '@polotno/schema/asset-urls';
11
+ import { fontSrcUrl } from '@polotno/core/fonts';
11
12
  import * as fontCache from './font-cache.js';
12
13
  import * as localMedia from './local-media.js';
13
14
  import { polotnoError, rehydrateError } from './errors.js';
14
- import { assertUrlAllowed } from './ssrf-guard.js';
15
+ import { assertUrlAllowed, guardedFetch, } from './ssrf-guard.js';
15
16
  import { createVideoStatusMonitor } from './video-status.js';
16
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
18
  // Compiled to lib/instance.js; the client bundle is published to ../dist.
@@ -59,9 +60,44 @@ export { URL_FIELDS, forEveryUrlField } from '@polotno/schema/asset-urls';
59
60
  // Not `toLowerCase().startsWith`: a `src` is routinely a data URL of several
60
61
  // MB, and that would copy every one of them on every render.
61
62
  const isFileUrl = (value) => /^file:/i.test(value);
63
+ /**
64
+ * The `file:` URL a design value names, bare or inside CSS `url(...)`, with a
65
+ * function that puts a replacement back the same way. `null` for any other
66
+ * value.
67
+ */
68
+ const localFileIn = (value) => {
69
+ if (isFileUrl(value))
70
+ return { url: value, wrap: (url) => url };
71
+ // A font style's `src` is CSS; the `url(` check skips multi-MB data URLs.
72
+ const url = /^url\(/i.test(value) ? fontSrcUrl(value) : null;
73
+ if (url && isFileUrl(url))
74
+ return { url, wrap: (served) => value.replace(url, served) };
75
+ return null;
76
+ };
77
+ /**
78
+ * `allowLocalFiles: false`: reject a design that names any `file:` source,
79
+ * before anything is downloaded or rendered. A design that may not read this
80
+ * host's disk is invalid input, so the error is DESIGN_INVALID.
81
+ */
82
+ export const assertNoLocalFiles = (json) => {
83
+ const errors = [];
84
+ forEveryUrlField(json, (node, key, value) => {
85
+ const local = localFileIn(value);
86
+ if (!local)
87
+ return;
88
+ errors.push({
89
+ path: typeof node.id === 'string' ? `${node.id}.${key}` : key,
90
+ message: `Local file source is not allowed (allowLocalFiles: false): ${local.url}`,
91
+ });
92
+ });
93
+ if (errors.length === 0)
94
+ return;
95
+ throw polotnoError('DESIGN_INVALID', `Invalid Polotno design: ${errors[0].message}` +
96
+ (errors.length > 1 ? ` (and ${errors.length - 1} more)` : ''), { errors });
97
+ };
62
98
  // The design goes to the browser as the caller wrote it, and there is nothing
63
- // to release or to switch on.
64
- const nothingToServe = (json) => ({ json, serving: false, release: () => { } });
99
+ // to release.
100
+ const nothingToServe = (json) => ({ json, release: () => { } });
65
101
  /**
66
102
  * Rewrite every `file:` source in a design to a URL the client can load with
67
103
  * any API. Designs without one — the common case — come back untouched, so this
@@ -71,7 +107,7 @@ const nothingToServe = (json) => ({ json, serving: false, release: () => { } });
71
107
  export const serveLocalFiles = (json) => {
72
108
  let found = false;
73
109
  forEveryUrlField(json, (_node, _key, value) => {
74
- found || (found = isFileUrl(value));
110
+ found || (found = localFileIn(value) !== null);
75
111
  });
76
112
  if (!found)
77
113
  return nothingToServe(json);
@@ -80,11 +116,12 @@ export const serveLocalFiles = (json) => {
80
116
  // request the browser answers from its own cache.
81
117
  const byPath = new Map();
82
118
  forEveryUrlField(cloned, (target, key, value) => {
83
- if (!isFileUrl(value))
119
+ const local = localFileIn(value);
120
+ if (!local)
84
121
  return;
85
122
  let filePath;
86
123
  try {
87
- filePath = fileURLToPath(value);
124
+ filePath = fileURLToPath(local.url);
88
125
  }
89
126
  catch (e) {
90
127
  // Not a file URL we can resolve to a path. Leave it alone: the client
@@ -93,15 +130,11 @@ export const serveLocalFiles = (json) => {
93
130
  }
94
131
  if (!byPath.has(filePath))
95
132
  byPath.set(filePath, localMedia.register(filePath));
96
- target[key] = byPath.get(filePath);
133
+ target[key] = local.wrap(byPath.get(filePath));
97
134
  });
98
135
  const urls = [...byPath.values()];
99
- // `serving` is per render, and the page-level switch is gated on it: a
100
- // process-wide "is anything registered" would let one render turn
101
- // interception on for another's page.
102
136
  return {
103
137
  json: cloned,
104
- serving: urls.length > 0,
105
138
  release: () => localMedia.release(urls),
106
139
  };
107
140
  };
@@ -134,32 +167,6 @@ export const guessExtension = (contentType, kind) => {
134
167
  .toLowerCase();
135
168
  return CONTENT_TYPE_EXTENSION[ct] || (kind === 'audio' ? '.m4a' : '.mp4');
136
169
  };
137
- // Follow redirects by hand, re-running the SSRF guard on every hop. `fetch`
138
- // follows redirects transparently, so a guard that only checked the original
139
- // URL would be trivially bypassed by a public URL that 302s to an internal
140
- // address. `redirect: 'manual'` hands us each 3xx so we can validate its
141
- // Location before chasing it.
142
- const MAX_REDIRECTS = 5;
143
- const guardedFetch = async (startUrl, policy, signal) => {
144
- let url = startUrl;
145
- for (let hop = 0;; hop++) {
146
- await assertUrlAllowed(url, policy);
147
- const res = await fetch(url, { redirect: 'manual', signal });
148
- if (res.status >= 300 && res.status < 400) {
149
- const location = res.headers.get('location');
150
- if (!location)
151
- return res; // 3xx without a target — let caller handle it
152
- if (hop >= MAX_REDIRECTS) {
153
- throw polotnoError('FETCH_FAILED', `Too many redirects while downloading: ${startUrl}`, { url: startUrl });
154
- }
155
- // Resolve relative Location against the current URL, drop the body.
156
- url = new URL(location, url).toString();
157
- await res.body?.cancel?.().catch(() => { });
158
- continue;
159
- }
160
- return res;
161
- }
162
- };
163
170
  // A media download only gets a deadline up to the RESPONSE HEADERS. The body
164
171
  // can legitimately be a hundred megabytes of video on a slow link, and there is
165
172
  // no way to tell that from a stalled socket — capping the whole transfer would
@@ -174,36 +181,10 @@ const downloadUrlToFile = async (urlString, destinationPath, policy = {}) => {
174
181
  const attemptDownload = async () => {
175
182
  // Ensure no partial file exists from a previous attempt
176
183
  await fs.promises.rm(destinationPath, { force: true });
177
- const controller = new AbortController();
178
- const timeoutId = setTimeout(() => controller.abort(), HEADERS_TIMEOUT_MS);
179
- let res;
180
- try {
181
- res = await guardedFetch(urlString, policy, controller.signal);
182
- }
183
- finally {
184
- // Cleared before the body streams — see HEADERS_TIMEOUT_MS.
185
- clearTimeout(timeoutId);
186
- }
187
- if (!res.ok) {
188
- // Best-effort read of error body (small) for debugging.
189
- let bodyPreview = '';
190
- try {
191
- bodyPreview = await res.text();
192
- if (bodyPreview.length > 500) {
193
- bodyPreview = bodyPreview.slice(0, 500) + '...';
194
- }
195
- }
196
- catch (e) {
197
- // ignore
198
- }
199
- const details = [
200
- `${res.status} ${res.statusText}`.trim(),
201
- bodyPreview ? `body: ${JSON.stringify(bodyPreview)}` : null,
202
- ]
203
- .filter(Boolean)
204
- .join(', ');
205
- throw polotnoError('FETCH_FAILED', `HTTP error while downloading: ${details}`, { url: urlString, status: res.status });
206
- }
184
+ // Rejects on a non-ok status, with `details.status` for the retry rule.
185
+ const res = await guardedFetch(urlString, policy, {
186
+ timeout: HEADERS_TIMEOUT_MS,
187
+ });
207
188
  if (!res.body) {
208
189
  throw polotnoError('FETCH_FAILED', 'Empty response body', {
209
190
  url: urlString,
@@ -212,8 +193,8 @@ const downloadUrlToFile = async (urlString, destinationPath, policy = {}) => {
212
193
  await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(destinationPath));
213
194
  return res.headers.get('content-type') || '';
214
195
  };
215
- // A local loop rather than @polotno/core/retry: importing that made core (and
216
- // konva) a runtime dependency of polotno-node for one call site.
196
+ // A local loop rather than core's retry: an attempt also covers streaming the
197
+ // body to disk.
217
198
  for (let attempt = 1;; attempt++) {
218
199
  try {
219
200
  return await attemptDownload();
@@ -234,11 +215,38 @@ const downloadUrlToFile = async (urlString, destinationPath, policy = {}) => {
234
215
  }
235
216
  }
236
217
  };
218
+ // Async, so a long transcode never freezes other renders; argv, so no shell parses a path.
219
+ const FFPROBE_TIMEOUT_MS = 30 * 1000;
220
+ // A full re-encode of a long clip on a slow box is minutes of honest work.
221
+ const FFMPEG_TIMEOUT_MS = 10 * 60 * 1000;
222
+ /** Run a tool; resolve with its stdout on exit 0, reject otherwise. */
223
+ const runTool = (command, args, timeoutMs) => new Promise((resolve, reject) => {
224
+ const child = spawn(command, args, {
225
+ // stderr is ignored, not buffered: ffmpeg's progress output is unbounded.
226
+ stdio: ['ignore', 'pipe', 'ignore'],
227
+ timeout: timeoutMs,
228
+ killSignal: 'SIGKILL',
229
+ });
230
+ let stdout = '';
231
+ child.stdout.setEncoding('utf8');
232
+ child.stdout.on('data', (chunk) => {
233
+ stdout += chunk;
234
+ });
235
+ child.on('error', reject);
236
+ child.on('close', (code, signal) => {
237
+ if (code === 0)
238
+ resolve(stdout);
239
+ else
240
+ reject(new Error(signal
241
+ ? `${command} was killed with ${signal} (timeout ${timeoutMs} ms)`
242
+ : `${command} exited with code ${code}`));
243
+ });
244
+ });
237
245
  // Returns a human-readable reason string if the video file needs transcoding
238
246
  // for Chromium headless compatibility, or null if it is already render-safe.
239
- const getVideoNormalizationReason = (filePath) => {
247
+ const getVideoNormalizationReason = async (filePath) => {
240
248
  try {
241
- const raw = execFileSync('ffprobe', [
249
+ const raw = await runTool('ffprobe', [
242
250
  '-v',
243
251
  'error',
244
252
  '-select_streams',
@@ -248,7 +256,7 @@ const getVideoNormalizationReason = (filePath) => {
248
256
  '-of',
249
257
  'json',
250
258
  filePath,
251
- ], { encoding: 'utf8' });
259
+ ], FFPROBE_TIMEOUT_MS);
252
260
  const info = JSON.parse(raw);
253
261
  const vs = info?.streams?.[0];
254
262
  if (!vs)
@@ -268,18 +276,18 @@ const getVideoNormalizationReason = (filePath) => {
268
276
  return null;
269
277
  }
270
278
  };
271
- const isFfmpegAvailable = () => {
279
+ const isFfmpegAvailable = async () => {
272
280
  try {
273
- execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' });
281
+ await runTool('ffmpeg', ['-version'], FFPROBE_TIMEOUT_MS);
274
282
  return true;
275
283
  }
276
284
  catch {
277
285
  return false;
278
286
  }
279
287
  };
280
- const getAudioCodec = (filePath) => {
288
+ const getAudioCodec = async (filePath) => {
281
289
  try {
282
- const result = execFileSync('ffprobe', [
290
+ const result = await runTool('ffprobe', [
283
291
  '-v',
284
292
  'error',
285
293
  '-select_streams',
@@ -289,16 +297,14 @@ const getAudioCodec = (filePath) => {
289
297
  '-of',
290
298
  'csv=p=0',
291
299
  filePath,
292
- ], { encoding: 'utf8' });
300
+ ], FFPROBE_TIMEOUT_MS);
293
301
  return result.trim();
294
302
  }
295
303
  catch {
296
304
  return null;
297
305
  }
298
306
  };
299
- const convertAudioToAac = (inputPath, outputPath) => {
300
- execFileSync('ffmpeg', ['-y', '-i', inputPath, '-c:v', 'copy', '-c:a', 'aac', outputPath], { stdio: 'ignore' });
301
- };
307
+ const convertAudioToAac = (inputPath, outputPath) => runTool('ffmpeg', ['-y', '-i', inputPath, '-c:v', 'copy', '-c:a', 'aac', outputPath], FFMPEG_TIMEOUT_MS);
302
308
  export const prepareLocalMediaForVideoExport = async (json, policy = {}, onProgress) => {
303
309
  const clonedJson = JSON.parse(JSON.stringify(json));
304
310
  const pointers = collectMediaSourcePointers(clonedJson);
@@ -346,10 +352,10 @@ export const prepareLocalMediaForVideoExport = async (json, policy = {}, onProgr
346
352
  let finalPath = tmpPath + ext;
347
353
  await fs.promises.rename(tmpPath, finalPath);
348
354
  if (videoUrls.has(src)) {
349
- const reason = getVideoNormalizationReason(finalPath);
355
+ const reason = await getVideoNormalizationReason(finalPath);
350
356
  if (reason) {
351
357
  const normalizedPath = finalPath + '.normalized.mp4';
352
- execFileSync('ffmpeg', [
358
+ await runTool('ffmpeg', [
353
359
  '-y',
354
360
  '-i',
355
361
  finalPath,
@@ -362,7 +368,7 @@ export const prepareLocalMediaForVideoExport = async (json, policy = {}, onProgr
362
368
  '-movflags',
363
369
  '+faststart',
364
370
  normalizedPath,
365
- ], { stdio: 'ignore' });
371
+ ], FFMPEG_TIMEOUT_MS);
366
372
  finalPath = normalizedPath;
367
373
  }
368
374
  }
@@ -395,27 +401,47 @@ export const prepareLocalMediaForVideoExport = async (json, policy = {}, onProgr
395
401
  // calls, not the navigation lifecycle. 2 minutes is generous but still bounded,
396
402
  // unlike `timeout: 0` ("disabled"), which turns a wedged load into a hang.
397
403
  const DEFAULT_NAVIGATION_TIMEOUT = 120 * 1000;
398
- // Pages that have request interception on. It is a page-level switch, and the
399
- // one handler each page carries must stay out of the way while it is off.
400
- const intercepting = new WeakSet();
401
404
  // Pages `createPage` opened, and so the only ones carrying the handler that
402
405
  // answers a local-media URL. A caller who brings their own page keeps their own
403
406
  // request handling, and their design keeps the URLs they wrote.
404
407
  const ourPages = new WeakSet();
405
- /**
406
- * Turn request interception on for a page, once. Interception is on already for
407
- * everyone who keeps the font cache; this covers the caller who turned it off
408
- * and then renders a design that carries local files.
409
- */
410
- const ensureServing = async (page) => {
411
- if (intercepting.has(page))
412
- return;
413
- await page.setRequestInterception(true);
414
- // After the await, never before: a page marked while the switch failed to
415
- // flip would let the handler respond on a page that is not intercepting, and
416
- // the asset would fail silently. Nothing can slip in between — the statement
417
- // runs in the same microtask the await resolves on.
418
- intercepting.add(page);
408
+ // The SSRF policy of the render running on a page, from its render options.
409
+ // A page without one — still loading the client, or between renders — gets
410
+ // the default policy, which blocks cloud-metadata endpoints.
411
+ const renderPolicies = new WeakMap();
412
+ // The directory a `file:` client page is served from, or null for any other
413
+ // client URL. Its own bundle is the only thing the page may read from disk.
414
+ const clientDirectory = (clientUrl) => {
415
+ if (!isFileUrl(clientUrl))
416
+ return null;
417
+ try {
418
+ return path.dirname(fileURLToPath(new URL(clientUrl)));
419
+ }
420
+ catch {
421
+ return null;
422
+ }
423
+ };
424
+ // The client's own services: the license check and Google Fonts. Fixed public
425
+ // hosts, so they skip the render's URL policy — a strict `fetchGuard` written
426
+ // with only the design's assets in mind must not cost the license or the fonts.
427
+ const POLOTNO_SERVICE_HOSTS = [
428
+ 'api.polotno.com',
429
+ 'fonts.googleapis.com',
430
+ 'fonts.gstatic.com',
431
+ ];
432
+ const LOCAL_FILE_REFUSED = 'polotno-node: the render page may not read local files directly. ' +
433
+ 'Put the file: URL in the design instead (polotno-node serves those), ' +
434
+ 'or read the file in Node and pass its contents in.';
435
+ const isInsideDirectory = (fileUrl, directory) => {
436
+ if (!directory)
437
+ return false;
438
+ try {
439
+ const filePath = fileURLToPath(new URL(fileUrl));
440
+ return filePath.startsWith(directory + path.sep);
441
+ }
442
+ catch {
443
+ return false;
444
+ }
419
445
  };
420
446
  // Match names across caller-supplied Puppeteer copies, not class identity.
421
447
  const isProtocolFailure = (error) => error instanceof Error &&
@@ -483,30 +509,58 @@ export const createPage = async (browser, url, requestInterceptor, useFontCache
483
509
  // 0 passes through as puppeteer's "no timeout", exactly as it does for
484
510
  // `goto`: a caller who disabled the budget meant it.
485
511
  page.setDefaultTimeout(navigationTimeout);
486
- // One handler per page, always attached, because `ensureServing` may turn
487
- // interception on later. It stays inert until then.
512
+ // Every request the page sends passes here, so interception is always on.
513
+ // Our own answers first, then the URL policy, so the caller's interceptor cannot skip it.
514
+ const ownDirectory = clientDirectory(url);
488
515
  page.on('request', (request) => {
489
- if (!intercepting.has(page))
490
- return;
491
516
  if (localMedia.tryRespond(request)) {
492
517
  return;
493
518
  }
494
519
  if (useFontCache && fontCache.tryRespond(request)) {
495
520
  return;
496
521
  }
497
- if (typeof requestInterceptor === 'function') {
498
- requestInterceptor(request);
499
- }
500
- else {
501
- request.continue().catch(() => { });
502
- }
522
+ void (async () => {
523
+ const requestUrl = request.url();
524
+ // The launch args allow file access, so only this can refuse a raw `file:` read.
525
+ if (isFileUrl(requestUrl)) {
526
+ if (!isInsideDirectory(requestUrl, ownDirectory)) {
527
+ // Answered, not aborted, so a script reading it gets the reason.
528
+ console.warn(`${LOCAL_FILE_REFUSED} Refused: ${requestUrl}`);
529
+ await request.respond({
530
+ status: 403,
531
+ contentType: 'text/plain',
532
+ body: LOCAL_FILE_REFUSED,
533
+ });
534
+ return;
535
+ }
536
+ }
537
+ else if (/^https?:/i.test(requestUrl) &&
538
+ !fontCache.isHttpsHost(requestUrl, POLOTNO_SERVICE_HOSTS)) {
539
+ try {
540
+ // Redirect hops arrive here as requests of their own.
541
+ await assertUrlAllowed(requestUrl, renderPolicies.get(page) ?? {});
542
+ }
543
+ catch (e) {
544
+ // The client reports the failed asset; the log names the rule.
545
+ console.warn(`polotno-node: ${e.message}`);
546
+ await request.abort('blockedbyclient');
547
+ return;
548
+ }
549
+ }
550
+ if (typeof requestInterceptor === 'function') {
551
+ requestInterceptor(request);
552
+ }
553
+ else {
554
+ await request.continue();
555
+ }
556
+ })().catch(() => {
557
+ // The page may be gone, or the request already handled.
558
+ });
503
559
  });
504
560
  if (useFontCache) {
505
561
  page.on('response', (response) => fontCache.capture(response));
506
562
  }
507
- if (useFontCache || typeof requestInterceptor === 'function') {
508
- await renderer.run(() => ensureServing(page));
509
- }
563
+ await renderer.run(() => page.setRequestInterception(true));
510
564
  // we need to make sure that headless is inside user agent
511
565
  // because we use it for key validation check
512
566
  // but also lets NOT just overwrite default one, because Google Fonts may send "reduced" version of font
@@ -554,15 +608,19 @@ export const createPage = async (browser, url, requestInterceptor, useFontCache
554
608
  * trade a URL the browser might load for one nothing answers.
555
609
  */
556
610
  const evaluateServed = async (page, func, json, ...rest) => {
557
- const served = ourPages.has(page)
558
- ? serveLocalFiles(json)
559
- : nothingToServe(json);
611
+ // The render options, as in the instance `run`.
612
+ const attrs = rest[0];
613
+ if (attrs?.allowLocalFiles === false)
614
+ assertNoLocalFiles(json);
615
+ if (!ourPages.has(page))
616
+ return page.evaluate(func, json, ...rest);
617
+ const served = serveLocalFiles(json);
618
+ renderPolicies.set(page, attrs ?? {});
560
619
  try {
561
- if (served.serving)
562
- await ensureServing(page);
563
620
  return await page.evaluate(func, served.json, ...rest);
564
621
  }
565
622
  finally {
623
+ renderPolicies.delete(page);
566
624
  served.release();
567
625
  }
568
626
  };
@@ -723,6 +781,9 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
723
781
  }
724
782
  }
725
783
  const run = async (func, ...args) => {
784
+ // Before a page is opened: a rejected design must not leave one behind.
785
+ if (args[1]?.allowLocalFiles === false)
786
+ assertNoLocalFiles(args[0]);
726
787
  const page = useParallelPages ? await openClientPage() : firstPage;
727
788
  if (busyPages.indexOf(page) >= 0) {
728
789
  throw polotnoError('EXPORT_FAILED', 'Current rendering context is busy with another task. Please use `useParallelPages: true` option to run multiple tasks in parallel or make sure previous task is finished before starting a new one.', { reason: 'busy' });
@@ -735,6 +796,8 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
735
796
  const served = serveLocalFiles(args[0]);
736
797
  const runArgs = [served.json, ...args.slice(1)];
737
798
  busyPages.push(page);
799
+ // The page's request handler reads it for every request of this render.
800
+ renderPolicies.set(page, args[1] ?? {});
738
801
  // Setup profiling if profilePath is provided
739
802
  let cdpSession = null;
740
803
  let profilerStopped = false;
@@ -743,9 +806,6 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
743
806
  const withRenderer = renderer.run;
744
807
  let succeeded = false;
745
808
  try {
746
- if (served.serving) {
747
- await withRenderer(() => ensureServing(page));
748
- }
749
809
  if (profilePath) {
750
810
  cdpSession = await withRenderer(() => page.target().createCDPSession());
751
811
  await withRenderer(() => cdpSession.send('Profiler.enable'));
@@ -840,6 +900,7 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
840
900
  finally {
841
901
  renderer.dispose();
842
902
  busyPages.splice(busyPages.indexOf(page), 1);
903
+ renderPolicies.delete(page);
843
904
  if (!succeeded && useParallelPages)
844
905
  closeFailedPage(page);
845
906
  served.release();
@@ -883,6 +944,10 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
883
944
  return blob;
884
945
  };
885
946
  const renderVideoFile = async (json, outputPath, attrs, status) => {
947
+ // The caller's design is checked here, before its media is downloaded: the
948
+ // downloads come back as `file:` URLs of our own, which must be served.
949
+ if (attrs?.allowLocalFiles === false)
950
+ assertNoLocalFiles(json);
886
951
  const shouldDownloadMedia = !hasCustomClientUrl && !(attrs && attrs.skipDownloads);
887
952
  const prepared = shouldDownloadMedia
888
953
  ? await prepareLocalMediaForVideoExport(json, {
@@ -1026,6 +1091,7 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
1026
1091
  }
1027
1092
  }, prepared.json, {
1028
1093
  ...attrs,
1094
+ allowLocalFiles: true,
1029
1095
  reportProgress: Boolean(attrs.onProgress),
1030
1096
  onProgress: attrs.onProgress ? status.progress : undefined,
1031
1097
  exposeFunctions: {
@@ -1050,11 +1116,11 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
1050
1116
  await file.close();
1051
1117
  // Convert audio to AAC if needed
1052
1118
  status.update({ stage: 'postprocess' });
1053
- if (isFfmpegAvailable()) {
1054
- const codec = getAudioCodec(outputPath);
1119
+ if (await isFfmpegAvailable()) {
1120
+ const codec = await getAudioCodec(outputPath);
1055
1121
  if (codec && codec !== 'aac') {
1056
1122
  const tempPath = outputPath + '.tmp.mp4';
1057
- convertAudioToAac(outputPath, tempPath);
1123
+ await convertAudioToAac(outputPath, tempPath);
1058
1124
  await fs.promises.rename(tempPath, outputPath);
1059
1125
  }
1060
1126
  }
@@ -1,15 +1,17 @@
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
- }
1
+ import { type FetchPolicy } from '@polotno/core/ssrf-guard';
2
+ export type { FetchPolicy };
10
3
  /**
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.
4
+ * Throw `FETCH_FAILED` (marked `ssrfBlocked: true`) if `url` must not be
5
+ * fetched under `policy`. Hostnames are resolved and every address checked.
14
6
  */
15
- export declare const assertUrlAllowed: (urlString: string, policy?: FetchPolicy) => Promise<void>;
7
+ export declare const assertUrlAllowed: (url: string, policy?: FetchPolicy) => Promise<void>;
8
+ /**
9
+ * One `fetch` with `assertUrlAllowed` applied to the URL and every redirect
10
+ * hop (at most 5). Resolves with an ok response; a blocked URL, an HTTP error
11
+ * or a timeout rejects with `FETCH_FAILED`. `init.timeout` bounds the wait for
12
+ * the response headers (default 30 s), not the body.
13
+ */
14
+ export declare const guardedFetch: (url: string, policy?: FetchPolicy, init?: {
15
+ signal?: AbortSignal;
16
+ timeout?: number;
17
+ }) => Promise<Response>;