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