figranium 0.11.3 → 0.12.1
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 +5 -5
- package/dist/assets/index-C2rVEs3q.css +1 -0
- package/dist/assets/index-CvaIUcTv.js +18 -0
- package/dist/index.html +2 -2
- package/extraction-worker.js +13 -10
- package/headful.js +2 -1
- package/package.json +2 -2
- package/scrape.js +2 -1
- package/server.js +22 -3
- package/src/server/scheduler.js +0 -2
- package/url-utils.js +184 -15
- package/dist/assets/index-CGxJAXB1.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-CvaIUcTv.js"></script>
|
|
20
|
+
<link rel="stylesheet" crossorigin href="/assets/index-C2rVEs3q.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/extraction-worker.js
CHANGED
|
@@ -153,16 +153,19 @@ const runExtraction = async (data) => {
|
|
|
153
153
|
return { shadowQueryAll, shadowText };
|
|
154
154
|
})();
|
|
155
155
|
|
|
156
|
+
const proxiedData = createSafeProxy({
|
|
157
|
+
html: () => html || '',
|
|
158
|
+
url: () => url || '',
|
|
159
|
+
window,
|
|
160
|
+
document: window.document,
|
|
161
|
+
shadowQueryAll: includeShadowDom ? shadowHelpers.shadowQueryAll : undefined,
|
|
162
|
+
shadowText: includeShadowDom ? shadowHelpers.shadowText : undefined
|
|
163
|
+
});
|
|
164
|
+
|
|
156
165
|
const sandbox = Object.create(null);
|
|
157
166
|
Object.assign(sandbox, {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
url: () => url || '',
|
|
161
|
-
window,
|
|
162
|
-
document: window.document,
|
|
163
|
-
shadowQueryAll: includeShadowDom ? shadowHelpers.shadowQueryAll : undefined,
|
|
164
|
-
shadowText: includeShadowDom ? shadowHelpers.shadowText : undefined
|
|
165
|
-
}),
|
|
167
|
+
data: proxiedData,
|
|
168
|
+
$$data: proxiedData,
|
|
166
169
|
window: createSafeProxy(window),
|
|
167
170
|
document: createSafeProxy(window.document),
|
|
168
171
|
DOMParser: createSafeProxy(window.DOMParser),
|
|
@@ -175,8 +178,8 @@ const runExtraction = async (data) => {
|
|
|
175
178
|
"use strict";
|
|
176
179
|
(async () => {
|
|
177
180
|
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
|
178
|
-
const fn = new AsyncFunction('$$data', 'window', 'document', 'DOMParser', 'console', $$userScript);
|
|
179
|
-
return fn($$data, window, document, DOMParser, console);
|
|
181
|
+
const fn = new AsyncFunction('data', '$$data', 'window', 'document', 'DOMParser', 'console', $$userScript);
|
|
182
|
+
return fn(data, $$data, window, document, DOMParser, console);
|
|
180
183
|
})();
|
|
181
184
|
`;
|
|
182
185
|
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "figranium",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"bin": {
|
|
6
6
|
"figranium": "bin/cli.js"
|
|
@@ -77,6 +77,6 @@
|
|
|
77
77
|
"postcss": "^8.5.6",
|
|
78
78
|
"tailwindcss": "^3.4.19",
|
|
79
79
|
"typescript": "^5.9.3",
|
|
80
|
-
"vite": "
|
|
80
|
+
"vite": "7.3.2"
|
|
81
81
|
}
|
|
82
82
|
}
|
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
|
|
@@ -180,6 +198,7 @@ app.use('/api/auth', authRoutes);
|
|
|
180
198
|
app.use('/api/settings', settingsRoutes);
|
|
181
199
|
app.use('/api/tasks', taskRoutes);
|
|
182
200
|
app.use('/api/executions', executionRoutes);
|
|
201
|
+
app.use('/api', dataRoutes);
|
|
183
202
|
app.use('/api/data', dataRoutes);
|
|
184
203
|
app.use('/api/schedules', scheduleRoutes);
|
|
185
204
|
app.use('/api/credentials', credentialRoutes);
|
|
@@ -245,7 +264,7 @@ const registerExecution = (req, res, baseMeta = {}) => {
|
|
|
245
264
|
durationMs: entry.durationMs,
|
|
246
265
|
result: entry.result
|
|
247
266
|
});
|
|
248
|
-
|
|
267
|
+
fetchWithRedirectValidation(webhookUrl, {
|
|
249
268
|
method: 'POST',
|
|
250
269
|
headers: { 'Content-Type': 'application/json' },
|
|
251
270
|
body: payload,
|
|
@@ -443,7 +462,7 @@ app.use('/screenshots', requireAuthOrApiKey, express.static(capturesDir));
|
|
|
443
462
|
app.use(express.static(DIST_DIR));
|
|
444
463
|
|
|
445
464
|
// Headful Status Endpoint
|
|
446
|
-
app.get('/api/headful/status', async (req, res) => {
|
|
465
|
+
app.get('/api/headful/status', requireAuth, async (req, res) => {
|
|
447
466
|
if (!novncEnabled) {
|
|
448
467
|
return res.json({ useNovnc: false });
|
|
449
468
|
}
|
package/src/server/scheduler.js
CHANGED
|
@@ -265,7 +265,6 @@ async function executeScheduledTask(taskId) {
|
|
|
265
265
|
async function startScheduler() {
|
|
266
266
|
if (running) return;
|
|
267
267
|
running = true;
|
|
268
|
-
console.log('[SCHEDULER] Starting scheduler...');
|
|
269
268
|
|
|
270
269
|
try {
|
|
271
270
|
await loadSchedules();
|
|
@@ -305,7 +304,6 @@ function stopScheduler() {
|
|
|
305
304
|
schedulerTimer = null;
|
|
306
305
|
}
|
|
307
306
|
scheduledTasks.clear();
|
|
308
|
-
console.log('[SCHEDULER] Stopped.');
|
|
309
307
|
}
|
|
310
308
|
|
|
311
309
|
/**
|
package/url-utils.js
CHANGED
|
@@ -4,6 +4,30 @@ const { ALLOW_PRIVATE_NETWORKS } = require('./src/server/constants');
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Checks if an IP address is private.
|
|
7
|
+
*
|
|
8
|
+
* Qualifies as a private network/blocked destination:
|
|
9
|
+
*
|
|
10
|
+
* IPv4 Ranges:
|
|
11
|
+
* - 0.0.0.0/8 (Current network)
|
|
12
|
+
* - 10.0.0.0/8 (Private-Use Networks - RFC 1918)
|
|
13
|
+
* - 100.64.0.0/10 (Shared Address Space - RFC 6598)
|
|
14
|
+
* - 127.0.0.0/8 (Loopback)
|
|
15
|
+
* - 169.254.0.0/16 (Link-Local)
|
|
16
|
+
* - 172.16.0.0/12 (Private-Use Networks - RFC 1918)
|
|
17
|
+
* - 192.168.0.0/16 (Private-Use Networks - RFC 1918)
|
|
18
|
+
*
|
|
19
|
+
* IPv6 Ranges:
|
|
20
|
+
* - ::/128 (Unspecified)
|
|
21
|
+
* - ::1/128 (Loopback)
|
|
22
|
+
* - fc00::/7 (Unique Local Address)
|
|
23
|
+
* - fe80::/10 (Link-Local Unicast)
|
|
24
|
+
* - IPv4-mapped/compatible addresses pointing to the above IPv4 ranges
|
|
25
|
+
*
|
|
26
|
+
* Hostnames:
|
|
27
|
+
* - localhost
|
|
28
|
+
* - *.localhost
|
|
29
|
+
* - host.docker.internal
|
|
30
|
+
*
|
|
7
31
|
* @param {string} ip The IP address to check.
|
|
8
32
|
* @returns {boolean} True if the IP is private.
|
|
9
33
|
*/
|
|
@@ -71,13 +95,19 @@ function isPrivateIP(ip) {
|
|
|
71
95
|
return false;
|
|
72
96
|
}
|
|
73
97
|
|
|
98
|
+
const VALID_HOSTNAME_CACHE = new Set();
|
|
99
|
+
const INVALID_HOSTNAME_CACHE = new Set();
|
|
100
|
+
const CACHE_TTL = 30000; // 30 seconds
|
|
101
|
+
let lastCacheClear = Date.now();
|
|
102
|
+
|
|
74
103
|
/**
|
|
75
104
|
* Validates a URL to prevent SSRF by blocking private IP ranges.
|
|
76
105
|
* @param {string} urlStr The URL to validate.
|
|
106
|
+
* @returns {string} The validated URL string.
|
|
77
107
|
* @throws {Error} If the URL is invalid or points to a private network.
|
|
78
108
|
*/
|
|
79
109
|
async function validateUrl(urlStr) {
|
|
80
|
-
if (!urlStr) return;
|
|
110
|
+
if (!urlStr) return '';
|
|
81
111
|
|
|
82
112
|
let url;
|
|
83
113
|
try {
|
|
@@ -90,7 +120,7 @@ async function validateUrl(urlStr) {
|
|
|
90
120
|
throw new Error('Only HTTP and HTTPS protocols are allowed');
|
|
91
121
|
}
|
|
92
122
|
|
|
93
|
-
if (ALLOW_PRIVATE_NETWORKS) return;
|
|
123
|
+
if (ALLOW_PRIVATE_NETWORKS) return url.href;
|
|
94
124
|
|
|
95
125
|
let hostname = url.hostname;
|
|
96
126
|
// Strip brackets from IPv6 hostnames
|
|
@@ -98,41 +128,180 @@ async function validateUrl(urlStr) {
|
|
|
98
128
|
hostname = hostname.substring(1, hostname.length - 1);
|
|
99
129
|
}
|
|
100
130
|
|
|
101
|
-
// Direct check for common private hostnames
|
|
102
131
|
const lowerHost = hostname.toLowerCase();
|
|
103
|
-
|
|
132
|
+
|
|
133
|
+
// Cache management
|
|
134
|
+
if (Date.now() - lastCacheClear > CACHE_TTL) {
|
|
135
|
+
VALID_HOSTNAME_CACHE.clear();
|
|
136
|
+
INVALID_HOSTNAME_CACHE.clear();
|
|
137
|
+
lastCacheClear = Date.now();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (VALID_HOSTNAME_CACHE.has(lowerHost)) return url.href;
|
|
141
|
+
if (INVALID_HOSTNAME_CACHE.has(lowerHost)) {
|
|
142
|
+
throw new Error('Access to private network is restricted');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Direct check for common private hostnames
|
|
146
|
+
if (
|
|
147
|
+
lowerHost === 'localhost' ||
|
|
148
|
+
lowerHost.endsWith('.localhost') ||
|
|
149
|
+
lowerHost === 'host.docker.internal'
|
|
150
|
+
) {
|
|
151
|
+
INVALID_HOSTNAME_CACHE.add(lowerHost);
|
|
104
152
|
throw new Error('Access to private network is restricted');
|
|
105
153
|
}
|
|
106
154
|
|
|
107
155
|
// Resolve hostname to IP
|
|
108
156
|
try {
|
|
157
|
+
// If it's already an IP address, check it directly
|
|
158
|
+
if (net.isIP(hostname)) {
|
|
159
|
+
if (isPrivateIP(hostname)) {
|
|
160
|
+
INVALID_HOSTNAME_CACHE.add(lowerHost);
|
|
161
|
+
throw new Error('Access to private network is restricted');
|
|
162
|
+
}
|
|
163
|
+
return url.href;
|
|
164
|
+
}
|
|
165
|
+
|
|
109
166
|
// dns.lookup follows /etc/hosts and is what's typically used for connecting
|
|
110
167
|
const addresses = await dns.lookup(hostname, { all: true });
|
|
111
168
|
for (const addr of addresses) {
|
|
112
169
|
if (isPrivateIP(addr.address)) {
|
|
170
|
+
INVALID_HOSTNAME_CACHE.add(lowerHost);
|
|
113
171
|
throw new Error('Access to private network is restricted');
|
|
114
172
|
}
|
|
115
173
|
}
|
|
174
|
+
VALID_HOSTNAME_CACHE.add(lowerHost);
|
|
116
175
|
} catch (e) {
|
|
117
176
|
if (e.message === 'Access to private network is restricted') {
|
|
118
177
|
throw e;
|
|
119
178
|
}
|
|
120
179
|
|
|
121
|
-
// If it's
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
180
|
+
// If we can't resolve it and it's not an IP, we allow it to proceed
|
|
181
|
+
// to the browser where it will likely fail normally.
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return url.href;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Perform a fetch with manual redirect following and validation at each hop.
|
|
189
|
+
* Ensures sensitive headers (Authorization, Token) are stripped on cross-origin redirects.
|
|
190
|
+
* @param {string} urlStr Initial URL.
|
|
191
|
+
* @param {object} options Fetch options.
|
|
192
|
+
* @param {number} maxRedirects Maximum number of redirects to follow.
|
|
193
|
+
*/
|
|
194
|
+
async function fetchWithRedirectValidation(urlStr, options = {}, maxRedirects = 5) {
|
|
195
|
+
let currentUrl;
|
|
196
|
+
try {
|
|
197
|
+
currentUrl = new URL(urlStr);
|
|
198
|
+
} catch (e) {
|
|
199
|
+
throw new Error('Invalid URL');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let currentOptions = { ...options };
|
|
203
|
+
let redirectCount = 0;
|
|
204
|
+
|
|
205
|
+
while (redirectCount <= maxRedirects) {
|
|
206
|
+
// validateUrl respects ALLOW_PRIVATE_NETWORKS internally
|
|
207
|
+
const validatedHref = await validateUrl(currentUrl.href);
|
|
208
|
+
|
|
209
|
+
// Explicitly reconstruct URL from validated href to ensure taint is cleared
|
|
210
|
+
const safeUrl = new URL(validatedHref);
|
|
211
|
+
|
|
212
|
+
// CodeQL mitigation: strictly verify protocol and pass URL object to fetch
|
|
213
|
+
if (safeUrl.protocol !== 'http:' && safeUrl.protocol !== 'https:') {
|
|
214
|
+
throw new Error('Only HTTP and HTTPS protocols are allowed');
|
|
126
215
|
}
|
|
127
216
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
217
|
+
const response = await fetch(safeUrl, {
|
|
218
|
+
...currentOptions,
|
|
219
|
+
redirect: 'manual'
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Handle redirects (301, 302, 303, 307, 308)
|
|
223
|
+
if (response.status >= 300 && response.status < 400) {
|
|
224
|
+
const location = response.headers.get('location');
|
|
225
|
+
if (!location) return response;
|
|
226
|
+
|
|
227
|
+
const nextUrl = new URL(location, safeUrl.href);
|
|
228
|
+
const isCrossOrigin = nextUrl.origin !== currentUrl.origin;
|
|
229
|
+
|
|
230
|
+
// Update options for the next request (shallow copy)
|
|
231
|
+
const nextOptions = { ...currentOptions };
|
|
232
|
+
if (nextOptions.headers) {
|
|
233
|
+
nextOptions.headers = { ...nextOptions.headers };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Strip sensitive headers on cross-origin redirects
|
|
237
|
+
if (isCrossOrigin && nextOptions.headers) {
|
|
238
|
+
const sensitiveHeaders = ['authorization', 'x-api-key', 'token', 'cookie', 'proxy-authorization'];
|
|
239
|
+
for (const h of Object.keys(nextOptions.headers)) {
|
|
240
|
+
if (sensitiveHeaders.includes(h.toLowerCase())) {
|
|
241
|
+
delete nextOptions.headers[h];
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Standards compliance: 301, 302, 303 redirects switch to GET and drop body
|
|
247
|
+
if ([301, 302, 303].includes(response.status)) {
|
|
248
|
+
nextOptions.method = 'GET';
|
|
249
|
+
delete nextOptions.body;
|
|
250
|
+
if (nextOptions.headers) {
|
|
251
|
+
for (const h of Object.keys(nextOptions.headers)) {
|
|
252
|
+
if (['content-type', 'content-length'].includes(h.toLowerCase())) {
|
|
253
|
+
delete nextOptions.headers[h];
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
currentUrl = nextUrl;
|
|
260
|
+
currentOptions = nextOptions;
|
|
261
|
+
redirectCount++;
|
|
262
|
+
continue;
|
|
131
263
|
}
|
|
132
264
|
|
|
133
|
-
|
|
134
|
-
// to the browser where it will likely fail normally.
|
|
265
|
+
return response;
|
|
135
266
|
}
|
|
267
|
+
|
|
268
|
+
throw new Error('Too many redirects');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Sets up navigation protection for a Playwright context.
|
|
273
|
+
* Intercepts requests and validates destination URLs.
|
|
274
|
+
* @param {object} context Playwright context.
|
|
275
|
+
*/
|
|
276
|
+
async function setupNavigationProtection(context) {
|
|
277
|
+
if (ALLOW_PRIVATE_NETWORKS) return;
|
|
278
|
+
|
|
279
|
+
await context.route('**/*', async (route) => {
|
|
280
|
+
const request = route.request();
|
|
281
|
+
// Only validate main frame navigations for performance and to avoid breaking sub-resources
|
|
282
|
+
if (request.isNavigationRequest() && request.frame() === request.frame().page().mainFrame()) {
|
|
283
|
+
const url = request.url();
|
|
284
|
+
const currentUrl = request.frame().url();
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
// If it's a same-origin navigation, skip validation for speed
|
|
288
|
+
if (currentUrl && currentUrl !== 'about:blank') {
|
|
289
|
+
const u1 = new URL(url);
|
|
290
|
+
const u2 = new URL(currentUrl);
|
|
291
|
+
if (u1.origin === u2.origin) {
|
|
292
|
+
return route.continue();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
await validateUrl(url);
|
|
297
|
+
return route.continue();
|
|
298
|
+
} catch (err) {
|
|
299
|
+
console.error(`[SECURITY] Navigation to ${url} blocked: ${err.message}`);
|
|
300
|
+
return route.abort('blockedbyclient');
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return route.continue();
|
|
304
|
+
});
|
|
136
305
|
}
|
|
137
306
|
|
|
138
307
|
/**
|
|
@@ -151,4 +320,4 @@ function isValidWebSocketOrigin(origin, host) {
|
|
|
151
320
|
}
|
|
152
321
|
}
|
|
153
322
|
|
|
154
|
-
module.exports = { validateUrl, isPrivateIP, isValidWebSocketOrigin };
|
|
323
|
+
module.exports = { validateUrl, isPrivateIP, isValidWebSocketOrigin, fetchWithRedirectValidation, setupNavigationProtection };
|