figranium 0.11.2 → 0.12.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 +2 -2
- package/dist/assets/index-B1CypY6C.css +1 -0
- package/dist/assets/index-B295GWry.js +18 -0
- package/dist/index.html +2 -2
- package/headful.js +2 -1
- package/package.json +1 -1
- package/scrape.js +2 -1
- package/server.js +21 -3
- package/url-utils.js +155 -14
- package/dist/assets/index-C7FmV4BD.js +0 -43
- package/dist/assets/index-CTQxB0fw.css +0 -1
package/dist/index.html
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
<link
|
|
17
17
|
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200"
|
|
18
18
|
rel="stylesheet" />
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
20
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
19
|
+
<script type="module" crossorigin src="/assets/index-B295GWry.js"></script>
|
|
20
|
+
<link rel="stylesheet" crossorigin href="/assets/index-B1CypY6C.css">
|
|
21
21
|
</head>
|
|
22
22
|
|
|
23
23
|
<body class="bg-[#020202] text-gray-100 font-sans h-full overflow-hidden selection:bg-white selection:text-black">
|
package/headful.js
CHANGED
|
@@ -3,7 +3,7 @@ const fs = require('fs');
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const { getProxySelection } = require('./proxy-rotation');
|
|
5
5
|
const { selectUserAgent } = require('./user-agent-settings');
|
|
6
|
-
const { validateUrl } = require('./url-utils');
|
|
6
|
+
const { validateUrl, setupNavigationProtection } = require('./url-utils');
|
|
7
7
|
const { parseBooleanFlag } = require('./common-utils');
|
|
8
8
|
const { Mutex } = require('./src/server/utils');
|
|
9
9
|
|
|
@@ -432,6 +432,7 @@ async function runHeadful(data, options = {}) {
|
|
|
432
432
|
});
|
|
433
433
|
};
|
|
434
434
|
|
|
435
|
+
await setupNavigationProtection(context);
|
|
435
436
|
await context.addInitScript(inspectInitFn);
|
|
436
437
|
|
|
437
438
|
await context.exposeBinding('__figraniumIsInspectEnabled', () => {
|
package/package.json
CHANGED
package/scrape.js
CHANGED
|
@@ -5,7 +5,7 @@ const { spawn } = require('child_process');
|
|
|
5
5
|
const { getProxySelection } = require('./proxy-rotation');
|
|
6
6
|
const { selectUserAgent } = require('./user-agent-settings');
|
|
7
7
|
const { formatHTML } = require('./html-utils');
|
|
8
|
-
const { validateUrl } = require('./url-utils');
|
|
8
|
+
const { validateUrl, setupNavigationProtection } = require('./url-utils');
|
|
9
9
|
const { parseBooleanFlag, sanitizeRunId, toCsvString } = require('./common-utils');
|
|
10
10
|
const { installMouseHelper } = require('./src/agent/dom-utils');
|
|
11
11
|
|
|
@@ -124,6 +124,7 @@ async function runScrape(data) {
|
|
|
124
124
|
await injectHeadfulCookies(context);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
await setupNavigationProtection(context);
|
|
127
128
|
await context.addInitScript(installMouseHelper);
|
|
128
129
|
|
|
129
130
|
if (includeShadowDom) {
|
package/server.js
CHANGED
|
@@ -45,7 +45,7 @@ const {
|
|
|
45
45
|
proxyWebsockify,
|
|
46
46
|
isPortAvailable
|
|
47
47
|
} = require('./src/server/utils');
|
|
48
|
-
const { isValidWebSocketOrigin } = require('./url-utils');
|
|
48
|
+
const { isValidWebSocketOrigin, fetchWithRedirectValidation } = require('./url-utils');
|
|
49
49
|
|
|
50
50
|
// Middleware
|
|
51
51
|
const {
|
|
@@ -139,6 +139,23 @@ app.use((req, res, next) => {
|
|
|
139
139
|
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
140
140
|
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
141
141
|
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
142
|
+
|
|
143
|
+
// Content Security Policy
|
|
144
|
+
const csp = [
|
|
145
|
+
"default-src 'self'",
|
|
146
|
+
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
|
147
|
+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
|
148
|
+
"font-src 'self' https://fonts.gstatic.com",
|
|
149
|
+
"img-src 'self' data: blob: https://www.google.com https://*.gstatic.com https://cdn.jsdelivr.net https://raw.githubusercontent.com",
|
|
150
|
+
"connect-src 'self' https://api.github.com https://generativelanguage.googleapis.com https://api.openai.com https://api.anthropic.com https://api.baserow.io",
|
|
151
|
+
"media-src 'self' blob:",
|
|
152
|
+
"frame-src 'self'"
|
|
153
|
+
].join('; ');
|
|
154
|
+
res.setHeader('Content-Security-Policy', csp);
|
|
155
|
+
|
|
156
|
+
if (SESSION_COOKIE_SECURE) {
|
|
157
|
+
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
158
|
+
}
|
|
142
159
|
next();
|
|
143
160
|
});
|
|
144
161
|
|
|
@@ -167,6 +184,7 @@ app.use(session({
|
|
|
167
184
|
rolling: true,
|
|
168
185
|
saveUninitialized: false,
|
|
169
186
|
cookie: {
|
|
187
|
+
httpOnly: true,
|
|
170
188
|
secure: SESSION_COOKIE_SECURE,
|
|
171
189
|
sameSite: 'strict',
|
|
172
190
|
maxAge: SESSION_TTL_SECONDS * 1000
|
|
@@ -245,7 +263,7 @@ const registerExecution = (req, res, baseMeta = {}) => {
|
|
|
245
263
|
durationMs: entry.durationMs,
|
|
246
264
|
result: entry.result
|
|
247
265
|
});
|
|
248
|
-
|
|
266
|
+
fetchWithRedirectValidation(webhookUrl, {
|
|
249
267
|
method: 'POST',
|
|
250
268
|
headers: { 'Content-Type': 'application/json' },
|
|
251
269
|
body: payload,
|
|
@@ -443,7 +461,7 @@ app.use('/screenshots', requireAuthOrApiKey, express.static(capturesDir));
|
|
|
443
461
|
app.use(express.static(DIST_DIR));
|
|
444
462
|
|
|
445
463
|
// Headful Status Endpoint
|
|
446
|
-
app.get('/api/headful/status', async (req, res) => {
|
|
464
|
+
app.get('/api/headful/status', requireAuth, async (req, res) => {
|
|
447
465
|
if (!novncEnabled) {
|
|
448
466
|
return res.json({ useNovnc: false });
|
|
449
467
|
}
|
package/url-utils.js
CHANGED
|
@@ -71,13 +71,19 @@ function isPrivateIP(ip) {
|
|
|
71
71
|
return false;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
const VALID_HOSTNAME_CACHE = new Set();
|
|
75
|
+
const INVALID_HOSTNAME_CACHE = new Set();
|
|
76
|
+
const CACHE_TTL = 30000; // 30 seconds
|
|
77
|
+
let lastCacheClear = Date.now();
|
|
78
|
+
|
|
74
79
|
/**
|
|
75
80
|
* Validates a URL to prevent SSRF by blocking private IP ranges.
|
|
76
81
|
* @param {string} urlStr The URL to validate.
|
|
82
|
+
* @returns {string} The validated URL string.
|
|
77
83
|
* @throws {Error} If the URL is invalid or points to a private network.
|
|
78
84
|
*/
|
|
79
85
|
async function validateUrl(urlStr) {
|
|
80
|
-
if (!urlStr) return;
|
|
86
|
+
if (!urlStr) return '';
|
|
81
87
|
|
|
82
88
|
let url;
|
|
83
89
|
try {
|
|
@@ -90,7 +96,7 @@ async function validateUrl(urlStr) {
|
|
|
90
96
|
throw new Error('Only HTTP and HTTPS protocols are allowed');
|
|
91
97
|
}
|
|
92
98
|
|
|
93
|
-
if (ALLOW_PRIVATE_NETWORKS) return;
|
|
99
|
+
if (ALLOW_PRIVATE_NETWORKS) return url.href;
|
|
94
100
|
|
|
95
101
|
let hostname = url.hostname;
|
|
96
102
|
// Strip brackets from IPv6 hostnames
|
|
@@ -98,41 +104,176 @@ async function validateUrl(urlStr) {
|
|
|
98
104
|
hostname = hostname.substring(1, hostname.length - 1);
|
|
99
105
|
}
|
|
100
106
|
|
|
101
|
-
// Direct check for common private hostnames
|
|
102
107
|
const lowerHost = hostname.toLowerCase();
|
|
108
|
+
|
|
109
|
+
// Cache management
|
|
110
|
+
if (Date.now() - lastCacheClear > CACHE_TTL) {
|
|
111
|
+
VALID_HOSTNAME_CACHE.clear();
|
|
112
|
+
INVALID_HOSTNAME_CACHE.clear();
|
|
113
|
+
lastCacheClear = Date.now();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (VALID_HOSTNAME_CACHE.has(lowerHost)) return url.href;
|
|
117
|
+
if (INVALID_HOSTNAME_CACHE.has(lowerHost)) {
|
|
118
|
+
throw new Error('Access to private network is restricted');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Direct check for common private hostnames
|
|
103
122
|
if (lowerHost === 'localhost' || lowerHost.endsWith('.localhost')) {
|
|
123
|
+
INVALID_HOSTNAME_CACHE.add(lowerHost);
|
|
104
124
|
throw new Error('Access to private network is restricted');
|
|
105
125
|
}
|
|
106
126
|
|
|
107
127
|
// Resolve hostname to IP
|
|
108
128
|
try {
|
|
129
|
+
// If it's already an IP address, check it directly
|
|
130
|
+
if (net.isIP(hostname)) {
|
|
131
|
+
if (isPrivateIP(hostname)) {
|
|
132
|
+
INVALID_HOSTNAME_CACHE.add(lowerHost);
|
|
133
|
+
throw new Error('Access to private network is restricted');
|
|
134
|
+
}
|
|
135
|
+
return url.href;
|
|
136
|
+
}
|
|
137
|
+
|
|
109
138
|
// dns.lookup follows /etc/hosts and is what's typically used for connecting
|
|
110
139
|
const addresses = await dns.lookup(hostname, { all: true });
|
|
111
140
|
for (const addr of addresses) {
|
|
112
141
|
if (isPrivateIP(addr.address)) {
|
|
142
|
+
INVALID_HOSTNAME_CACHE.add(lowerHost);
|
|
113
143
|
throw new Error('Access to private network is restricted');
|
|
114
144
|
}
|
|
115
145
|
}
|
|
146
|
+
VALID_HOSTNAME_CACHE.add(lowerHost);
|
|
116
147
|
} catch (e) {
|
|
117
148
|
if (e.message === 'Access to private network is restricted') {
|
|
118
149
|
throw e;
|
|
119
150
|
}
|
|
120
151
|
|
|
121
|
-
// If it's
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
152
|
+
// If we can't resolve it and it's not an IP, we allow it to proceed
|
|
153
|
+
// to the browser where it will likely fail normally.
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return url.href;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Perform a fetch with manual redirect following and validation at each hop.
|
|
161
|
+
* Ensures sensitive headers (Authorization, Token) are stripped on cross-origin redirects.
|
|
162
|
+
* @param {string} urlStr Initial URL.
|
|
163
|
+
* @param {object} options Fetch options.
|
|
164
|
+
* @param {number} maxRedirects Maximum number of redirects to follow.
|
|
165
|
+
*/
|
|
166
|
+
async function fetchWithRedirectValidation(urlStr, options = {}, maxRedirects = 5) {
|
|
167
|
+
let currentUrl;
|
|
168
|
+
try {
|
|
169
|
+
currentUrl = new URL(urlStr);
|
|
170
|
+
} catch (e) {
|
|
171
|
+
throw new Error('Invalid URL');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let currentOptions = { ...options };
|
|
175
|
+
let redirectCount = 0;
|
|
176
|
+
|
|
177
|
+
while (redirectCount <= maxRedirects) {
|
|
178
|
+
// validateUrl respects ALLOW_PRIVATE_NETWORKS internally
|
|
179
|
+
const validatedHref = await validateUrl(currentUrl.href);
|
|
180
|
+
|
|
181
|
+
// Explicitly reconstruct URL from validated href to ensure taint is cleared
|
|
182
|
+
const safeUrl = new URL(validatedHref);
|
|
183
|
+
|
|
184
|
+
// CodeQL mitigation: strictly verify protocol and pass URL object to fetch
|
|
185
|
+
if (safeUrl.protocol !== 'http:' && safeUrl.protocol !== 'https:') {
|
|
186
|
+
throw new Error('Only HTTP and HTTPS protocols are allowed');
|
|
126
187
|
}
|
|
127
188
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
189
|
+
const response = await fetch(safeUrl, {
|
|
190
|
+
...currentOptions,
|
|
191
|
+
redirect: 'manual'
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Handle redirects (301, 302, 303, 307, 308)
|
|
195
|
+
if (response.status >= 300 && response.status < 400) {
|
|
196
|
+
const location = response.headers.get('location');
|
|
197
|
+
if (!location) return response;
|
|
198
|
+
|
|
199
|
+
const nextUrl = new URL(location, safeUrl.href);
|
|
200
|
+
const isCrossOrigin = nextUrl.origin !== currentUrl.origin;
|
|
201
|
+
|
|
202
|
+
// Update options for the next request (shallow copy)
|
|
203
|
+
const nextOptions = { ...currentOptions };
|
|
204
|
+
if (nextOptions.headers) {
|
|
205
|
+
nextOptions.headers = { ...nextOptions.headers };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Strip sensitive headers on cross-origin redirects
|
|
209
|
+
if (isCrossOrigin && nextOptions.headers) {
|
|
210
|
+
const sensitiveHeaders = ['authorization', 'x-api-key', 'token', 'cookie', 'proxy-authorization'];
|
|
211
|
+
for (const h of Object.keys(nextOptions.headers)) {
|
|
212
|
+
if (sensitiveHeaders.includes(h.toLowerCase())) {
|
|
213
|
+
delete nextOptions.headers[h];
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Standards compliance: 301, 302, 303 redirects switch to GET and drop body
|
|
219
|
+
if ([301, 302, 303].includes(response.status)) {
|
|
220
|
+
nextOptions.method = 'GET';
|
|
221
|
+
delete nextOptions.body;
|
|
222
|
+
if (nextOptions.headers) {
|
|
223
|
+
for (const h of Object.keys(nextOptions.headers)) {
|
|
224
|
+
if (['content-type', 'content-length'].includes(h.toLowerCase())) {
|
|
225
|
+
delete nextOptions.headers[h];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
currentUrl = nextUrl;
|
|
232
|
+
currentOptions = nextOptions;
|
|
233
|
+
redirectCount++;
|
|
234
|
+
continue;
|
|
131
235
|
}
|
|
132
236
|
|
|
133
|
-
|
|
134
|
-
// to the browser where it will likely fail normally.
|
|
237
|
+
return response;
|
|
135
238
|
}
|
|
239
|
+
|
|
240
|
+
throw new Error('Too many redirects');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Sets up navigation protection for a Playwright context.
|
|
245
|
+
* Intercepts requests and validates destination URLs.
|
|
246
|
+
* @param {object} context Playwright context.
|
|
247
|
+
*/
|
|
248
|
+
async function setupNavigationProtection(context) {
|
|
249
|
+
if (ALLOW_PRIVATE_NETWORKS) return;
|
|
250
|
+
|
|
251
|
+
await context.route('**/*', async (route) => {
|
|
252
|
+
const request = route.request();
|
|
253
|
+
// Only validate main frame navigations for performance and to avoid breaking sub-resources
|
|
254
|
+
if (request.isNavigationRequest() && request.frame() === request.frame().page().mainFrame()) {
|
|
255
|
+
const url = request.url();
|
|
256
|
+
const currentUrl = request.frame().url();
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
// If it's a same-origin navigation, skip validation for speed
|
|
260
|
+
if (currentUrl && currentUrl !== 'about:blank') {
|
|
261
|
+
const u1 = new URL(url);
|
|
262
|
+
const u2 = new URL(currentUrl);
|
|
263
|
+
if (u1.origin === u2.origin) {
|
|
264
|
+
return route.continue();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
await validateUrl(url);
|
|
269
|
+
return route.continue();
|
|
270
|
+
} catch (err) {
|
|
271
|
+
console.error(`[SECURITY] Navigation to ${url} blocked: ${err.message}`);
|
|
272
|
+
return route.abort('blockedbyclient');
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return route.continue();
|
|
276
|
+
});
|
|
136
277
|
}
|
|
137
278
|
|
|
138
279
|
/**
|
|
@@ -151,4 +292,4 @@ function isValidWebSocketOrigin(origin, host) {
|
|
|
151
292
|
}
|
|
152
293
|
}
|
|
153
294
|
|
|
154
|
-
module.exports = { validateUrl, isPrivateIP, isValidWebSocketOrigin };
|
|
295
|
+
module.exports = { validateUrl, isPrivateIP, isValidWebSocketOrigin, fetchWithRedirectValidation, setupNavigationProtection };
|