figranium 0.12.0 → 0.12.2

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/url-utils.js CHANGED
@@ -1,295 +1,339 @@
1
- const dns = require('dns').promises;
2
- const net = require('net');
3
- const { ALLOW_PRIVATE_NETWORKS } = require('./src/server/constants');
4
-
5
- /**
6
- * Checks if an IP address is private.
7
- * @param {string} ip The IP address to check.
8
- * @returns {boolean} True if the IP is private.
9
- */
10
- function isPrivateIP(ip) {
11
- if (net.isIPv4(ip)) {
12
- const parts = ip.split('.').map(Number);
13
- return (
14
- parts[0] === 0 ||
15
- parts[0] === 10 ||
16
- (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
17
- (parts[0] === 192 && parts[1] === 168) ||
18
- parts[0] === 127 ||
19
- (parts[0] === 169 && parts[1] === 254) ||
20
- (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127)
21
- );
22
- }
23
- if (net.isIPv6(ip)) {
24
- const lower = ip.toLowerCase();
25
- const parts = lower.split(':');
26
- const last = parts[parts.length - 1];
27
-
28
- // Handle IPv4-mapped IPv6 addresses (::ffff:1.2.3.4 or ::ffff:7f00:1)
29
- const ffffIndex = parts.indexOf('ffff');
30
- if (ffffIndex !== -1) {
31
- const prefixAllZeros = parts.slice(0, ffffIndex).every(p => p === '' || p === '0');
32
- if (prefixAllZeros) {
33
- if (net.isIPv4(last)) {
34
- return isPrivateIP(last);
35
- }
36
- const p1 = parseInt(parts[parts.length - 2], 16);
37
- const p2 = parseInt(parts[parts.length - 1], 16);
38
- if (!isNaN(p1) && !isNaN(p2)) {
39
- return isPrivateIP(`${(p1 >> 8) & 0xff}.${p1 & 0xff}.${(p2 >> 8) & 0xff}.${p2 & 0xff}`);
40
- }
41
- }
42
- }
43
-
44
- // Handle IPv4-compatible IPv6 addresses (::1.2.3.4 or ::7f00:1)
45
- if (ffffIndex === -1) {
46
- const prefixAllZeros = parts.slice(0, -2).every(p => p === '' || p === '0');
47
- if (prefixAllZeros) {
48
- if (net.isIPv4(last)) {
49
- return isPrivateIP(last);
50
- }
51
- const p1 = parseInt(parts[parts.length - 2], 16);
52
- const p2 = parseInt(parts[parts.length - 1], 16);
53
- if (!isNaN(p1) && !isNaN(p2)) {
54
- return isPrivateIP(`${(p1 >> 8) & 0xff}.${p1 & 0xff}.${(p2 >> 8) & 0xff}.${p2 & 0xff}`);
55
- }
56
- }
57
- }
58
-
59
- // ::1 loopback, :: unspecified
60
- if (lower === '::1' || lower === '::' || lower === '0:0:0:0:0:0:0:0' || lower === '0:0:0:0:0:0:0:1') {
61
- return true;
62
- }
63
-
64
- // fe80:: link-local, fc00::/fd00:: unique local
65
- return (
66
- lower.startsWith('fe80:') ||
67
- lower.startsWith('fc') ||
68
- lower.startsWith('fd')
69
- );
70
- }
71
- return false;
72
- }
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
-
79
- /**
80
- * Validates a URL to prevent SSRF by blocking private IP ranges.
81
- * @param {string} urlStr The URL to validate.
82
- * @returns {string} The validated URL string.
83
- * @throws {Error} If the URL is invalid or points to a private network.
84
- */
85
- async function validateUrl(urlStr) {
86
- if (!urlStr) return '';
87
-
88
- let url;
89
- try {
90
- url = new URL(urlStr);
91
- } catch (e) {
92
- throw new Error('Invalid URL');
93
- }
94
-
95
- if (url.protocol !== 'http:' && url.protocol !== 'https:') {
96
- throw new Error('Only HTTP and HTTPS protocols are allowed');
97
- }
98
-
99
- if (ALLOW_PRIVATE_NETWORKS) return url.href;
100
-
101
- let hostname = url.hostname;
102
- // Strip brackets from IPv6 hostnames
103
- if (hostname.startsWith('[') && hostname.endsWith(']')) {
104
- hostname = hostname.substring(1, hostname.length - 1);
105
- }
106
-
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
122
- if (lowerHost === 'localhost' || lowerHost.endsWith('.localhost')) {
123
- INVALID_HOSTNAME_CACHE.add(lowerHost);
124
- throw new Error('Access to private network is restricted');
125
- }
126
-
127
- // Resolve hostname to IP
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
-
138
- // dns.lookup follows /etc/hosts and is what's typically used for connecting
139
- const addresses = await dns.lookup(hostname, { all: true });
140
- for (const addr of addresses) {
141
- if (isPrivateIP(addr.address)) {
142
- INVALID_HOSTNAME_CACHE.add(lowerHost);
143
- throw new Error('Access to private network is restricted');
144
- }
145
- }
146
- VALID_HOSTNAME_CACHE.add(lowerHost);
147
- } catch (e) {
148
- if (e.message === 'Access to private network is restricted') {
149
- throw e;
150
- }
151
-
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');
187
- }
188
-
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;
235
- }
236
-
237
- return response;
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
- });
277
- }
278
-
279
- /**
280
- * Verifies if a WebSocket origin matches the request host (CSWSH protection).
281
- * @param {string} origin The Origin header value.
282
- * @param {string} host The Host header value.
283
- * @returns {boolean} True if the origin is valid or missing.
284
- */
285
- function isValidWebSocketOrigin(origin, host) {
286
- if (!origin) return true;
287
- try {
288
- const originHost = new URL(origin).host;
289
- return !!(originHost && host && originHost === host);
290
- } catch (e) {
291
- return false;
292
- }
293
- }
294
-
295
- module.exports = { validateUrl, isPrivateIP, isValidWebSocketOrigin, fetchWithRedirectValidation, setupNavigationProtection };
1
+ const dns = require('dns').promises;
2
+ const net = require('net');
3
+ const { ALLOW_PRIVATE_NETWORKS } = require('./src/server/constants');
4
+
5
+ /**
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.0.0.0/24 (IETF Protocol Assignments)
18
+ * - 192.0.2.0/24 (TEST-NET-1)
19
+ * - 192.168.0.0/16 (Private-Use Networks - RFC 1918)
20
+ * - 198.18.0.0/15 (Benchmarking)
21
+ * - 198.51.100.0/24 (TEST-NET-2)
22
+ * - 203.0.113.0/24 (TEST-NET-3)
23
+ * - 224.0.0.0/4 (Multicast)
24
+ * - 240.0.0.0/4 (Reserved)
25
+ *
26
+ * IPv6 Ranges:
27
+ * - ::/128 (Unspecified)
28
+ * - ::1/128 (Loopback)
29
+ * - fc00::/7 (Unique Local Address)
30
+ * - fe80::/10 (Link-Local Unicast)
31
+ * - ff00::/8 (Multicast)
32
+ * - IPv4-mapped/compatible addresses pointing to the above IPv4 ranges
33
+ *
34
+ * Hostnames:
35
+ * - localhost
36
+ * - *.localhost
37
+ * - host.docker.internal
38
+ *
39
+ * @param {string} ip The IP address to check.
40
+ * @returns {boolean} True if the IP is private.
41
+ */
42
+ function isPrivateIP(ip) {
43
+ if (net.isIPv4(ip)) {
44
+ const parts = ip.split('.').map(Number);
45
+ return (
46
+ parts[0] === 0 ||
47
+ parts[0] === 10 ||
48
+ (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
49
+ (parts[0] === 192 && parts[1] === 168) ||
50
+ parts[0] === 127 ||
51
+ (parts[0] === 169 && parts[1] === 254) ||
52
+ (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) ||
53
+ (parts[0] === 192 && parts[1] === 0 && parts[2] === 0) || // 192.0.0.0/24
54
+ (parts[0] === 192 && parts[1] === 0 && parts[2] === 2) || // 192.0.2.0/24
55
+ (parts[0] === 198 && parts[1] >= 18 && parts[1] <= 19) || // 198.18.0.0/15
56
+ (parts[0] === 198 && parts[1] === 51 && parts[2] === 100) || // 198.51.100.0/24
57
+ (parts[0] === 203 && parts[1] === 0 && parts[2] === 113) || // 203.0.113.0/24
58
+ parts[0] >= 224 // 224.0.0.0/4 (Multicast) and 240.0.0.0/4 (Reserved)
59
+ );
60
+ }
61
+ if (net.isIPv6(ip)) {
62
+ const lower = ip.toLowerCase();
63
+ const parts = lower.split(':');
64
+ const last = parts[parts.length - 1];
65
+
66
+ // Handle IPv4-mapped IPv6 addresses (::ffff:1.2.3.4 or ::ffff:7f00:1)
67
+ const ffffIndex = parts.indexOf('ffff');
68
+ if (ffffIndex !== -1) {
69
+ const prefixAllZeros = parts.slice(0, ffffIndex).every(p => p === '' || p === '0');
70
+ if (prefixAllZeros) {
71
+ if (net.isIPv4(last)) {
72
+ return isPrivateIP(last);
73
+ }
74
+ const p1 = parseInt(parts[parts.length - 2], 16);
75
+ const p2 = parseInt(parts[parts.length - 1], 16);
76
+ if (!isNaN(p1) && !isNaN(p2)) {
77
+ return isPrivateIP(`${(p1 >> 8) & 0xff}.${p1 & 0xff}.${(p2 >> 8) & 0xff}.${p2 & 0xff}`);
78
+ }
79
+ }
80
+ }
81
+
82
+ // Handle IPv4-compatible IPv6 addresses (::1.2.3.4 or ::7f00:1)
83
+ if (ffffIndex === -1) {
84
+ const prefixAllZeros = parts.slice(0, -2).every(p => p === '' || p === '0');
85
+ if (prefixAllZeros) {
86
+ if (net.isIPv4(last)) {
87
+ return isPrivateIP(last);
88
+ }
89
+ const p1 = parseInt(parts[parts.length - 2], 16);
90
+ const p2 = parseInt(parts[parts.length - 1], 16);
91
+ if (!isNaN(p1) && !isNaN(p2)) {
92
+ return isPrivateIP(`${(p1 >> 8) & 0xff}.${p1 & 0xff}.${(p2 >> 8) & 0xff}.${p2 & 0xff}`);
93
+ }
94
+ }
95
+ }
96
+
97
+ // Native IPv6 checks (Unspecified, Loopback, Link-Local, Unique Local, Multicast)
98
+ const isUnspecified = parts.every(p => p === '0' || p === '0000' || p === '');
99
+ const isLoopback = parts.slice(0, -1).every(p => p === '0' || p === '0000' || p === '') &&
100
+ (parts[parts.length - 1] === '1' || parts[parts.length - 1] === '0001');
101
+
102
+ if (isUnspecified || isLoopback) return true;
103
+
104
+ const firstHex = parseInt(parts[0], 16);
105
+ if (!isNaN(firstHex)) {
106
+ if (firstHex >= 0xff00) return true; // ff00::/8 Multicast
107
+ if (firstHex >= 0xfe80 && firstHex <= 0xfebf) return true; // fe80::/10 Link-local
108
+ if (firstHex >= 0xfc00 && firstHex <= 0xfdff) return true; // fc00::/7 Unique local
109
+ }
110
+ }
111
+ return false;
112
+ }
113
+
114
+ const VALID_HOSTNAME_CACHE = new Set();
115
+ const INVALID_HOSTNAME_CACHE = new Set();
116
+ const CACHE_TTL = 30000; // 30 seconds
117
+ let lastCacheClear = Date.now();
118
+
119
+ /**
120
+ * Validates a URL to prevent SSRF by blocking private IP ranges.
121
+ * @param {string} urlStr The URL to validate.
122
+ * @returns {string} The validated URL string.
123
+ * @throws {Error} If the URL is invalid or points to a private network.
124
+ */
125
+ async function validateUrl(urlStr) {
126
+ if (!urlStr) return '';
127
+
128
+ let url;
129
+ try {
130
+ url = new URL(urlStr);
131
+ } catch (e) {
132
+ throw new Error('Invalid URL');
133
+ }
134
+
135
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
136
+ throw new Error('Only HTTP and HTTPS protocols are allowed');
137
+ }
138
+
139
+ if (ALLOW_PRIVATE_NETWORKS) return url.href;
140
+
141
+ let hostname = url.hostname;
142
+ // Strip brackets from IPv6 hostnames
143
+ if (hostname.startsWith('[') && hostname.endsWith(']')) {
144
+ hostname = hostname.substring(1, hostname.length - 1);
145
+ }
146
+
147
+ const lowerHost = hostname.toLowerCase();
148
+
149
+ // Cache management
150
+ if (Date.now() - lastCacheClear > CACHE_TTL) {
151
+ VALID_HOSTNAME_CACHE.clear();
152
+ INVALID_HOSTNAME_CACHE.clear();
153
+ lastCacheClear = Date.now();
154
+ }
155
+
156
+ if (VALID_HOSTNAME_CACHE.has(lowerHost)) return url.href;
157
+ if (INVALID_HOSTNAME_CACHE.has(lowerHost)) {
158
+ throw new Error('Access to private network is restricted');
159
+ }
160
+
161
+ // Direct check for common private hostnames
162
+ if (
163
+ lowerHost === 'localhost' ||
164
+ lowerHost.endsWith('.localhost') ||
165
+ lowerHost === 'host.docker.internal'
166
+ ) {
167
+ INVALID_HOSTNAME_CACHE.add(lowerHost);
168
+ throw new Error('Access to private network is restricted');
169
+ }
170
+
171
+ // Resolve hostname to IP
172
+ try {
173
+ // If it's already an IP address, check it directly
174
+ if (net.isIP(hostname)) {
175
+ if (isPrivateIP(hostname)) {
176
+ INVALID_HOSTNAME_CACHE.add(lowerHost);
177
+ throw new Error('Access to private network is restricted');
178
+ }
179
+ return url.href;
180
+ }
181
+
182
+ // dns.lookup follows /etc/hosts and is what's typically used for connecting
183
+ const addresses = await dns.lookup(hostname, { all: true });
184
+ for (const addr of addresses) {
185
+ if (isPrivateIP(addr.address)) {
186
+ INVALID_HOSTNAME_CACHE.add(lowerHost);
187
+ throw new Error('Access to private network is restricted');
188
+ }
189
+ }
190
+ VALID_HOSTNAME_CACHE.add(lowerHost);
191
+ } catch (e) {
192
+ if (e.message === 'Access to private network is restricted') {
193
+ throw e;
194
+ }
195
+
196
+ // If we can't resolve it and it's not an IP, we allow it to proceed
197
+ // to the browser where it will likely fail normally.
198
+ }
199
+
200
+ return url.href;
201
+ }
202
+
203
+ /**
204
+ * Perform a fetch with manual redirect following and validation at each hop.
205
+ * Ensures sensitive headers (Authorization, Token) are stripped on cross-origin redirects.
206
+ * @param {string} urlStr Initial URL.
207
+ * @param {object} options Fetch options.
208
+ * @param {number} maxRedirects Maximum number of redirects to follow.
209
+ */
210
+ async function fetchWithRedirectValidation(urlStr, options = {}, maxRedirects = 5) {
211
+ let currentUrl;
212
+ try {
213
+ currentUrl = new URL(urlStr);
214
+ } catch (e) {
215
+ throw new Error('Invalid URL');
216
+ }
217
+
218
+ let currentOptions = { ...options };
219
+ let redirectCount = 0;
220
+
221
+ while (redirectCount <= maxRedirects) {
222
+ // validateUrl respects ALLOW_PRIVATE_NETWORKS internally
223
+ const validatedHref = await validateUrl(currentUrl.href);
224
+
225
+ // Explicitly reconstruct URL from validated href to ensure taint is cleared
226
+ const safeUrl = new URL(validatedHref);
227
+
228
+ // CodeQL mitigation: strictly verify protocol and pass URL object to fetch
229
+ if (safeUrl.protocol !== 'http:' && safeUrl.protocol !== 'https:') {
230
+ throw new Error('Only HTTP and HTTPS protocols are allowed');
231
+ }
232
+
233
+ const response = await fetch(safeUrl, {
234
+ ...currentOptions,
235
+ redirect: 'manual'
236
+ });
237
+
238
+ // Handle redirects (301, 302, 303, 307, 308)
239
+ if (response.status >= 300 && response.status < 400) {
240
+ const location = response.headers.get('location');
241
+ if (!location) return response;
242
+
243
+ const nextUrl = new URL(location, safeUrl.href);
244
+ const isCrossOrigin = nextUrl.origin !== currentUrl.origin;
245
+
246
+ // Update options for the next request (shallow copy)
247
+ const nextOptions = { ...currentOptions };
248
+ if (nextOptions.headers) {
249
+ nextOptions.headers = { ...nextOptions.headers };
250
+ }
251
+
252
+ // Strip sensitive headers on cross-origin redirects
253
+ if (isCrossOrigin && nextOptions.headers) {
254
+ const sensitiveHeaders = ['authorization', 'x-api-key', 'token', 'cookie', 'proxy-authorization'];
255
+ for (const h of Object.keys(nextOptions.headers)) {
256
+ if (sensitiveHeaders.includes(h.toLowerCase())) {
257
+ delete nextOptions.headers[h];
258
+ }
259
+ }
260
+ }
261
+
262
+ // Standards compliance: 301, 302, 303 redirects switch to GET and drop body
263
+ if ([301, 302, 303].includes(response.status)) {
264
+ nextOptions.method = 'GET';
265
+ delete nextOptions.body;
266
+ if (nextOptions.headers) {
267
+ for (const h of Object.keys(nextOptions.headers)) {
268
+ if (['content-type', 'content-length'].includes(h.toLowerCase())) {
269
+ delete nextOptions.headers[h];
270
+ }
271
+ }
272
+ }
273
+ }
274
+
275
+ currentUrl = nextUrl;
276
+ currentOptions = nextOptions;
277
+ redirectCount++;
278
+ continue;
279
+ }
280
+
281
+ return response;
282
+ }
283
+
284
+ throw new Error('Too many redirects');
285
+ }
286
+
287
+ /**
288
+ * Sets up navigation protection for a Playwright context.
289
+ * Intercepts requests and validates destination URLs.
290
+ * @param {object} context Playwright context.
291
+ */
292
+ async function setupNavigationProtection(context) {
293
+ if (ALLOW_PRIVATE_NETWORKS) return;
294
+
295
+ await context.route('**/*', async (route) => {
296
+ const request = route.request();
297
+ // Only validate main frame navigations for performance and to avoid breaking sub-resources
298
+ if (request.isNavigationRequest() && request.frame() === request.frame().page().mainFrame()) {
299
+ const url = request.url();
300
+ const currentUrl = request.frame().url();
301
+
302
+ try {
303
+ // If it's a same-origin navigation, skip validation for speed
304
+ if (currentUrl && currentUrl !== 'about:blank') {
305
+ const u1 = new URL(url);
306
+ const u2 = new URL(currentUrl);
307
+ if (u1.origin === u2.origin) {
308
+ return route.continue();
309
+ }
310
+ }
311
+
312
+ await validateUrl(url);
313
+ return route.continue();
314
+ } catch (err) {
315
+ console.error(`[SECURITY] Navigation to ${url} blocked: ${err.message}`);
316
+ return route.abort('blockedbyclient');
317
+ }
318
+ }
319
+ return route.continue();
320
+ });
321
+ }
322
+
323
+ /**
324
+ * Verifies if a WebSocket origin matches the request host (CSWSH protection).
325
+ * @param {string} origin The Origin header value.
326
+ * @param {string} host The Host header value.
327
+ * @returns {boolean} True if the origin is valid or missing.
328
+ */
329
+ function isValidWebSocketOrigin(origin, host) {
330
+ if (!origin) return true;
331
+ try {
332
+ const originHost = new URL(origin).host;
333
+ return !!(originHost && host && originHost === host);
334
+ } catch (e) {
335
+ return false;
336
+ }
337
+ }
338
+
339
+ module.exports = { validateUrl, isPrivateIP, isValidWebSocketOrigin, fetchWithRedirectValidation, setupNavigationProtection };