browserclaw 0.4.0 → 0.4.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 CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  <p align="center">
4
4
  <a href="https://www.npmjs.com/package/browserclaw"><img src="https://img.shields.io/npm/v/browserclaw.svg" alt="npm version" /></a>
5
+ <a href="https://www.npmjs.com/package/browserclaw"><img src="https://img.shields.io/npm/dw/browserclaw" alt="npm downloads" /></a>
6
+ <a href="https://github.com/idan-rubin/browserclaw/stargazers"><img src="https://img.shields.io/github/stars/idan-rubin/browserclaw" alt="GitHub stars" /></a>
5
7
  <a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT" /></a>
6
8
  </p>
7
9
 
package/dist/index.cjs CHANGED
@@ -349,39 +349,109 @@ function resolveUserDataDir(profileName) {
349
349
  const configDir = process.env.XDG_CONFIG_HOME ?? path__default.default.join(os__default.default.homedir(), ".config");
350
350
  return path__default.default.join(configDir, "browserclaw", "profiles", profileName, "user-data");
351
351
  }
352
- async function isChromeReachable(cdpUrl, timeoutMs = 500, authToken) {
353
- const ctrl = new AbortController();
354
- const t = setTimeout(() => ctrl.abort(), timeoutMs);
352
+ function isWebSocketUrl(url) {
355
353
  try {
356
- const headers = {};
357
- if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
358
- const res = await fetch(`${cdpUrl.replace(/\/+$/, "")}/json/version`, { signal: ctrl.signal, headers });
359
- if (!res.ok) return false;
360
- const data = await res.json();
361
- return data != null && typeof data === "object";
354
+ const parsed = new URL(url);
355
+ return parsed.protocol === "ws:" || parsed.protocol === "wss:";
362
356
  } catch {
363
357
  return false;
364
- } finally {
365
- clearTimeout(t);
366
358
  }
367
359
  }
368
- async function getChromeWebSocketUrl(cdpUrl, timeoutMs = 500, authToken) {
360
+ function isLoopbackHost(hostname) {
361
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
362
+ }
363
+ function normalizeCdpWsUrl(wsUrl, cdpUrl) {
364
+ const ws = new URL(wsUrl);
365
+ const cdp = new URL(cdpUrl);
366
+ const isWildcardBind = ws.hostname === "0.0.0.0" || ws.hostname === "[::]";
367
+ if ((isLoopbackHost(ws.hostname) || isWildcardBind) && !isLoopbackHost(cdp.hostname)) {
368
+ ws.hostname = cdp.hostname;
369
+ const cdpPort = cdp.port || (cdp.protocol === "https:" ? "443" : "80");
370
+ ws.port = cdpPort;
371
+ ws.protocol = cdp.protocol === "https:" ? "wss:" : "ws:";
372
+ }
373
+ if (cdp.protocol === "https:" && ws.protocol === "ws:") ws.protocol = "wss:";
374
+ if (!ws.username && !ws.password && (cdp.username || cdp.password)) {
375
+ ws.username = cdp.username;
376
+ ws.password = cdp.password;
377
+ }
378
+ for (const [key, value] of cdp.searchParams.entries()) {
379
+ if (!ws.searchParams.has(key)) ws.searchParams.append(key, value);
380
+ }
381
+ return ws.toString();
382
+ }
383
+ function normalizeCdpHttpBaseForJsonEndpoints(cdpUrl) {
384
+ try {
385
+ const url = new URL(cdpUrl);
386
+ if (url.protocol === "ws:") url.protocol = "http:";
387
+ else if (url.protocol === "wss:") url.protocol = "https:";
388
+ url.pathname = url.pathname.replace(/\/devtools\/browser\/.*$/, "");
389
+ url.pathname = url.pathname.replace(/\/cdp$/, "");
390
+ return url.toString().replace(/\/$/, "");
391
+ } catch {
392
+ return cdpUrl.replace(/^ws:/, "http:").replace(/^wss:/, "https:").replace(/\/devtools\/browser\/.*$/, "").replace(/\/cdp$/, "").replace(/\/$/, "");
393
+ }
394
+ }
395
+ function appendCdpPath(cdpUrl, cdpPath) {
396
+ const url = new URL(cdpUrl);
397
+ url.pathname = `${url.pathname.replace(/\/$/, "")}${cdpPath.startsWith("/") ? cdpPath : `/${cdpPath}`}`;
398
+ return url.toString();
399
+ }
400
+ async function canOpenWebSocket(url, timeoutMs) {
401
+ return new Promise((resolve2) => {
402
+ let settled = false;
403
+ const finish = (value) => {
404
+ if (settled) return;
405
+ settled = true;
406
+ clearTimeout(timer);
407
+ try {
408
+ ws.close();
409
+ } catch {
410
+ }
411
+ resolve2(value);
412
+ };
413
+ const timer = setTimeout(() => finish(false), Math.max(50, timeoutMs + 25));
414
+ let ws;
415
+ try {
416
+ ws = new WebSocket(url);
417
+ } catch {
418
+ finish(false);
419
+ return;
420
+ }
421
+ ws.onopen = () => finish(true);
422
+ ws.onerror = () => finish(false);
423
+ });
424
+ }
425
+ async function fetchChromeVersion(cdpUrl, timeoutMs = 500, authToken) {
369
426
  const ctrl = new AbortController();
370
427
  const t = setTimeout(() => ctrl.abort(), timeoutMs);
371
428
  try {
429
+ const httpBase = isWebSocketUrl(cdpUrl) ? normalizeCdpHttpBaseForJsonEndpoints(cdpUrl) : cdpUrl;
372
430
  const headers = {};
373
431
  if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
374
- const res = await fetch(`${cdpUrl.replace(/\/+$/, "")}/json/version`, { signal: ctrl.signal, headers });
432
+ const res = await fetch(appendCdpPath(httpBase, "/json/version"), { signal: ctrl.signal, headers });
375
433
  if (!res.ok) return null;
376
434
  const data = await res.json();
377
435
  if (!data || typeof data !== "object") return null;
378
- return String(data?.webSocketDebuggerUrl ?? "").trim() || null;
436
+ return data;
379
437
  } catch {
380
438
  return null;
381
439
  } finally {
382
440
  clearTimeout(t);
383
441
  }
384
442
  }
443
+ async function isChromeReachable(cdpUrl, timeoutMs = 500, authToken) {
444
+ if (isWebSocketUrl(cdpUrl)) return await canOpenWebSocket(cdpUrl, timeoutMs);
445
+ const version = await fetchChromeVersion(cdpUrl, timeoutMs, authToken);
446
+ return Boolean(version);
447
+ }
448
+ async function getChromeWebSocketUrl(cdpUrl, timeoutMs = 500, authToken) {
449
+ if (isWebSocketUrl(cdpUrl)) return cdpUrl;
450
+ const version = await fetchChromeVersion(cdpUrl, timeoutMs, authToken);
451
+ const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim();
452
+ if (!wsUrl) return null;
453
+ return normalizeCdpWsUrl(wsUrl, cdpUrl);
454
+ }
385
455
  async function isChromeCdpReady(cdpUrl, timeoutMs = 500, handshakeTimeoutMs = 800) {
386
456
  const wsUrl = await getChromeWebSocketUrl(cdpUrl, timeoutMs);
387
457
  if (!wsUrl) return false;
@@ -1267,6 +1337,20 @@ function withBrowserNavigationPolicy(ssrfPolicy) {
1267
1337
  }
1268
1338
  var NETWORK_NAVIGATION_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
1269
1339
  var SAFE_NON_NETWORK_URLS = /* @__PURE__ */ new Set(["about:blank"]);
1340
+ var PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
1341
+ function isAllowedNonNetworkNavigationUrl(parsed) {
1342
+ return SAFE_NON_NETWORK_URLS.has(parsed.href);
1343
+ }
1344
+ function isPrivateNetworkAllowedByPolicy(policy) {
1345
+ return policy?.dangerouslyAllowPrivateNetwork === true || policy?.allowPrivateNetwork === true;
1346
+ }
1347
+ function hasProxyEnvConfigured(env = process.env) {
1348
+ for (const key of PROXY_ENV_KEYS) {
1349
+ const value = env[key];
1350
+ if (typeof value === "string" && value.trim().length > 0) return true;
1351
+ }
1352
+ return false;
1353
+ }
1270
1354
  async function assertBrowserNavigationAllowed(opts) {
1271
1355
  const rawUrl = String(opts.url ?? "").trim();
1272
1356
  let parsed;
@@ -1276,9 +1360,14 @@ async function assertBrowserNavigationAllowed(opts) {
1276
1360
  throw new InvalidBrowserNavigationUrlError(`Invalid URL: "${rawUrl}"`);
1277
1361
  }
1278
1362
  if (!NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol)) {
1279
- if (SAFE_NON_NETWORK_URLS.has(parsed.href)) return;
1363
+ if (isAllowedNonNetworkNavigationUrl(parsed)) return;
1280
1364
  throw new InvalidBrowserNavigationUrlError(`Navigation blocked: unsupported protocol "${parsed.protocol}"`);
1281
1365
  }
1366
+ if (hasProxyEnvConfigured() && !isPrivateNetworkAllowedByPolicy(opts.ssrfPolicy)) {
1367
+ throw new InvalidBrowserNavigationUrlError(
1368
+ "Navigation blocked: strict browser SSRF policy cannot be enforced while env proxy variables are set"
1369
+ );
1370
+ }
1282
1371
  const policy = opts.ssrfPolicy;
1283
1372
  if (policy?.dangerouslyAllowPrivateNetwork ?? policy?.allowPrivateNetwork ?? true) return;
1284
1373
  const allowedHostnames = [
@@ -1483,10 +1572,24 @@ async function assertBrowserNavigationResultAllowed(opts) {
1483
1572
  } catch {
1484
1573
  return;
1485
1574
  }
1486
- if (NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol) || SAFE_NON_NETWORK_URLS.has(parsed.href)) {
1575
+ if (NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol) || isAllowedNonNetworkNavigationUrl(parsed)) {
1487
1576
  await assertBrowserNavigationAllowed(opts);
1488
1577
  }
1489
1578
  }
1579
+ async function assertBrowserNavigationRedirectChainAllowed(opts) {
1580
+ const chain = [];
1581
+ let current = opts.request ?? null;
1582
+ while (current) {
1583
+ chain.push(current.url());
1584
+ current = current.redirectedFrom();
1585
+ }
1586
+ for (const url of [...chain].reverse()) {
1587
+ await assertBrowserNavigationAllowed({ url, lookupFn: opts.lookupFn, ssrfPolicy: opts.ssrfPolicy });
1588
+ }
1589
+ }
1590
+ function requiresInspectableBrowserNavigationRedirects(ssrfPolicy) {
1591
+ return !isPrivateNetworkAllowedByPolicy(ssrfPolicy);
1592
+ }
1490
1593
 
1491
1594
  // src/actions/interaction.ts
1492
1595
  async function clickViaPlaywright(opts) {
@@ -1691,7 +1794,8 @@ async function navigateViaPlaywright(opts) {
1691
1794
  await assertBrowserNavigationAllowed({ url, ssrfPolicy: policy });
1692
1795
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1693
1796
  ensurePageState(page);
1694
- await page.goto(url, { timeout: normalizeTimeoutMs(opts.timeoutMs, 2e4) });
1797
+ const response = await page.goto(url, { timeout: normalizeTimeoutMs(opts.timeoutMs, 2e4) });
1798
+ await assertBrowserNavigationRedirectChainAllowed({ request: response?.request(), ...withBrowserNavigationPolicy(policy) });
1695
1799
  const finalUrl = page.url();
1696
1800
  await assertBrowserNavigationResultAllowed({ url: finalUrl, ssrfPolicy: policy });
1697
1801
  return { url: finalUrl };
@@ -1722,7 +1826,9 @@ async function createPageViaPlaywright(opts) {
1722
1826
  const page = await context.newPage();
1723
1827
  ensurePageState(page);
1724
1828
  if (targetUrl !== "about:blank") {
1725
- await page.goto(targetUrl, { timeout: normalizeTimeoutMs(void 0, 2e4) });
1829
+ const navigationPolicy = withBrowserNavigationPolicy(policy);
1830
+ const response = await page.goto(targetUrl, { timeout: normalizeTimeoutMs(void 0, 2e4) });
1831
+ await assertBrowserNavigationRedirectChainAllowed({ request: response?.request(), ...navigationPolicy });
1726
1832
  await assertBrowserNavigationResultAllowed({ url: page.url(), ssrfPolicy: policy });
1727
1833
  }
1728
1834
  const tid = await pageTargetId(page).catch(() => null);
@@ -3252,7 +3358,13 @@ exports.BrowserClaw = BrowserClaw;
3252
3358
  exports.CrawlPage = CrawlPage;
3253
3359
  exports.InvalidBrowserNavigationUrlError = InvalidBrowserNavigationUrlError;
3254
3360
  exports.assertBrowserNavigationAllowed = assertBrowserNavigationAllowed;
3361
+ exports.assertBrowserNavigationRedirectChainAllowed = assertBrowserNavigationRedirectChainAllowed;
3362
+ exports.assertBrowserNavigationResultAllowed = assertBrowserNavigationResultAllowed;
3363
+ exports.getChromeWebSocketUrl = getChromeWebSocketUrl;
3255
3364
  exports.isChromeCdpReady = isChromeCdpReady;
3365
+ exports.isChromeReachable = isChromeReachable;
3366
+ exports.normalizeCdpHttpBaseForJsonEndpoints = normalizeCdpHttpBaseForJsonEndpoints;
3367
+ exports.requiresInspectableBrowserNavigationRedirects = requiresInspectableBrowserNavigationRedirects;
3256
3368
  exports.withBrowserNavigationPolicy = withBrowserNavigationPolicy;
3257
3369
  //# sourceMappingURL=index.cjs.map
3258
3370
  //# sourceMappingURL=index.cjs.map