figranium 0.11.3 → 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/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-CGxJAXB1.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-CTQxB0fw.css">
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figranium",
3
- "version": "0.11.3",
3
+ "version": "0.12.0",
4
4
  "main": "index.js",
5
5
  "bin": {
6
6
  "figranium": "bin/cli.js"
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
- fetch(webhookUrl, {
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 already an IP address, check it directly
122
- if (net.isIP(hostname)) {
123
- if (isPrivateIP(hostname)) {
124
- throw new Error('Access to private network is restricted');
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
- // Rethrow if it's the specific restricted error
129
- if (e.message === 'Access to private network is restricted') {
130
- throw e;
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
- // If we can't resolve it and it's not an IP, we allow it to proceed
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 };