polotno-node 3.13.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-DO5siyU6.js +662 -0
- package/dist/assets/{index.es-Dt5PfWmo.js → index.es-NZjPkaXm.js} +1 -1
- package/dist/assets/{jspdf.es.min-D3vqlR_0.js → jspdf.es.min-Cjjh_5Ab.js} +3 -3
- package/dist/assets/lib-IzftT-th.js +1 -0
- package/dist/index.html +1 -1
- package/lib/errors.d.ts +9 -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 +193 -127
- 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 +16 -11
- package/dist/assets/index-DODDd0dH.js +0 -662
- package/dist/assets/lib-SCSmTgPg.js +0 -1
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 {
|
|
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
|
|
64
|
-
const nothingToServe = (json) => ({ json,
|
|
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 =
|
|
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
|
-
|
|
119
|
+
const local = localFileIn(value);
|
|
120
|
+
if (!local)
|
|
84
121
|
return;
|
|
85
122
|
let filePath;
|
|
86
123
|
try {
|
|
87
|
-
filePath = fileURLToPath(
|
|
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
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
216
|
-
//
|
|
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 =
|
|
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
|
-
],
|
|
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
|
-
|
|
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 =
|
|
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
|
-
],
|
|
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
|
-
|
|
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
|
-
],
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
//
|
|
487
|
-
//
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
};
|
|
@@ -680,8 +738,8 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
|
|
|
680
738
|
// Clear immediately to avoid double-reporting.
|
|
681
739
|
window._polotnoError = null;
|
|
682
740
|
// The client stashes a structured `{code, message, details}`
|
|
683
|
-
// payload (image timeouts arrive as IMAGE_FAILED
|
|
684
|
-
// so skip flags cover them too).
|
|
741
|
+
// payload (image and font timeouts arrive as IMAGE_FAILED and
|
|
742
|
+
// FONT_FAILED, so skip flags cover them too).
|
|
685
743
|
const code = typeof payload === 'object' ? payload.code : null;
|
|
686
744
|
if (code === 'FONT_FAILED' && attrs && attrs.skipFontError) {
|
|
687
745
|
return null;
|
|
@@ -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
|
}
|
package/lib/ssrf-guard.d.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
|
12
|
-
* `policy`.
|
|
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: (
|
|
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>;
|