browserclaw 0.11.0 → 0.11.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/dist/index.js CHANGED
@@ -1207,11 +1207,14 @@ function safeWriteJson(filePath, data) {
1207
1207
  function setDeep(obj, keys, value) {
1208
1208
  let node = obj;
1209
1209
  for (const key of keys.slice(0, -1)) {
1210
+ if (key === "__proto__" || key === "constructor" || key === "prototype") return;
1210
1211
  const next = node[key];
1211
1212
  if (typeof next !== "object" || next === null || Array.isArray(next)) node[key] = {};
1212
1213
  node = node[key];
1213
1214
  }
1214
- node[keys[keys.length - 1]] = value;
1215
+ const lastKey = keys[keys.length - 1];
1216
+ if (lastKey === "__proto__" || lastKey === "constructor" || lastKey === "prototype") return;
1217
+ node[lastKey] = value;
1215
1218
  }
1216
1219
  function parseHexRgbToSignedArgbInt(hex) {
1217
1220
  const cleaned = hex.trim().replace(/^#/, "");
@@ -1265,7 +1268,8 @@ function isWebSocketUrl(url) {
1265
1268
  }
1266
1269
  }
1267
1270
  function isLoopbackHost(hostname) {
1268
- return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
1271
+ const h = hostname.replace(/\.+$/, "");
1272
+ return h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "[::1]";
1269
1273
  }
1270
1274
  var PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
1271
1275
  function hasProxyEnvConfigured(env = process.env) {
@@ -1917,6 +1921,56 @@ function bumpDownloadArmId() {
1917
1921
  nextDownloadArmId += 1;
1918
1922
  return nextDownloadArmId;
1919
1923
  }
1924
+ var BlockedBrowserTargetError = class extends Error {
1925
+ constructor() {
1926
+ super("Browser target is unavailable after SSRF policy blocked its navigation.");
1927
+ this.name = "BlockedBrowserTargetError";
1928
+ }
1929
+ };
1930
+ var blockedTargetsByCdpUrl = /* @__PURE__ */ new Set();
1931
+ var blockedPageRefsByCdpUrl = /* @__PURE__ */ new Map();
1932
+ function blockedTargetKey(cdpUrl, targetId) {
1933
+ return `${normalizeCdpUrl(cdpUrl)}::${targetId}`;
1934
+ }
1935
+ function isBlockedTarget(cdpUrl, targetId) {
1936
+ const normalized = targetId?.trim() ?? "";
1937
+ if (normalized === "") return false;
1938
+ return blockedTargetsByCdpUrl.has(blockedTargetKey(cdpUrl, normalized));
1939
+ }
1940
+ function markTargetBlocked(cdpUrl, targetId) {
1941
+ const normalized = targetId?.trim() ?? "";
1942
+ if (normalized === "") return;
1943
+ blockedTargetsByCdpUrl.add(blockedTargetKey(cdpUrl, normalized));
1944
+ }
1945
+ function clearBlockedTarget(cdpUrl, targetId) {
1946
+ const normalized = targetId?.trim() ?? "";
1947
+ if (normalized === "") return;
1948
+ blockedTargetsByCdpUrl.delete(blockedTargetKey(cdpUrl, normalized));
1949
+ }
1950
+ function hasBlockedTargetsForCdpUrl(cdpUrl) {
1951
+ const prefix = `${normalizeCdpUrl(cdpUrl)}::`;
1952
+ for (const key of blockedTargetsByCdpUrl) {
1953
+ if (key.startsWith(prefix)) return true;
1954
+ }
1955
+ return false;
1956
+ }
1957
+ function blockedPageRefsForCdpUrl(cdpUrl) {
1958
+ const normalized = normalizeCdpUrl(cdpUrl);
1959
+ const existing = blockedPageRefsByCdpUrl.get(normalized);
1960
+ if (existing) return existing;
1961
+ const created = /* @__PURE__ */ new WeakSet();
1962
+ blockedPageRefsByCdpUrl.set(normalized, created);
1963
+ return created;
1964
+ }
1965
+ function isBlockedPageRef(cdpUrl, page) {
1966
+ return blockedPageRefsByCdpUrl.get(normalizeCdpUrl(cdpUrl))?.has(page) ?? false;
1967
+ }
1968
+ function markPageRefBlocked(cdpUrl, page) {
1969
+ blockedPageRefsForCdpUrl(cdpUrl).add(page);
1970
+ }
1971
+ function clearBlockedPageRef(cdpUrl, page) {
1972
+ blockedPageRefsByCdpUrl.get(normalizeCdpUrl(cdpUrl))?.delete(page);
1973
+ }
1920
1974
  function ensureContextState(context) {
1921
1975
  const existing = contextStates.get(context);
1922
1976
  if (existing) return existing;
@@ -2357,11 +2411,43 @@ async function findPageByTargetId(browser, targetId, cdpUrl) {
2357
2411
  if (!resolvedViaCdp && pages.length === 1) return pages[0] ?? null;
2358
2412
  return null;
2359
2413
  }
2414
+ async function partitionAccessiblePages(opts) {
2415
+ const accessible = [];
2416
+ let blockedCount = 0;
2417
+ for (const page of opts.pages) {
2418
+ if (isBlockedPageRef(opts.cdpUrl, page)) {
2419
+ blockedCount += 1;
2420
+ continue;
2421
+ }
2422
+ const targetId = await pageTargetId(page).catch(() => null);
2423
+ if (targetId === null || targetId === "") {
2424
+ if (hasBlockedTargetsForCdpUrl(opts.cdpUrl)) {
2425
+ blockedCount += 1;
2426
+ continue;
2427
+ }
2428
+ accessible.push(page);
2429
+ continue;
2430
+ }
2431
+ if (isBlockedTarget(opts.cdpUrl, targetId)) {
2432
+ blockedCount += 1;
2433
+ continue;
2434
+ }
2435
+ accessible.push(page);
2436
+ }
2437
+ return { accessible, blockedCount };
2438
+ }
2360
2439
  async function getPageForTargetId(opts) {
2440
+ if (opts.targetId !== void 0 && opts.targetId !== "" && isBlockedTarget(opts.cdpUrl, opts.targetId))
2441
+ throw new BlockedBrowserTargetError();
2361
2442
  const { browser } = await connectBrowser(opts.cdpUrl);
2362
2443
  const pages = getAllPages(browser);
2363
2444
  if (!pages.length) throw new Error("No pages available in the connected browser.");
2364
- const first = pages[0];
2445
+ const { accessible, blockedCount } = await partitionAccessiblePages({ cdpUrl: opts.cdpUrl, pages });
2446
+ if (!accessible.length) {
2447
+ if (blockedCount > 0) throw new BlockedBrowserTargetError();
2448
+ throw new Error("No pages available in the connected browser.");
2449
+ }
2450
+ const first = accessible[0];
2365
2451
  if (opts.targetId === void 0 || opts.targetId === "") return first;
2366
2452
  const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
2367
2453
  if (!found) {
@@ -2370,6 +2456,10 @@ async function getPageForTargetId(opts) {
2370
2456
  `Tab not found (targetId: ${opts.targetId}). Call browser.tabs() to list open tabs.`
2371
2457
  );
2372
2458
  }
2459
+ if (isBlockedPageRef(opts.cdpUrl, found)) throw new BlockedBrowserTargetError();
2460
+ const foundTargetId = await pageTargetId(found).catch(() => null);
2461
+ if (foundTargetId !== null && foundTargetId !== "" && isBlockedTarget(opts.cdpUrl, foundTargetId))
2462
+ throw new BlockedBrowserTargetError();
2373
2463
  return found;
2374
2464
  }
2375
2465
  async function resolvePageByTargetIdOrThrow(opts) {
@@ -3497,6 +3587,82 @@ function isRetryableNavigateError(err) {
3497
3587
  const msg = typeof err === "string" ? err.toLowerCase() : err instanceof Error ? err.message.toLowerCase() : "";
3498
3588
  return msg.includes("frame has been detached") || msg.includes("target page, context or browser has been closed");
3499
3589
  }
3590
+ function isPolicyDenyNavigationError(err) {
3591
+ return err instanceof InvalidBrowserNavigationUrlError;
3592
+ }
3593
+ function isTopLevelNavigationRequest(page, request) {
3594
+ if (!request.isNavigationRequest()) return false;
3595
+ try {
3596
+ return request.frame() === page.mainFrame();
3597
+ } catch {
3598
+ return true;
3599
+ }
3600
+ }
3601
+ async function closeBlockedNavigationTarget(opts) {
3602
+ markPageRefBlocked(opts.cdpUrl, opts.page);
3603
+ const resolvedTargetId = await pageTargetId(opts.page).catch(() => null);
3604
+ const fallbackTargetId = opts.targetId?.trim() ?? "";
3605
+ const targetIdToBlock = resolvedTargetId ?? fallbackTargetId;
3606
+ if (targetIdToBlock) markTargetBlocked(opts.cdpUrl, targetIdToBlock);
3607
+ await opts.page.close().catch((e) => {
3608
+ console.warn("[browserclaw] failed to close blocked page", e);
3609
+ });
3610
+ }
3611
+ async function assertPageNavigationCompletedSafely(opts) {
3612
+ const navigationPolicy = withBrowserNavigationPolicy(opts.ssrfPolicy);
3613
+ try {
3614
+ await assertBrowserNavigationRedirectChainAllowed({ request: opts.response?.request(), ...navigationPolicy });
3615
+ await assertBrowserNavigationResultAllowed({ url: opts.page.url(), ...navigationPolicy });
3616
+ } catch (err) {
3617
+ if (isPolicyDenyNavigationError(err))
3618
+ await closeBlockedNavigationTarget({ cdpUrl: opts.cdpUrl, page: opts.page, targetId: opts.targetId });
3619
+ throw err;
3620
+ }
3621
+ }
3622
+ async function gotoPageWithNavigationGuard(opts) {
3623
+ const navigationPolicy = withBrowserNavigationPolicy(opts.ssrfPolicy);
3624
+ const state = { blocked: null };
3625
+ const handler = async (route, request) => {
3626
+ if (state.blocked !== null) {
3627
+ await route.abort().catch((e) => {
3628
+ console.warn("[browserclaw] route abort failed", e);
3629
+ });
3630
+ return;
3631
+ }
3632
+ if (!isTopLevelNavigationRequest(opts.page, request)) {
3633
+ await route.continue();
3634
+ return;
3635
+ }
3636
+ try {
3637
+ await assertBrowserNavigationAllowed({ url: request.url(), ...navigationPolicy });
3638
+ } catch (err) {
3639
+ if (isPolicyDenyNavigationError(err)) {
3640
+ state.blocked = err;
3641
+ await route.abort().catch((e) => {
3642
+ console.warn("[browserclaw] route abort failed", e);
3643
+ });
3644
+ return;
3645
+ }
3646
+ throw err;
3647
+ }
3648
+ await route.continue();
3649
+ };
3650
+ await opts.page.route("**", handler);
3651
+ try {
3652
+ const response = await opts.page.goto(opts.url, { timeout: opts.timeoutMs });
3653
+ if (state.blocked !== null) throw state.blocked;
3654
+ return response;
3655
+ } catch (err) {
3656
+ if (state.blocked !== null) throw state.blocked;
3657
+ throw err;
3658
+ } finally {
3659
+ await opts.page.unroute("**", handler).catch((e) => {
3660
+ console.warn("[browserclaw] route unroute failed", e);
3661
+ });
3662
+ if (state.blocked !== null)
3663
+ await closeBlockedNavigationTarget({ cdpUrl: opts.cdpUrl, page: opts.page, targetId: opts.targetId });
3664
+ }
3665
+ }
3500
3666
  async function navigateViaPlaywright(opts) {
3501
3667
  const url = opts.url.trim();
3502
3668
  if (!url) throw new Error("url is required");
@@ -3505,7 +3671,14 @@ async function navigateViaPlaywright(opts) {
3505
3671
  const timeout = Math.max(1e3, Math.min(12e4, opts.timeoutMs ?? 2e4));
3506
3672
  let page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
3507
3673
  ensurePageState(page);
3508
- const navigate = async () => await page.goto(url, { timeout });
3674
+ const navigate = async () => await gotoPageWithNavigationGuard({
3675
+ cdpUrl: opts.cdpUrl,
3676
+ page,
3677
+ url,
3678
+ timeoutMs: timeout,
3679
+ ssrfPolicy: policy,
3680
+ targetId: opts.targetId
3681
+ });
3509
3682
  let response;
3510
3683
  try {
3511
3684
  response = await navigate();
@@ -3519,21 +3692,23 @@ async function navigateViaPlaywright(opts) {
3519
3692
  ensurePageState(page);
3520
3693
  response = await navigate();
3521
3694
  }
3522
- await assertBrowserNavigationRedirectChainAllowed({
3523
- request: response?.request(),
3524
- ...withBrowserNavigationPolicy(policy)
3695
+ await assertPageNavigationCompletedSafely({
3696
+ cdpUrl: opts.cdpUrl,
3697
+ page,
3698
+ response,
3699
+ ssrfPolicy: policy,
3700
+ targetId: opts.targetId
3525
3701
  });
3526
- const finalUrl = page.url();
3527
- await assertBrowserNavigationResultAllowed({ url: finalUrl, ...withBrowserNavigationPolicy(policy) });
3528
- return { url: finalUrl };
3702
+ return { url: page.url() };
3529
3703
  }
3530
3704
  async function listPagesViaPlaywright(opts) {
3531
3705
  const { browser } = await connectBrowser(opts.cdpUrl);
3532
3706
  const pages = getAllPages(browser);
3533
3707
  const results = [];
3534
3708
  for (const page of pages) {
3709
+ if (isBlockedPageRef(opts.cdpUrl, page)) continue;
3535
3710
  const tid = await pageTargetId(page).catch(() => null);
3536
- if (tid !== null && tid !== "")
3711
+ if (tid !== null && tid !== "" && !isBlockedTarget(opts.cdpUrl, tid))
3537
3712
  results.push({
3538
3713
  targetId: tid,
3539
3714
  title: await page.title().catch(() => ""),
@@ -3549,18 +3724,35 @@ async function createPageViaPlaywright(opts) {
3549
3724
  ensureContextState(context);
3550
3725
  const page = await context.newPage();
3551
3726
  ensurePageState(page);
3727
+ clearBlockedPageRef(opts.cdpUrl, page);
3728
+ const createdTargetId = await pageTargetId(page).catch(() => null);
3729
+ clearBlockedTarget(opts.cdpUrl, createdTargetId ?? void 0);
3552
3730
  const targetUrl = (opts.url ?? "").trim() || "about:blank";
3553
3731
  const policy = opts.allowInternal === true ? { ...opts.ssrfPolicy, dangerouslyAllowPrivateNetwork: true } : opts.ssrfPolicy;
3554
3732
  if (targetUrl !== "about:blank") {
3555
- const navigationPolicy = withBrowserNavigationPolicy(policy);
3556
- await assertBrowserNavigationAllowed({ url: targetUrl, ...navigationPolicy });
3557
- await assertBrowserNavigationRedirectChainAllowed({
3558
- request: (await page.goto(targetUrl, { timeout: 3e4 }).catch(() => null))?.request(),
3559
- ...navigationPolicy
3733
+ await assertBrowserNavigationAllowed({ url: targetUrl, ...withBrowserNavigationPolicy(policy) });
3734
+ let response = null;
3735
+ try {
3736
+ response = await gotoPageWithNavigationGuard({
3737
+ cdpUrl: opts.cdpUrl,
3738
+ page,
3739
+ url: targetUrl,
3740
+ timeoutMs: 3e4,
3741
+ ssrfPolicy: policy,
3742
+ targetId: createdTargetId ?? void 0
3743
+ });
3744
+ } catch (err) {
3745
+ if (isPolicyDenyNavigationError(err) || err instanceof BlockedBrowserTargetError) throw err;
3746
+ }
3747
+ await assertPageNavigationCompletedSafely({
3748
+ cdpUrl: opts.cdpUrl,
3749
+ page,
3750
+ response,
3751
+ ssrfPolicy: policy,
3752
+ targetId: createdTargetId ?? void 0
3560
3753
  });
3561
- await assertBrowserNavigationResultAllowed({ url: page.url(), ...navigationPolicy });
3562
3754
  }
3563
- const tid = await pageTargetId(page).catch(() => null);
3755
+ const tid = createdTargetId ?? await pageTargetId(page).catch(() => null);
3564
3756
  if (tid === null || tid === "") throw new Error("Failed to get targetId for new page");
3565
3757
  return {
3566
3758
  targetId: tid,