Skip to content

Skyvern's browser egress policy fails open on DNS resolution failure, granting unauthorized access to internal services #9010

Closed

Description

@EddieMurphy89

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 None

The 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 None

The 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:

  1. 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_URL points 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)", and is_blocked_host notes that worker-side resolution failure "is not a policy signal".
  2. 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.com is NXDOMAIN on the worker and 10.x for the browser.
  3. That name bypasses both checks the policy actually relies on: it is not an IP literal (so _ip_is_public never sees it) and it does not end in .internal / .local / .svc / .cluster.local (so BLOCKED_HOST_SUFFIXES never 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:

  1. Start an HTTP service bound to 127.0.0.1 (stands in for the internal target).
  2. Call the real classify_url() from browser_egress_policy.py on http://skyvern-intranet.test:PORT/secret. It returns None; destination_allowed() returns True. 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.
  3. 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.1 as the local stand-in) and navigate to the same URL. The request lands on 127.0.0.1:PORT and the page body is returned.
  4. Control: without that resolver view, the browser gets ERR_NAME_NOT_RESOLVED and 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(): turn DestinationResolutionError into a block reason, or make the API tri-state (ALLOW / DENY / UNKNOWN) and require callers to handle UNKNOWN.
  • destination_allowed(): admit only an explicit allow.
  • is_blocked_host(..., resolve_dns=True): fail closed on UnresolvableHost.
  • 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

  1. self-assigned this
    on Oct 22, 2026
  2. EddieMurphy89 commented on Oct 23, 2026

    @EddieMurphy89
    Author

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions