polotno-node 3.2.0 → 3.3.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/LICENSE.md +20 -6
- package/README.md +31 -15
- package/dist/assets/html2canvas-DCcDvdvP.js +5 -0
- package/dist/assets/id3-CBTtHQkE.js +16 -0
- package/dist/assets/index-Brevyr6F.js +653 -0
- package/dist/assets/index.es-5v7ubLSO.js +5 -0
- package/dist/assets/jspdf.es.min-87BhH7b6.js +79 -0
- package/dist/assets/lib-C8V7BvzS.js +1 -0
- package/dist/assets/preload-helper-HclGiUj8.js +1 -0
- package/dist/assets/purify.es-JEAr64Sr.js +3 -0
- package/dist/assets/{rolldown-runtime-QTnfLwEv.js → rolldown-runtime-hePW80VL.js} +1 -1
- package/dist/index.html +4 -2
- package/lib/index.d.ts +3 -3
- package/lib/index.js +23 -101
- package/lib/instance.d.ts +6 -1
- package/lib/instance.js +97 -36
- package/lib/launch-options.d.ts +31 -0
- package/lib/launch-options.js +124 -0
- package/lib/ssrf-guard.d.ts +15 -0
- package/lib/ssrf-guard.js +132 -0
- package/lib/types.d.ts +23 -1
- package/package.json +13 -13
- package/dist/assets/html2canvas-BfZ04V3R.js +0 -5
- package/dist/assets/index-j-YZMZrC.js +0 -668
- package/dist/assets/index.es-DYERYDqN.js +0 -5
- package/dist/assets/jspdf.es.min-EyQhoXfa.js +0 -79
- package/dist/assets/lib-BguUsusU.js +0 -1
- package/dist/assets/purify.es-BT_A-1xG.js +0 -3
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 {
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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?:
|
|
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.
|
|
3
|
+
"version": "3.3.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.
|
|
39
|
-
"mediabunny": "^1.
|
|
40
|
-
"axios": "^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.
|
|
48
|
-
"react-dom": "^19.2.
|
|
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
|
|
51
|
+
"vite": "^8.2.1",
|
|
52
52
|
"vitest": "^4.1.10",
|
|
53
53
|
"web-audio-api": "^1.5.3",
|
|
54
|
-
"polotno": "4.
|
|
55
|
-
"@polotno/video-export": "0.2.
|
|
54
|
+
"polotno": "4.7.0",
|
|
55
|
+
"@polotno/video-export": "0.2.3"
|
|
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.
|
|
67
|
-
"puppeteer-core": "25.
|
|
68
|
-
"@polotno/schema": "0.
|
|
66
|
+
"puppeteer": "25.5.0",
|
|
67
|
+
"puppeteer-core": "25.5.0",
|
|
68
|
+
"@polotno/schema": "0.5.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"
|