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.cjs CHANGED
@@ -1218,11 +1218,14 @@ function safeWriteJson(filePath, data) {
1218
1218
  function setDeep(obj, keys, value) {
1219
1219
  let node = obj;
1220
1220
  for (const key of keys.slice(0, -1)) {
1221
+ if (key === "__proto__" || key === "constructor" || key === "prototype") return;
1221
1222
  const next = node[key];
1222
1223
  if (typeof next !== "object" || next === null || Array.isArray(next)) node[key] = {};
1223
1224
  node = node[key];
1224
1225
  }
1225
- node[keys[keys.length - 1]] = value;
1226
+ const lastKey = keys[keys.length - 1];
1227
+ if (lastKey === "__proto__" || lastKey === "constructor" || lastKey === "prototype") return;
1228
+ node[lastKey] = value;
1226
1229
  }
1227
1230
  function parseHexRgbToSignedArgbInt(hex) {
1228
1231
  const cleaned = hex.trim().replace(/^#/, "");
@@ -1276,7 +1279,8 @@ function isWebSocketUrl(url) {
1276
1279
  }
1277
1280
  }
1278
1281
  function isLoopbackHost(hostname) {
1279
- return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
1282
+ const h = hostname.replace(/\.+$/, "");
1283
+ return h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "[::1]";
1280
1284
  }
1281
1285
  var PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
1282
1286
  function hasProxyEnvConfigured(env = process.env) {
@@ -1928,6 +1932,56 @@ function bumpDownloadArmId() {
1928
1932
  nextDownloadArmId += 1;
1929
1933
  return nextDownloadArmId;
1930
1934
  }
1935
+ var BlockedBrowserTargetError = class extends Error {
1936
+ constructor() {
1937
+ super("Browser target is unavailable after SSRF policy blocked its navigation.");
1938
+ this.name = "BlockedBrowserTargetError";
1939
+ }
1940
+ };
1941
+ var blockedTargetsByCdpUrl = /* @__PURE__ */ new Set();
1942
+ var blockedPageRefsByCdpUrl = /* @__PURE__ */ new Map();
1943
+ function blockedTargetKey(cdpUrl, targetId) {
1944
+ return `${normalizeCdpUrl(cdpUrl)}::${targetId}`;
1945
+ }
1946
+ function isBlockedTarget(cdpUrl, targetId) {
1947
+ const normalized = targetId?.trim() ?? "";
1948
+ if (normalized === "") return false;
1949
+ return blockedTargetsByCdpUrl.has(blockedTargetKey(cdpUrl, normalized));
1950
+ }
1951
+ function markTargetBlocked(cdpUrl, targetId) {
1952
+ const normalized = targetId?.trim() ?? "";
1953
+ if (normalized === "") return;
1954
+ blockedTargetsByCdpUrl.add(blockedTargetKey(cdpUrl, normalized));
1955
+ }
1956
+ function clearBlockedTarget(cdpUrl, targetId) {
1957
+ const normalized = targetId?.trim() ?? "";
1958
+ if (normalized === "") return;
1959
+ blockedTargetsByCdpUrl.delete(blockedTargetKey(cdpUrl, normalized));
1960
+ }
1961
+ function hasBlockedTargetsForCdpUrl(cdpUrl) {
1962
+ const prefix = `${normalizeCdpUrl(cdpUrl)}::`;
1963
+ for (const key of blockedTargetsByCdpUrl) {
1964
+ if (key.startsWith(prefix)) return true;
1965
+ }
1966
+ return false;
1967
+ }
1968
+ function blockedPageRefsForCdpUrl(cdpUrl) {
1969
+ const normalized = normalizeCdpUrl(cdpUrl);
1970
+ const existing = blockedPageRefsByCdpUrl.get(normalized);
1971
+ if (existing) return existing;
1972
+ const created = /* @__PURE__ */ new WeakSet();
1973
+ blockedPageRefsByCdpUrl.set(normalized, created);
1974
+ return created;
1975
+ }
1976
+ function isBlockedPageRef(cdpUrl, page) {
1977
+ return blockedPageRefsByCdpUrl.get(normalizeCdpUrl(cdpUrl))?.has(page) ?? false;
1978
+ }
1979
+ function markPageRefBlocked(cdpUrl, page) {
1980
+ blockedPageRefsForCdpUrl(cdpUrl).add(page);
1981
+ }
1982
+ function clearBlockedPageRef(cdpUrl, page) {
1983
+ blockedPageRefsByCdpUrl.get(normalizeCdpUrl(cdpUrl))?.delete(page);
1984
+ }
1931
1985
  function ensureContextState(context) {
1932
1986
  const existing = contextStates.get(context);
1933
1987
  if (existing) return existing;
@@ -2368,11 +2422,43 @@ async function findPageByTargetId(browser, targetId, cdpUrl) {
2368
2422
  if (!resolvedViaCdp && pages.length === 1) return pages[0] ?? null;
2369
2423
  return null;
2370
2424
  }
2425
+ async function partitionAccessiblePages(opts) {
2426
+ const accessible = [];
2427
+ let blockedCount = 0;
2428
+ for (const page of opts.pages) {
2429
+ if (isBlockedPageRef(opts.cdpUrl, page)) {
2430
+ blockedCount += 1;
2431
+ continue;
2432
+ }
2433
+ const targetId = await pageTargetId(page).catch(() => null);
2434
+ if (targetId === null || targetId === "") {
2435
+ if (hasBlockedTargetsForCdpUrl(opts.cdpUrl)) {
2436
+ blockedCount += 1;
2437
+ continue;
2438
+ }
2439
+ accessible.push(page);
2440
+ continue;
2441
+ }
2442
+ if (isBlockedTarget(opts.cdpUrl, targetId)) {
2443
+ blockedCount += 1;
2444
+ continue;
2445
+ }
2446
+ accessible.push(page);
2447
+ }
2448
+ return { accessible, blockedCount };
2449
+ }
2371
2450
  async function getPageForTargetId(opts) {
2451
+ if (opts.targetId !== void 0 && opts.targetId !== "" && isBlockedTarget(opts.cdpUrl, opts.targetId))
2452
+ throw new BlockedBrowserTargetError();
2372
2453
  const { browser } = await connectBrowser(opts.cdpUrl);
2373
2454
  const pages = getAllPages(browser);
2374
2455
  if (!pages.length) throw new Error("No pages available in the connected browser.");
2375
- const first = pages[0];
2456
+ const { accessible, blockedCount } = await partitionAccessiblePages({ cdpUrl: opts.cdpUrl, pages });
2457
+ if (!accessible.length) {
2458
+ if (blockedCount > 0) throw new BlockedBrowserTargetError();
2459
+ throw new Error("No pages available in the connected browser.");
2460
+ }
2461
+ const first = accessible[0];
2376
2462
  if (opts.targetId === void 0 || opts.targetId === "") return first;
2377
2463
  const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
2378
2464
  if (!found) {
@@ -2381,6 +2467,10 @@ async function getPageForTargetId(opts) {
2381
2467
  `Tab not found (targetId: ${opts.targetId}). Call browser.tabs() to list open tabs.`
2382
2468
  );
2383
2469
  }
2470
+ if (isBlockedPageRef(opts.cdpUrl, found)) throw new BlockedBrowserTargetError();
2471
+ const foundTargetId = await pageTargetId(found).catch(() => null);
2472
+ if (foundTargetId !== null && foundTargetId !== "" && isBlockedTarget(opts.cdpUrl, foundTargetId))
2473
+ throw new BlockedBrowserTargetError();
2384
2474
  return found;
2385
2475
  }
2386
2476
  async function resolvePageByTargetIdOrThrow(opts) {
@@ -3508,6 +3598,82 @@ function isRetryableNavigateError(err) {
3508
3598
  const msg = typeof err === "string" ? err.toLowerCase() : err instanceof Error ? err.message.toLowerCase() : "";
3509
3599
  return msg.includes("frame has been detached") || msg.includes("target page, context or browser has been closed");
3510
3600
  }
3601
+ function isPolicyDenyNavigationError(err) {
3602
+ return err instanceof InvalidBrowserNavigationUrlError;
3603
+ }
3604
+ function isTopLevelNavigationRequest(page, request) {
3605
+ if (!request.isNavigationRequest()) return false;
3606
+ try {
3607
+ return request.frame() === page.mainFrame();
3608
+ } catch {
3609
+ return true;
3610
+ }
3611
+ }
3612
+ async function closeBlockedNavigationTarget(opts) {
3613
+ markPageRefBlocked(opts.cdpUrl, opts.page);
3614
+ const resolvedTargetId = await pageTargetId(opts.page).catch(() => null);
3615
+ const fallbackTargetId = opts.targetId?.trim() ?? "";
3616
+ const targetIdToBlock = resolvedTargetId ?? fallbackTargetId;
3617
+ if (targetIdToBlock) markTargetBlocked(opts.cdpUrl, targetIdToBlock);
3618
+ await opts.page.close().catch((e) => {
3619
+ console.warn("[browserclaw] failed to close blocked page", e);
3620
+ });
3621
+ }
3622
+ async function assertPageNavigationCompletedSafely(opts) {
3623
+ const navigationPolicy = withBrowserNavigationPolicy(opts.ssrfPolicy);
3624
+ try {
3625
+ await assertBrowserNavigationRedirectChainAllowed({ request: opts.response?.request(), ...navigationPolicy });
3626
+ await assertBrowserNavigationResultAllowed({ url: opts.page.url(), ...navigationPolicy });
3627
+ } catch (err) {
3628
+ if (isPolicyDenyNavigationError(err))
3629
+ await closeBlockedNavigationTarget({ cdpUrl: opts.cdpUrl, page: opts.page, targetId: opts.targetId });
3630
+ throw err;
3631
+ }
3632
+ }
3633
+ async function gotoPageWithNavigationGuard(opts) {
3634
+ const navigationPolicy = withBrowserNavigationPolicy(opts.ssrfPolicy);
3635
+ const state = { blocked: null };
3636
+ const handler = async (route, request) => {
3637
+ if (state.blocked !== null) {
3638
+ await route.abort().catch((e) => {
3639
+ console.warn("[browserclaw] route abort failed", e);
3640
+ });
3641
+ return;
3642
+ }
3643
+ if (!isTopLevelNavigationRequest(opts.page, request)) {
3644
+ await route.continue();
3645
+ return;
3646
+ }
3647
+ try {
3648
+ await assertBrowserNavigationAllowed({ url: request.url(), ...navigationPolicy });
3649
+ } catch (err) {
3650
+ if (isPolicyDenyNavigationError(err)) {
3651
+ state.blocked = err;
3652
+ await route.abort().catch((e) => {
3653
+ console.warn("[browserclaw] route abort failed", e);
3654
+ });
3655
+ return;
3656
+ }
3657
+ throw err;
3658
+ }
3659
+ await route.continue();
3660
+ };
3661
+ await opts.page.route("**", handler);
3662
+ try {
3663
+ const response = await opts.page.goto(opts.url, { timeout: opts.timeoutMs });
3664
+ if (state.blocked !== null) throw state.blocked;
3665
+ return response;
3666
+ } catch (err) {
3667
+ if (state.blocked !== null) throw state.blocked;
3668
+ throw err;
3669
+ } finally {
3670
+ await opts.page.unroute("**", handler).catch((e) => {
3671
+ console.warn("[browserclaw] route unroute failed", e);
3672
+ });
3673
+ if (state.blocked !== null)
3674
+ await closeBlockedNavigationTarget({ cdpUrl: opts.cdpUrl, page: opts.page, targetId: opts.targetId });
3675
+ }
3676
+ }
3511
3677
  async function navigateViaPlaywright(opts) {
3512
3678
  const url = opts.url.trim();
3513
3679
  if (!url) throw new Error("url is required");
@@ -3516,7 +3682,14 @@ async function navigateViaPlaywright(opts) {
3516
3682
  const timeout = Math.max(1e3, Math.min(12e4, opts.timeoutMs ?? 2e4));
3517
3683
  let page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
3518
3684
  ensurePageState(page);
3519
- const navigate = async () => await page.goto(url, { timeout });
3685
+ const navigate = async () => await gotoPageWithNavigationGuard({
3686
+ cdpUrl: opts.cdpUrl,
3687
+ page,
3688
+ url,
3689
+ timeoutMs: timeout,
3690
+ ssrfPolicy: policy,
3691
+ targetId: opts.targetId
3692
+ });
3520
3693
  let response;
3521
3694
  try {
3522
3695
  response = await navigate();
@@ -3530,21 +3703,23 @@ async function navigateViaPlaywright(opts) {
3530
3703
  ensurePageState(page);
3531
3704
  response = await navigate();
3532
3705
  }
3533
- await assertBrowserNavigationRedirectChainAllowed({
3534
- request: response?.request(),
3535
- ...withBrowserNavigationPolicy(policy)
3706
+ await assertPageNavigationCompletedSafely({
3707
+ cdpUrl: opts.cdpUrl,
3708
+ page,
3709
+ response,
3710
+ ssrfPolicy: policy,
3711
+ targetId: opts.targetId
3536
3712
  });
3537
- const finalUrl = page.url();
3538
- await assertBrowserNavigationResultAllowed({ url: finalUrl, ...withBrowserNavigationPolicy(policy) });
3539
- return { url: finalUrl };
3713
+ return { url: page.url() };
3540
3714
  }
3541
3715
  async function listPagesViaPlaywright(opts) {
3542
3716
  const { browser } = await connectBrowser(opts.cdpUrl);
3543
3717
  const pages = getAllPages(browser);
3544
3718
  const results = [];
3545
3719
  for (const page of pages) {
3720
+ if (isBlockedPageRef(opts.cdpUrl, page)) continue;
3546
3721
  const tid = await pageTargetId(page).catch(() => null);
3547
- if (tid !== null && tid !== "")
3722
+ if (tid !== null && tid !== "" && !isBlockedTarget(opts.cdpUrl, tid))
3548
3723
  results.push({
3549
3724
  targetId: tid,
3550
3725
  title: await page.title().catch(() => ""),
@@ -3560,18 +3735,35 @@ async function createPageViaPlaywright(opts) {
3560
3735
  ensureContextState(context);
3561
3736
  const page = await context.newPage();
3562
3737
  ensurePageState(page);
3738
+ clearBlockedPageRef(opts.cdpUrl, page);
3739
+ const createdTargetId = await pageTargetId(page).catch(() => null);
3740
+ clearBlockedTarget(opts.cdpUrl, createdTargetId ?? void 0);
3563
3741
  const targetUrl = (opts.url ?? "").trim() || "about:blank";
3564
3742
  const policy = opts.allowInternal === true ? { ...opts.ssrfPolicy, dangerouslyAllowPrivateNetwork: true } : opts.ssrfPolicy;
3565
3743
  if (targetUrl !== "about:blank") {
3566
- const navigationPolicy = withBrowserNavigationPolicy(policy);
3567
- await assertBrowserNavigationAllowed({ url: targetUrl, ...navigationPolicy });
3568
- await assertBrowserNavigationRedirectChainAllowed({
3569
- request: (await page.goto(targetUrl, { timeout: 3e4 }).catch(() => null))?.request(),
3570
- ...navigationPolicy
3744
+ await assertBrowserNavigationAllowed({ url: targetUrl, ...withBrowserNavigationPolicy(policy) });
3745
+ let response = null;
3746
+ try {
3747
+ response = await gotoPageWithNavigationGuard({
3748
+ cdpUrl: opts.cdpUrl,
3749
+ page,
3750
+ url: targetUrl,
3751
+ timeoutMs: 3e4,
3752
+ ssrfPolicy: policy,
3753
+ targetId: createdTargetId ?? void 0
3754
+ });
3755
+ } catch (err) {
3756
+ if (isPolicyDenyNavigationError(err) || err instanceof BlockedBrowserTargetError) throw err;
3757
+ }
3758
+ await assertPageNavigationCompletedSafely({
3759
+ cdpUrl: opts.cdpUrl,
3760
+ page,
3761
+ response,
3762
+ ssrfPolicy: policy,
3763
+ targetId: createdTargetId ?? void 0
3571
3764
  });
3572
- await assertBrowserNavigationResultAllowed({ url: page.url(), ...navigationPolicy });
3573
3765
  }
3574
- const tid = await pageTargetId(page).catch(() => null);
3766
+ const tid = createdTargetId ?? await pageTargetId(page).catch(() => null);
3575
3767
  if (tid === null || tid === "") throw new Error("Failed to get targetId for new page");
3576
3768
  return {
3577
3769
  targetId: tid,