Repository navigation
Skyvern's browser egress policy fails open on DNS resolution failure, granting unauthorized access to internal services #9010
Description
Affected: Skyvern-AI/skyvern at commit 623eb57 (v1.0.56); 3dcc567 carries the same file blob.
Summary
Skyvern classifies browser and search destinations before allowing egress. The classifier can only express two outcomes — a block reason, or None for "allowed" — and it folds "could not resolve" into the second one:
# skyvern/forge/sdk/browser_egress_policy.py:277-285
def classify_url(url: str) -> str | None:
"""Return a block reason for an internal destination, or None if allowed."""
try:
resolve_public_destination(url)
except DestinationBlockedError as exc:
return str(exc)
except DestinationResolutionError:
return None # resolution failure -> "no block reason"
return NoneThe search admission filter admits every URL that returns None:
# skyvern/forge/sdk/workflow/web_search.py:72-78
async def destination_allowed(url: str) -> bool:
return await classify_url_async(url) is NoneThe navigation validator repeats the same convention. is_blocked_host(host, resolve_dns=True) returns False on UnresolvableHost (skyvern/utils/url_validators.py:271-277), and validate_navigation_destination uses that as its decision (skyvern/webeye/navigation.py:62).
So "the worker could not resolve it" is treated as "not internal" in both places.
Why that is an authorization bypass (and an SSRF)
The egress policy is the authorization decision: a destination is allowed because the classifier answered "public". On a resolution failure no answer exists, yet the allow value is returned — the request is admitted because the check could not run, not because it passed. Under the policy's own contract (None = allowed) an unresolved destination is indistinguishable from a verified public host, so no caller can recover the missing decision. The boundary the operator drew is crossed without anyone deciding to allow it: that is an authorization bypass (CWE-863), and it is why the effect is unauthorized access to internal services rather than a search-quality issue.
And it is reachable because of how the connection is made:
- The resolution is a pre-flight check in the worker process. The request is then issued by the browser — cloud: browser pool / run proxy; self-hosted: whatever
BROWSER_REMOTE_DEBUGGING_URLpoints at. The browser hands the hostname downstream and resolves it independently. Your own code says so:skyvern/exceptions.py:894-901(UnresolvableNavigationHost) documents that "a browser behind a proxy hands the hostname to the proxy (HTTP CONNECT, SOCKS remote DNS)", andis_blocked_hostnotes that worker-side resolution failure "is not a policy signal". - When the browser sits in a different network, the two resolvers disagree by design. The classic case is a public-suffix name with an internal split-horizon A record:
jenkins.acme-corp.comis NXDOMAIN on the worker and10.xfor the browser. - That name bypasses both checks the policy actually relies on: it is not an IP literal (so
_ip_is_publicnever sees it) and it does not end in.internal/.local/.svc/.cluster.local(soBLOCKED_HOST_SUFFIXESnever sees it).
Reproduction
No exploit script needed — the decision layer can be checked directly against the real module, and the reachability can be checked with any browser that has its own resolver view:
- Start an HTTP service bound to
127.0.0.1(stands in for the internal target). - Call the real
classify_url()frombrowser_egress_policy.pyonhttp://skyvern-intranet.test:PORT/secret. It returnsNone;destination_allowed()returnsTrue. A loopback literal (http://127.0.0.1:PORT/) still returns a block reason, so the fail-closed path is intact — only the resolution failure is treated as allowed. - Give the browser a resolver view where that name resolves (a remote browser with that DNS record, or Chromium's
--host-resolver-rules=MAP skyvern-intranet.test 127.0.0.1as the local stand-in) and navigate to the same URL. The request lands on127.0.0.1:PORTand the page body is returned. - Control: without that resolver view, the browser gets
ERR_NAME_NOT_RESOLVEDand nothing is fetched — which shows the gap is exactly the resolver divergence, not the URL parsing.
validate_navigation_destination() behaves the same way: it raises for 127.0.0.1 and 169.254.169.254, and passes the unresolvable name.
Scope
- Self-hosted deployments with a remote browser (
BROWSER_REMOTE_DEBUGGING_URL→ Browserless / Playwright grid / a browser on another segment): the worker is typically outside the network the browser is meant to reach, which is exactly why a remote browser is used. - Cloud deployments: the run proxy is the remaining gate. If it resolves and fails closed at connect time, the request is stopped; the egress-policy API itself cannot express that requirement and gives every caller the same fail-open answer.
Impact
Requests reach HTTP(S) services that are reachable from the browser's network (internal dashboards, internal APIs, CI, the Kubernetes API, admin endpoints); the response is rendered and extracted by the agent, so internal content can be read and leave through the run's outbound channels. Cloud metadata is largely blocked by the literal-IP and hostname rules; it is reachable only where internal DNS aliases the metadata endpoint under an ordinary name. This is not RCE.
Suggested fix
classify_url(): turnDestinationResolutionErrorinto a block reason, or make the API tri-state (ALLOW/DENY/UNKNOWN) and require callers to handleUNKNOWN.destination_allowed(): admit only an explicit allow.is_blocked_host(..., resolve_dns=True): fail closed onUnresolvableHost.- At the connect boundary, resolve and pin the checked addresses, and refuse when resolution fails.
Happy to test a patch or provide the verification harness.
Reporting note: this was also submitted through the repository's private vulnerability reporting form on 2026-10-22. I am opening this issue as well because, from the outside, that queue looks unmonitored: #8482 asks whether anyone is watching it and has had no reply since 2026-09-08, and #8927 describes a report filed through GHSA-563g-mp6w-57r2 on 2026-09-09 that is still neither published nor answered as of 2026-10-22. This is not a complaint — queues get busy — but a public copy keeps the analysis and the fix guidance available if the private report is never triaged.
Activity
EddieMurphy89 commentedon Oct 23, 2026
Fixed by #9013 (10b8d82), which landed the same day this issue was filed. I checked the fix locally: classify_url() now returns blocked egress: the worker could not resolve the destination for a host the worker cannot resolve, loopback is still blocked, and a public host is still allowed — the path reported here is closed.