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/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-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">
@@ -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
- $$data: createSafeProxy({
159
- html: () => html || '',
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.11.3",
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": "^7.3.0"
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
- fetch(webhookUrl, {
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
  }
@@ -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
- if (lowerHost === 'localhost' || lowerHost.endsWith('.localhost')) {
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 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
- }
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
- // Rethrow if it's the specific restricted error
129
- if (e.message === 'Access to private network is restricted') {
130
- throw e;
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
- // 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.
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 };