Repository navigation
Document browser egress allowlists #657
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
meliaj
wants to merge
12
commits into
main
Choose a base branch
from
hypeship/egress-allowlists
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+265
−12
Open
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
da6677f
Document browser egress allowlists
meliaj 04492be
Clarify allowlist timing, rejected policies, and limitations
meliaj 75c8cb6
Say that browsers acquired from a pool can't get an allowlist
meliaj 9386ba6
List the secure DNS policies rejected with an allowlist
meliaj b853ed4
Note that CAPTCHA providers need to be on the allowlist
meliaj cb93103
Tighten the proxy_error telemetry notes
meliaj cee0f95
Say that update() can change or remove an egress allowlist but not ad…
meliaj 1c93ee2
docs(browsers): align egress allowlists with released behavior
meliaj f964ed5
docs(browsers): document egress allowlists on browser pools
meliaj 75a38be
docs(browsers): qualify allowlist denial telemetry
meliaj f59203c
docs(security): add egress allowlists to shared responsibility model
meliaj dba4bc4
docs(security): describe allowlists on browser pools
meliaj File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,232 @@ | ||
| --- | ||
| title: "Egress Allowlists" | ||
| description: "Limit the destinations a browser session can reach" | ||
| --- | ||
|
|
||
| Use `network.allowed_hosts` to limit which destinations a browser session can reach through Kernel's egress proxy. The proxy refuses unlisted destinations for navigations, subresources, `fetch()` and XHR, and WebSockets. This isn't full VM-level network isolation; traffic that doesn't use the proxy isn't filtered. See [limitations](#limitations). | ||
|
|
||
| An allowlist limits the damage a prompt injection can do: an agent tricked into sending data to an attacker's server gets a refusal instead of a connection. | ||
|
|
||
| ## Create a browser with an allowlist | ||
|
|
||
| Set the allowlist when you create the browser: | ||
|
|
||
| <CodeGroup> | ||
|
|
||
| ```typescript TypeScript | ||
| import Kernel from '@onkernel/sdk'; | ||
|
|
||
| const kernel = new Kernel(); | ||
|
|
||
| const kernelBrowser = await kernel.browsers.create({ | ||
| start_url: 'https://shop.example.com/orders', | ||
| network: { | ||
| allowed_hosts: ['shop.example.com', '*.shop.example.com', 'payments.example.net'], | ||
| }, | ||
| }); | ||
|
|
||
| console.log(kernelBrowser.network?.allowed_hosts); | ||
| ``` | ||
|
|
||
| ```python Python | ||
| from kernel import Kernel | ||
|
|
||
| kernel = Kernel() | ||
|
|
||
| kernel_browser = kernel.browsers.create( | ||
| start_url="https://shop.example.com/orders", | ||
| network={ | ||
| "allowed_hosts": ["shop.example.com", "*.shop.example.com", "payments.example.net"], | ||
| }, | ||
| ) | ||
|
|
||
| print(kernel_browser.network.allowed_hosts) | ||
| ``` | ||
|
|
||
| </CodeGroup> | ||
|
|
||
| The allowlist applies from the browser's first request, including the load of `start_url`. The API rejects a `start_url` that the list doesn't allow. The browser's `network.allowed_hosts` returns the list as Kernel stored it: trimmed, lowercased, and deduplicated. | ||
|
|
||
| If Kernel can't start a browser that enforces the allowlist, the request fails with a `500` rather than starting a browser without it. | ||
|
|
||
| ## Supported entries | ||
|
|
||
| You can provide up to 100 entries, each no longer than 253 characters: | ||
|
|
||
| - Exact hostnames: `shop.example.com` | ||
| - A single leading wildcard: `*.example.com` matches subdomains at any depth, such as `api.example.com` and `cdn.eu.example.com`, but not `example.com` itself. Add both entries to allow a domain and its subdomains. | ||
| - Public IPv4 addresses: `8.8.8.8` | ||
| - Bracketed public IPv6 addresses: `[2001:4860:4860::8888]` | ||
| - Canonical public CIDRs: `8.8.4.0/24` or `2001:4860::/32` | ||
|
|
||
| Each entry matches every port on its host, so entries can't include ports. They also can't include schemes or paths. Write internationalized domain names in their ASCII (`xn--`) form. | ||
|
|
||
| IP and CIDR entries only match destinations written as an IP address, such as `https://8.8.8.8/`. They never match a hostname that resolves into the range, so add the hostname itself. | ||
|
|
||
| Kernel rejects: | ||
|
|
||
| - An empty list (`[]`). Omit `allowed_hosts` for unfiltered egress. | ||
| - Wildcards over any [public suffix](https://publicsuffix.org/), including provider domains such as `*.github.io`, `*.cloudfront.net`, `*.vercel.app`, and `*.herokuapp.com`. List the specific host instead, such as `d111111abcdef8.cloudfront.net`. | ||
| - Private, loopback, link-local, and other reserved IP ranges. To reach private services, use [private networking](/browsers/private-networking). | ||
| - Entries that overlap `network.private_hosts`. Private hosts bypass Kernel's egress proxy, so an allowlist can't filter them. | ||
| - [Chrome policies](/browsers/chrome-policies) in `chrome_policy` that can send traffic around the egress proxy: `WebRtcIPHandling` and `WebRtcIPHandlingUrl` for WebRTC, and `DnsOverHttpsMode` (unless it's `off`), `DnsOverHttpsTemplates`, `DnsOverHttpsTemplatesWithIdentifiers`, and `DnsOverHttpsSalt` for secure DNS. | ||
| - Non-canonical forms, such as CIDRs with host bits set or IPv4-mapped IPv6 addresses. | ||
|
|
||
| ## When a destination is blocked | ||
|
|
||
| Kernel's egress proxy answers a request for an unlisted destination with a `403` and sets the `X-Kernel-Proxy-Error` header to `network_policy_denied`. A navigation shows a Kernel error page that names the blocked host and says that retrying won't help, so an agent reading the page can tell a policy refusal from a website's own `403`. The proxy also refuses blocked `fetch()`, XHR, and subresource requests, and WebSocket handshakes fail. | ||
|
|
||
| The proxy's `403` and header aren't guaranteed to be visible to page JavaScript. For cross-origin `fetch()` and XHR, CORS can hide the denial response and produce `net::ERR_FAILED`. Cross-origin subresources can be rejected by Chromium's opaque response blocking (ORB): an image receiving the HTML denial page can fail with `net::ERR_BLOCKED_BY_ORB`. | ||
|
|
||
| With network [telemetry](/browsers/telemetry/overview) enabled, `proxy_error` classification depends on Chromium exposing the branded response in `Network.responseReceived`. When it does, the event's code is `network_policy_denied`. Cross-origin requests blocked by CORS or ORB can instead appear only as `network_loading_failed` events, without the denial status or header. Those generic error strings can also occur for unrelated failures, so they don't prove an allowlist denial on their own. | ||
|
|
||
| HTTPS connections on ports other than 443 get a bare `403` without the page, which Chromium reports as `net::ERR_TUNNEL_CONNECTION_FAILED`. Those refusals also appear only as `network_loading_failed`, and WebSocket handshake failures aren't classified as `proxy_error`. See [proxy errors](/proxies/errors) for the other codes. | ||
|
|
||
| ## CAPTCHAs | ||
|
|
||
| The automatic CAPTCHA solver in [stealth mode](/browsers/bot-detection/stealth) works with an allowlist. It only talks to Kernel's own browser infrastructure, which is always allowed. | ||
|
|
||
| The CAPTCHA itself loads from the CAPTCHA provider, not from the site that shows it. If a site you allow uses a CAPTCHA, add the provider's domains as well, or the challenge can't load and the solver has nothing to solve. For example: | ||
|
|
||
| - reCAPTCHA: `www.google.com` and `www.gstatic.com` | ||
| - hCaptcha: `hcaptcha.com` and `*.hcaptcha.com` | ||
| - Cloudflare Turnstile: `challenges.cloudflare.com` | ||
|
|
||
| Providers can change the hosts they use, so check the provider's documentation for the current list. A blocked CAPTCHA resource can produce a `network_policy_denied` `proxy_error` event when Chromium exposes the branded response, or only a `network_loading_failed` event when it doesn't. Don't rely on `proxy_error` alone to find every blocked provider resource. | ||
|
|
||
| ## Change the allowlist during a session | ||
|
|
||
| Replace the allowlist on a running browser with `update()` (`PATCH /browsers/{id_or_name}`). Chromium keeps running and open pages stay loaded: | ||
|
|
||
| <CodeGroup> | ||
|
|
||
| ```typescript TypeScript | ||
| await kernel.browsers.update(kernelBrowser.session_id, { | ||
| network: { | ||
| allowed_hosts: ['shop.example.com', '*.shop.example.com'], | ||
| }, | ||
| }); | ||
| ``` | ||
|
|
||
| ```python Python | ||
| kernel.browsers.update( | ||
| kernel_browser.session_id, | ||
| network={ | ||
| "allowed_hosts": ["shop.example.com", "*.shop.example.com"], | ||
| }, | ||
| ) | ||
| ``` | ||
|
|
||
| </CodeGroup> | ||
|
|
||
| The new list takes effect without restarting the browser: | ||
|
|
||
| - New requests to destinations the new list doesn't allow are refused within a few seconds, and Kernel closes open connections to them within about 30 seconds. Connections to destinations it still allows, such as an open WebSocket, stay open. | ||
| - During a Kernel deploy, an open connection to a removed host can survive for up to 10 minutes. New requests to it are still refused within a few seconds. | ||
| - A `start_url` in the same update must be allowed by the new list. Kernel navigates to it only after the new list is in effect. | ||
| - Omitting `allowed_hosts` leaves the list unchanged, and `[]` is rejected. | ||
| - If the update fails with a `500`, retry it. The new list might already apply to some requests. | ||
|
|
||
| Within `network`, only `allowed_hosts` can change after creation, and only on a browser created with an allowlist. `update()` can replace or remove the list, but adding one to a browser created without it returns `400 not_supported`, so create the browser with an allowlist when you'll need one. | ||
|
|
||
| To remove the allowlist and return to unfiltered egress, set `allowed_hosts` to `null`. Adding it back also returns `400 not_supported`; create a new browser with an allowlist instead: | ||
|
|
||
| <CodeGroup> | ||
|
|
||
| ```typescript TypeScript | ||
| await kernel.browsers.update(kernelBrowser.session_id, { | ||
| network: { allowed_hosts: null }, | ||
| }); | ||
| ``` | ||
|
|
||
| ```python Python | ||
| kernel.browsers.update( | ||
| kernel_browser.session_id, | ||
| network={"allowed_hosts": None}, | ||
| ) | ||
| ``` | ||
|
|
||
| </CodeGroup> | ||
|
|
||
| <Note> | ||
| Chromium can show a page from its HTTP cache without sending a request. After you remove a host, a page Chromium cached earlier might still render, even though new requests to that host are refused. | ||
| </Note> | ||
|
|
||
| ## Use allowlists with browser pools | ||
|
|
||
| Set `network.allowed_hosts` on a [browser pool](/browsers/pools) to give every browser it creates the same allowlist. The same entry rules, `start_url` validation, Chrome policy restrictions, and proxy-only enforcement apply. Allowlisted pools require proxy v3 and won't fall back to a proxy that can't enforce the list. | ||
|
|
||
| <CodeGroup> | ||
|
|
||
| ```typescript TypeScript | ||
| const pool = await kernel.browserPools.create({ | ||
| name: 'orders-pool', | ||
| size: 5, | ||
| start_url: 'https://shop.example.com/orders', | ||
| network: { | ||
| allowed_hosts: ['shop.example.com', '*.shop.example.com', 'payments.example.net'], | ||
| }, | ||
| }); | ||
| ``` | ||
|
|
||
| ```python Python | ||
| pool = kernel.browser_pools.create( | ||
| name="orders-pool", | ||
| size=5, | ||
| start_url="https://shop.example.com/orders", | ||
| network={ | ||
| "allowed_hosts": ["shop.example.com", "*.shop.example.com", "payments.example.net"], | ||
| }, | ||
| ) | ||
| ``` | ||
|
|
||
| </CodeGroup> | ||
|
|
||
| Acquire a browser normally. Its `network.allowed_hosts` returns the allowlist for that lease. An acquisition's `start_url` override must also be allowed. While you hold the browser, you can replace or remove its existing allowlist with `browsers.update()` as shown above. You still can't add a list to a browser created without one or add it back after removal. | ||
|
|
||
| To change the pool's baseline for future acquisitions, update the pool: | ||
|
|
||
| <CodeGroup> | ||
|
|
||
| ```typescript TypeScript | ||
| await kernel.browserPools.update(pool.id, { | ||
| network: { | ||
| allowed_hosts: ['shop.example.com', '*.shop.example.com'], | ||
| }, | ||
| }); | ||
| ``` | ||
|
|
||
| ```python Python | ||
| kernel.browser_pools.update( | ||
| pool.id, | ||
| network={ | ||
| "allowed_hosts": ["shop.example.com", "*.shop.example.com"], | ||
| }, | ||
| ) | ||
| ``` | ||
|
|
||
| </CodeGroup> | ||
|
|
||
| A pool update replaces the entire `network` object, unlike a browser update. Omit `network` to leave it unchanged. If you provide it, include every network setting you want to keep, including `private_hosts` and `proxy_routes`. Omitting `allowed_hosts` from that replacement removes the pool's allowlist; `network: {}` clears the entire network configuration. An empty allowlist (`[]`) is invalid. You can add an allowlist to a pool that doesn't have one because its unfiltered browsers are replaced rather than given a list in place. | ||
|
|
||
| Changing the pool's allowlist automatically replaces idle browsers, without requiring `discard_all_idle: true`. Browsers with a stale allowlist aren't handed out while the pool refills. Already acquired browsers keep their current list until release; a pool update doesn't change their egress immediately. | ||
|
|
||
| On release with `reuse: true`, Kernel restores the current pool allowlist before returning the browser to the pool. If it can't safely restore and enforce that baseline, it destroys the browser and the pool creates a replacement. This includes a browser whose allowlist you removed when the pool still requires one: the list isn't added back to that browser. Per-lease allowlist changes don't carry over to the next acquisition. Reuse still preserves cookies, storage, and other browser state; it isn't isolation between users. | ||
|
|
||
| ## Keep the allowlist out of the agent's reach | ||
|
|
||
| An allowlist only constrains a browser if the agent driving it can't change it. Anyone holding your Kernel API key can update or remove the allowlist, or create a new browser without one. Set and change allowlists from your own code, and don't give the agent your API key or connect it to the Kernel [MCP server](/reference/mcp-server). | ||
|
|
||
| On allowlisted browsers, CDP context creation specifying `proxyServer` or `proxyBypassList` is refused, including empty strings or direct proxy settings. This includes Playwright's `browser.newContext({ proxy })`. Ordinary context creation without those proxy parameters remains permitted. Browsers without an allowlist retain normal custom-proxy context behavior. | ||
|
|
||
| Don't give the constrained agent code execution inside the browser VM. Code running there can send traffic directly and disable the VM-side CDP restriction. The allowlist isn't full VM-level network isolation. See [limitations](#limitations). | ||
|
|
||
| ## Limitations | ||
|
|
||
| - Enforcement covers Chromium's HTTP(S) and WebSocket traffic through Kernel's egress proxy, not all traffic leaving the browser VM. This isn't full VM-level network isolation: | ||
| - Code executing inside the browser VM, including through [process execution](/browsers/process-execution), can send traffic directly and disable the VM-side CDP restriction. | ||
| - Extensions with the `proxy` permission can override Chromium's proxy settings and switch to direct connections. | ||
| - DNS lookups that don't go through the proxy aren't filtered, such as Chromium's lookups for hosts in `network.private_hosts`. Requests through the egress proxy are resolved by the proxy, and Kernel rejects the Chrome policies that turn on secure DNS for allowlisted browsers. | ||
| - CDP context creation with `proxyServer` or `proxyBypassList` is refused on allowlisted browsers, including empty/direct settings and Playwright's `browser.newContext({ proxy })`. Ordinary context creation is permitted. Browsers without an allowlist retain normal custom-proxy context behavior; code executing inside the VM can disable this restriction. | ||
| - Destinations in `network.private_hosts`, including the [default private ranges](/browsers/private-networking#default-private-routes), are reached directly and aren't filtered. If the session doesn't need private routes, set `private_hosts` to `[]` to remove those browser proxy bypasses. This doesn't restrict direct traffic from code executing inside the VM. | ||
| - Requests to Kernel's own browser infrastructure, which features such as CAPTCHA solving and telemetry depend on, are always allowed. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pool network rules packed together
Low Severity
Several independent pool
networkupdate rules sit in one paragraph: whole-object replacement, omit-versus-replace behavior, clearing vianetwork: {}, rejecting[], and adding an allowlist to a pool. Readers can miss that omittingallowed_hostsfrom a replacement removes the list.Triggered by learned rule: Use bullet lists when covering multiple distinct points in guides
Reviewed by Cursor Bugbot for commit dba4bc4. Configure here.