{"record":{"id":"16a838ee59da5097","repo":"unclecode/crawl4ai","slug":"enable-stealth-cannot-be-used-with-browser-mode-b","errorCode":null,"errorMessage":"enable_stealth cannot be used with browser_mode='builtin'. Stealth mode requires a dedicated browser instance.","messagePattern":"enable_stealth cannot be used with browser_mode='builtin'\\. Stealth mode requires a dedicated browser instance\\.","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"warning","filePath":"crawl4ai/async_configs.py","lineNumber":933,"sourceCode":"            # cdp_url will be set later by browser_manager\n        elif self.browser_mode == \"docker\":\n            # Docker mode uses managed browser with CDP to connect to browser in container\n            self.use_managed_browser = True\n            # cdp_url will be set later by docker browser strategy\n        elif self.browser_mode == \"custom\" and self.cdp_url:\n            # Custom mode with explicit CDP URL\n            self.use_managed_browser = True\n        elif self.browser_mode == \"dedicated\":\n            # Dedicated mode uses a new browser instance each time\n            pass\n\n        # If persistent context is requested, ensure managed browser is enabled\n        if self.use_persistent_context:\n            self.use_managed_browser = True\n            \n        # Validate stealth configuration\n        if self.enable_stealth and self.use_managed_browser and self.browser_mode == \"builtin\":\n            raise ValueError(\n                \"enable_stealth cannot be used with browser_mode='builtin'. \"\n                \"Stealth mode requires a dedicated browser instance.\"\n            )\n\n    @staticmethod\n    def from_kwargs(kwargs: dict) -> \"BrowserConfig\":\n        # Auto-deserialize any dict values that use the {\"type\": ..., \"params\": ...}\n        # serialization format (e.g. from JSON API requests or dump()/load() roundtrips).\n        kwargs = {\n            k: from_serializable_dict(v) if isinstance(v, dict) and \"type\" in v else v\n            for k, v in kwargs.items()\n        }\n        # Only pass keys present in kwargs so that __init__ defaults (and\n        # set_defaults() overrides) are respected for missing keys.\n        valid = inspect.signature(BrowserConfig.__init__).parameters.keys() - {\"self\"}\n        return BrowserConfig(**{k: v for k, v in kwargs.items() if k in valid})\n\n    def to_dict(self):","sourceCodeStart":915,"sourceCodeEnd":951,"githubUrl":"https://github.com/unclecode/crawl4ai/blob/7e801521428ee12509994d39151006f64055ebe3/crawl4ai/async_configs.py#L915-L951","documentation":"Two adjacent EgressBlocked raises in resolve_and_pin's answer loop: (a) any DNS answer whose IP is non-globally-routable rejects the host outright (rebinding defense, same any-forbidden-IP rule as assert_host_allowed); (b) the fall-through when the loop completes but no answer was pinnable (pinned is None — e.g. empty answer list), so there is no safe IP to dial. Either way the URL is refused before any connection.","triggerScenarios":"(a) resolve_and_pin('https://name-with-one-private-A-record') — one 10.x answer among public ones triggers the block. (b) An edge case where getaddrinfo returns records but none survive iteration (e.g. unexpected family/filtering), leaving pinned None and raising.","commonSituations":"Split-horizon DNS publishing internal IPs on a public name; wildcard DNS records; DNS appliances returning odd record sets; testing with names that resolve to 127.0.0.1.","solutions":["From the container, run `getent ahosts <host>` / `dig +short <host>` and confirm every answer is globally routable; remove private records from the name if you control DNS","Use a different, fully public hostname for the crawl target","For sanctioned internal crawling, use a deployment with ALLOW_INTERNAL=true so the global check is skipped and pinning proceeds"],"exampleFix":"# diagnose\n# dig +short api.example.com\n# 198.51.100.7\n# 192.168.1.20   <- triggers 'URL blocked' in resolve_and_pin\n# fix DNS so only 198.51.100.7 is published, then retry","handlingStrategy":"validation","validationCode":"import socket, ipaddress\ndef pinnable_global_host(host: str, port: int) -> bool:\n    try:\n        answers = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)\n    except socket.gaierror:\n        return False\n    ips = [a[4][0] for a in answers]\n    return bool(ips) and all(ipaddress.ip_address(ip).is_global for ip in ips)","typeGuard":null,"tryCatchPattern":"from egress_broker import EgressBlocked, resolve_and_pin\ntry:\n    target = resolve_and_pin(url)\nexcept EgressBlocked:\n    if pinnable_global_host(host, port):\n        log.error(\"broker blocked %s unexpectedly — audit broker config\", url)\n    skip(url)","preventionTips":["Pre-check that DNS returns at least one and only globally-routable answers","Audit your DNS zones for stray private/loopback records on public names","Remember pinning dials exactly one resolved IP — the any-internal-answer rule protects the connect"],"tags":["ssrf-protection","egress","dns-rebinding","ip-pinning"],"backgroundTag":null,"analyzedSha":"7e801521428ee12509994d39151006f64055ebe3","analyzedAt":"2026-08-14T20:46:20.673Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}