From da6677fc109316fe60d6c0a8846b44c74412e3a3 Mon Sep 17 00:00:00 2001 From: meliaj <17581886+meliaj@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:30:17 +0000 Subject: [PATCH 01/12] Document browser egress allowlists Add a page for network.allowed_hosts: creating a browser with an allowlist, the entry rules, what a blocked request returns, changing or removing the list with update(), keeping it away from the agent, and the limitations. List network_policy_denied and its 403 on the proxy errors page, and narrow the private networking note now that allowed_hosts can change after creation. Co-Authored-By: Claude Opus 5.5 --- browsers/egress-allowlists.mdx | 142 ++++++++++++++++++++++++++++++++ browsers/private-networking.mdx | 2 +- docs.json | 1 + proxies/errors.mdx | 3 +- 4 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 browsers/egress-allowlists.mdx diff --git a/browsers/egress-allowlists.mdx b/browsers/egress-allowlists.mdx new file mode 100644 index 00000000..f0767ecc --- /dev/null +++ b/browsers/egress-allowlists.mdx @@ -0,0 +1,142 @@ +--- +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. Kernel's egress proxy refuses any destination that isn't on the list, so pages can't load from unlisted hosts or send data to them. That covers navigations, subresources, `fetch()` and XHR, and WebSockets. + +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: + + +```typescript TypeScript +import Kernel from '@onkernel/sdk'; + +const kernel = new Kernel(); + +const browser = 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(browser.network?.allowed_hosts); +``` + +```python Python +from kernel import 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(browser.network.allowed_hosts) +``` + + +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: `203.0.113.10` +- 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://203.0.113.10/`. 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 a public suffix, such as `*.com` or `*.github.io`. +- 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. +- Non-canonical forms, such as CIDRs with host bits set or IPv4-mapped IPv6 addresses. + +## When a destination is blocked + +Kernel 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`. `fetch()`, XHR, and subresource requests fail with the same status and header, and WebSocket handshakes fail. + +HTTPS connections on ports other than 443 get a bare `403` without the page, which Chromium reports as `net::ERR_TUNNEL_CONNECTION_FAILED`. + +Blocked requests are also reported as `proxy_error` [telemetry](/browsers/telemetry/overview) events with the code `network_policy_denied`. See [proxy errors](/proxies/errors) for the other codes. + +## Change the allowlist during a session + +Replace the allowlist on a running browser with `update()`. Chromium keeps running and open pages stay loaded: + + +```typescript TypeScript +await kernel.browsers.update(browser.session_id, { + network: { + allowed_hosts: ['shop.example.com', '*.shop.example.com'], + }, +}); +``` + +```python Python +kernel.browsers.update( + browser.session_id, + network={ + "allowed_hosts": ["shop.example.com", "*.shop.example.com"], + }, +) +``` + + +The new list applies to requests made after the update returns: + +- Kernel closes the session's open connections to destinations the new list doesn't allow. Connections to destinations it still allows, such as an open WebSocket, stay open. +- 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. + +Only `allowed_hosts` can change after creation. Adding an allowlist to a browser created without one isn't supported on every browser and returns `400 not_supported` when it isn't, so create the browser with an allowlist when you know you'll need one. + +To remove the allowlist and return to unfiltered egress, set `allowed_hosts` to `null`: + + +```typescript TypeScript +await kernel.browsers.update(browser.session_id, { + network: { allowed_hosts: null }, +}); +``` + +```python Python +kernel.browsers.update( + browser.session_id, + network={"allowed_hosts": None}, +) +``` + + + +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. + + +## 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). + +## Limitations + +- Only traffic that goes through Kernel's egress proxy is filtered. Chromium's traffic does. Programs you run in the browser VM, for example with [process execution](/browsers/process-execution), might not use the proxy, and their traffic isn't filtered. +- 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 `[]` so all traffic goes through the egress proxy. +- Requests to Kernel's own browser infrastructure, which features such as CAPTCHA solving and telemetry depend on, are always allowed. +- [Browser pools](/browsers/pools) don't support allowlists. diff --git a/browsers/private-networking.mdx b/browsers/private-networking.mdx index 596d3f54..5f72f75e 100644 --- a/browsers/private-networking.mdx +++ b/browsers/private-networking.mdx @@ -13,7 +13,7 @@ This is useful for services reachable through Tailscale, a corporate VPN, or ano ## Configure a browser -Set private hosts when you create the browser. You can't change the network configuration after creation. +Set private hosts when you create the browser. You can't change them after creation. ```typescript TypeScript diff --git a/docs.json b/docs.json index 567cde9e..97f4ddfe 100644 --- a/docs.json +++ b/docs.json @@ -159,6 +159,7 @@ "proxies/isp", "proxies/mobile", "proxies/custom", + "browsers/egress-allowlists", "proxies/errors" ] }, diff --git a/proxies/errors.mdx b/proxies/errors.mdx index 8fa993b3..e8d5b831 100644 --- a/proxies/errors.mdx +++ b/proxies/errors.mdx @@ -4,7 +4,7 @@ title: "Proxy Errors" When a request cannot be completed at the proxy layer, Kernel serves a branded error page and sets the `X-Kernel-Proxy-Error` response header to a typed code. -The header is set on every branded proxy-layer error response, including WebSocket and subresource requests where the page body never renders. The status is always `502`, including for timeouts, so use the header rather than the status to decide what went wrong. +The header is set on every branded proxy-layer error response, including WebSocket and subresource requests where the page body never renders. The status is `502` for every proxy-layer failure, including timeouts, and `403` when the session's [egress allowlist](/browsers/egress-allowlists) blocks the destination. Use the header rather than the status to decide what went wrong. ## Error codes @@ -22,6 +22,7 @@ The header is set on every branded proxy-layer error response, including WebSock | `provider_rejected` | The upstream proxy provider rejected the request before the destination connection was established. | No. Try a different proxy or proxy type, and check the credentials on a [custom proxy](/proxies/custom). | | `provider_blacklisted` | The upstream proxy provider blocks this destination. | No. Try a different proxy or proxy type, or disable the proxy for this destination. | | `destination_blocked` | Kernel policy blocks connections to internal and private addresses. | No. Use a public destination address. | +| `network_policy_denied` | The destination isn't in the session's [egress allowlist](/browsers/egress-allowlists), so Kernel didn't connect to it. The status is `403`. | No. If the session needs the destination, add it to `network.allowed_hosts` from your own code. | ## Recovering from a proxy error From 04492be0750f98b5e3aec3bed1c500ccef190fb9 Mon Sep 17 00:00:00 2001 From: meliaj <17581886+meliaj@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:39:20 +0000 Subject: [PATCH 02/12] Clarify allowlist timing, rejected policies, and limitations - Updating the allowlist: new requests are refused within seconds and open connections close within about 30 seconds, or up to 10 minutes during a Kernel deploy. - The WebRtcIPHandling and WebRtcIPHandlingUrl Chrome policies are rejected with an allowlist. - Public suffix rejections include provider domains such as *.cloudfront.net. - Limitations list what can leave the VM without the egress proxy: VM processes, CDP contexts with their own proxy, extensions with the proxy permission, and DNS lookups Chromium makes itself. Co-Authored-By: Claude Opus 5.5 --- browsers/egress-allowlists.mdx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/browsers/egress-allowlists.mdx b/browsers/egress-allowlists.mdx index f0767ecc..0a431075 100644 --- a/browsers/egress-allowlists.mdx +++ b/browsers/egress-allowlists.mdx @@ -64,9 +64,10 @@ IP and CIDR entries only match destinations written as an IP address, such as `h Kernel rejects: - An empty list (`[]`). Omit `allowed_hosts` for unfiltered egress. -- Wildcards over a public suffix, such as `*.com` or `*.github.io`. +- 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. +- The `WebRtcIPHandling` and `WebRtcIPHandlingUrl` [Chrome policies](/browsers/chrome-policies) in `chrome_policy`. Either one can let WebRTC send traffic around the egress proxy. Adding an allowlist with `update()` to a browser created with one of them is rejected too. - Non-canonical forms, such as CIDRs with host bits set or IPv4-mapped IPv6 addresses. ## When a destination is blocked @@ -100,9 +101,10 @@ kernel.browsers.update( ``` -The new list applies to requests made after the update returns: +The new list takes effect without restarting the browser: -- Kernel closes the session's open connections to destinations the new list doesn't allow. Connections to destinations it still allows, such as an open WebSocket, stay open. +- 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. @@ -134,9 +136,15 @@ Chromium can show a page from its HTTP cache without sending a request. After yo 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). +An agent that drives the browser over CDP can also create a browser context with its own proxy settings, and the allowlist doesn't cover that context. See [limitations](#limitations). + ## Limitations -- Only traffic that goes through Kernel's egress proxy is filtered. Chromium's traffic does. Programs you run in the browser VM, for example with [process execution](/browsers/process-execution), might not use the proxy, and their traffic isn't filtered. +- Only Chromium's HTTP(S) and WebSocket traffic through Kernel's egress proxy is filtered. The browser VM has a direct route to the internet, so anything else can leave it unfiltered, including: + - Programs you run in the browser VM, for example with [process execution](/browsers/process-execution). + - Browser contexts created over CDP with their own proxy settings, such as Playwright's `browser.newContext({ proxy })`. + - Extensions with the `proxy` permission, which can switch Chromium to direct connections. + - DNS lookups Chromium makes itself, including DNS-over-HTTPS that you turn on with a Chrome policy. Requests that go through the egress proxy are resolved by the proxy, not in the VM. - 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 `[]` so all traffic goes through the egress proxy. - Requests to Kernel's own browser infrastructure, which features such as CAPTCHA solving and telemetry depend on, are always allowed. - [Browser pools](/browsers/pools) don't support allowlists. From 75c8cb606f7ea95a236a0900b7080059f0f9198f Mon Sep 17 00:00:00 2001 From: meliaj <17581886+meliaj@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:53:53 +0000 Subject: [PATCH 03/12] Say that browsers acquired from a pool can't get an allowlist Co-Authored-By: Claude Opus 5.5 --- browsers/egress-allowlists.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/browsers/egress-allowlists.mdx b/browsers/egress-allowlists.mdx index 0a431075..0f3828e5 100644 --- a/browsers/egress-allowlists.mdx +++ b/browsers/egress-allowlists.mdx @@ -147,4 +147,4 @@ An agent that drives the browser over CDP can also create a browser context with - DNS lookups Chromium makes itself, including DNS-over-HTTPS that you turn on with a Chrome policy. Requests that go through the egress proxy are resolved by the proxy, not in the VM. - 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 `[]` so all traffic goes through the egress proxy. - Requests to Kernel's own browser infrastructure, which features such as CAPTCHA solving and telemetry depend on, are always allowed. -- [Browser pools](/browsers/pools) don't support allowlists. +- [Browser pools](/browsers/pools) don't support allowlists. You can't set one on a pool, or add one with `update()` to a browser acquired from a pool. From 9386ba6123cce556e269d9437cee9af66fffdaec Mon Sep 17 00:00:00 2001 From: meliaj <17581886+meliaj@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:32:33 +0000 Subject: [PATCH 04/12] List the secure DNS policies rejected with an allowlist Co-Authored-By: Claude Opus 5.5 --- browsers/egress-allowlists.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/browsers/egress-allowlists.mdx b/browsers/egress-allowlists.mdx index 0f3828e5..95dedde0 100644 --- a/browsers/egress-allowlists.mdx +++ b/browsers/egress-allowlists.mdx @@ -67,7 +67,7 @@ Kernel rejects: - 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. -- The `WebRtcIPHandling` and `WebRtcIPHandlingUrl` [Chrome policies](/browsers/chrome-policies) in `chrome_policy`. Either one can let WebRTC send traffic around the egress proxy. Adding an allowlist with `update()` to a browser created with one of them is rejected too. +- [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. Adding an allowlist with `update()` to a browser created with one of them is rejected too. - Non-canonical forms, such as CIDRs with host bits set or IPv4-mapped IPv6 addresses. ## When a destination is blocked @@ -144,7 +144,7 @@ An agent that drives the browser over CDP can also create a browser context with - Programs you run in the browser VM, for example with [process execution](/browsers/process-execution). - Browser contexts created over CDP with their own proxy settings, such as Playwright's `browser.newContext({ proxy })`. - Extensions with the `proxy` permission, which can switch Chromium to direct connections. - - DNS lookups Chromium makes itself, including DNS-over-HTTPS that you turn on with a Chrome policy. Requests that go through the egress proxy are resolved by the proxy, not in the VM. + - DNS lookups that don't go through the proxy, 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. - 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 `[]` so all traffic goes through the egress proxy. - Requests to Kernel's own browser infrastructure, which features such as CAPTCHA solving and telemetry depend on, are always allowed. - [Browser pools](/browsers/pools) don't support allowlists. You can't set one on a pool, or add one with `update()` to a browser acquired from a pool. From b853ed4d14692cf459f1dddc71b4a041466eed5a Mon Sep 17 00:00:00 2001 From: meliaj <17581886+meliaj@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:29:28 +0000 Subject: [PATCH 05/12] Note that CAPTCHA providers need to be on the allowlist --- browsers/egress-allowlists.mdx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/browsers/egress-allowlists.mdx b/browsers/egress-allowlists.mdx index 95dedde0..3149a7b2 100644 --- a/browsers/egress-allowlists.mdx +++ b/browsers/egress-allowlists.mdx @@ -78,6 +78,18 @@ HTTPS connections on ports other than 443 get a bare `403` without the page, whi Blocked requests are also reported as `proxy_error` [telemetry](/browsers/telemetry/overview) events with the code `network_policy_denied`. 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 host shows up as a `network_policy_denied` `proxy_error` event that names the host. + ## Change the allowlist during a session Replace the allowlist on a running browser with `update()`. Chromium keeps running and open pages stay loaded: From cb93103d66fa12c6e5261780e6d5722d9169ec94 Mon Sep 17 00:00:00 2001 From: meliaj <17581886+meliaj@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:32:46 +0000 Subject: [PATCH 06/12] Tighten the proxy_error telemetry notes Blocked connections to ports other than 443 get a bare 403 that Chromium doesn't expose, so they show up as network_loading_failed, not proxy_error. The browser image release this page waits for reports origin_response_incomplete with its own code, so the proxy errors page no longer needs to say it isn't reported. Co-Authored-By: Claude Opus 5.5 --- browsers/egress-allowlists.mdx | 2 +- proxies/errors.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/browsers/egress-allowlists.mdx b/browsers/egress-allowlists.mdx index 3149a7b2..f1a8e785 100644 --- a/browsers/egress-allowlists.mdx +++ b/browsers/egress-allowlists.mdx @@ -76,7 +76,7 @@ Kernel answers a request for an unlisted destination with a `403` and sets the ` HTTPS connections on ports other than 443 get a bare `403` without the page, which Chromium reports as `net::ERR_TUNNEL_CONNECTION_FAILED`. -Blocked requests are also reported as `proxy_error` [telemetry](/browsers/telemetry/overview) events with the code `network_policy_denied`. See [proxy errors](/proxies/errors) for the other codes. +Blocked requests are also reported as `proxy_error` [telemetry](/browsers/telemetry/overview) events with the code `network_policy_denied`. The bare `403`s on other ports are the exception: Chromium doesn't expose them, so those connections appear only as `network_loading_failed` events. See [proxy errors](/proxies/errors) for the other codes. ## CAPTCHAs diff --git a/proxies/errors.mdx b/proxies/errors.mdx index e8d5b831..1fa9b081 100644 --- a/proxies/errors.mdx +++ b/proxies/errors.mdx @@ -59,4 +59,4 @@ async function navigate(page, url, attempts = 3) { Proxy failures are also reported as `proxy_error` [browser telemetry](/browsers/telemetry/overview) events in the `network` category. Use the header to recover in the moment, and telemetry to attribute failures per session and per URL afterwards. -The telemetry `code` does not cover every code above. `origin_response_incomplete` is not reported as a `proxy_error` event, and a header value the browser image does not recognize is reported as `unknown`, with the original value in `raw_code`. Read the header when you need the exact code. +A header value the browser image does not recognize is reported as `unknown`, with the original value in `raw_code`. Read the header when you need the exact code. From cee0f95be92e762ad1e76612dc02626753211381 Mon Sep 17 00:00:00 2001 From: meliaj <17581886+meliaj@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:51:46 +0000 Subject: [PATCH 07/12] Say that update() can change or remove an egress allowlist but not add one An allowlist is now set when the browser is created. update() can replace or remove it, but adding one to a browser created without it returns 400 not_supported, and one removed with null can't be added back. --- browsers/egress-allowlists.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/browsers/egress-allowlists.mdx b/browsers/egress-allowlists.mdx index f1a8e785..ae9ebeb3 100644 --- a/browsers/egress-allowlists.mdx +++ b/browsers/egress-allowlists.mdx @@ -67,7 +67,7 @@ Kernel rejects: - 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. Adding an allowlist with `update()` to a browser created with one of them is rejected too. +- [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 @@ -121,9 +121,9 @@ The new list takes effect without restarting the browser: - 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. -Only `allowed_hosts` can change after creation. Adding an allowlist to a browser created without one isn't supported on every browser and returns `400 not_supported` when it isn't, so create the browser with an allowlist when you know you'll need one. +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`: +To remove the allowlist and return to unfiltered egress, set `allowed_hosts` to `null`. You can't add an allowlist back to the browser afterwards: ```typescript TypeScript @@ -159,4 +159,4 @@ An agent that drives the browser over CDP can also create a browser context with - DNS lookups that don't go through the proxy, 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. - 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 `[]` so all traffic goes through the egress proxy. - Requests to Kernel's own browser infrastructure, which features such as CAPTCHA solving and telemetry depend on, are always allowed. -- [Browser pools](/browsers/pools) don't support allowlists. You can't set one on a pool, or add one with `update()` to a browser acquired from a pool. +- [Browser pools](/browsers/pools) don't support allowlists. You can't set one on a pool, so a browser acquired from a pool can't have one. From 1c93ee262ceaea3c42ad64a5acb6784ee45c1555 Mon Sep 17 00:00:00 2001 From: meliaj <17581886+meliaj@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:22:54 +0000 Subject: [PATCH 08/12] docs(browsers): align egress allowlists with released behavior --- browsers/egress-allowlists.mdx | 52 +++++++++++++++++++-------------- browsers/private-networking.mdx | 2 +- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/browsers/egress-allowlists.mdx b/browsers/egress-allowlists.mdx index ae9ebeb3..8cf16fa8 100644 --- a/browsers/egress-allowlists.mdx +++ b/browsers/egress-allowlists.mdx @@ -3,7 +3,7 @@ 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. Kernel's egress proxy refuses any destination that isn't on the list, so pages can't load from unlisted hosts or send data to them. That covers navigations, subresources, `fetch()` and XHR, and WebSockets. +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. @@ -12,19 +12,20 @@ An allowlist limits the damage a prompt injection can do: an agent tricked into Set the allowlist when you create the browser: + ```typescript TypeScript import Kernel from '@onkernel/sdk'; const kernel = new Kernel(); -const browser = await kernel.browsers.create({ +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(browser.network?.allowed_hosts); +console.log(kernelBrowser.network?.allowed_hosts); ``` ```python Python @@ -32,15 +33,16 @@ from kernel import Kernel kernel = Kernel() -browser = kernel.browsers.create( +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(browser.network.allowed_hosts) +print(kernel_browser.network.allowed_hosts) ``` + 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. @@ -53,13 +55,13 @@ 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: `203.0.113.10` +- 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://203.0.113.10/`. They never match a hostname that resolves into the range, so add the hostname itself. +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: @@ -92,11 +94,12 @@ Providers can change the hosts they use, so check the provider's documentation f ## Change the allowlist during a session -Replace the allowlist on a running browser with `update()`. Chromium keeps running and open pages stay loaded: +Replace the allowlist on a running browser with `update()` (`PATCH /browsers/{id_or_name}`). Chromium keeps running and open pages stay loaded: + ```typescript TypeScript -await kernel.browsers.update(browser.session_id, { +await kernel.browsers.update(kernelBrowser.session_id, { network: { allowed_hosts: ['shop.example.com', '*.shop.example.com'], }, @@ -105,12 +108,13 @@ await kernel.browsers.update(browser.session_id, { ```python Python kernel.browsers.update( - browser.session_id, + kernel_browser.session_id, network={ "allowed_hosts": ["shop.example.com", "*.shop.example.com"], }, ) ``` + The new list takes effect without restarting the browser: @@ -121,42 +125,46 @@ The new list takes effect without restarting the browser: - 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. -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. +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`. You can't add an allowlist back to the browser afterwards: +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: + ```typescript TypeScript -await kernel.browsers.update(browser.session_id, { +await kernel.browsers.update(kernelBrowser.session_id, { network: { allowed_hosts: null }, }); ``` ```python Python kernel.browsers.update( - browser.session_id, + kernel_browser.session_id, network={"allowed_hosts": None}, ) ``` + -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. + 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. ## 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). -An agent that drives the browser over CDP can also create a browser context with its own proxy settings, and the allowlist doesn't cover that context. See [limitations](#limitations). +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 -- Only Chromium's HTTP(S) and WebSocket traffic through Kernel's egress proxy is filtered. The browser VM has a direct route to the internet, so anything else can leave it unfiltered, including: - - Programs you run in the browser VM, for example with [process execution](/browsers/process-execution). - - Browser contexts created over CDP with their own proxy settings, such as Playwright's `browser.newContext({ proxy })`. - - Extensions with the `proxy` permission, which can switch Chromium to direct connections. - - DNS lookups that don't go through the proxy, 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. -- 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 `[]` so all traffic goes through the egress proxy. +- 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. - [Browser pools](/browsers/pools) don't support allowlists. You can't set one on a pool, so a browser acquired from a pool can't have one. diff --git a/browsers/private-networking.mdx b/browsers/private-networking.mdx index 5f72f75e..b5f972e2 100644 --- a/browsers/private-networking.mdx +++ b/browsers/private-networking.mdx @@ -108,7 +108,7 @@ These CIDR rules only match URLs that use literal IP addresses. They don't match Providing `private_hosts` replaces the default list; it doesn't add to it. Include any default CIDRs you still need alongside your hostname rules. -To disable direct private routing and send all traffic through Kernel-managed egress, provide an explicit empty list: +To disable direct private routing for browser requests and send them through Kernel-managed egress, provide an explicit empty list: ```typescript TypeScript From f964ed5a60c9715fe7963affbc6662ee2ce7cdf3 Mon Sep 17 00:00:00 2001 From: meliaj <17581886+meliaj@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:55:00 +0000 Subject: [PATCH 09/12] docs(browsers): document egress allowlists on browser pools --- browsers/egress-allowlists.mdx | 62 ++++++++++++++++++++++++++++++++- browsers/pools.mdx | 12 ++++--- browsers/private-networking.mdx | 2 +- 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/browsers/egress-allowlists.mdx b/browsers/egress-allowlists.mdx index 8cf16fa8..131aff3a 100644 --- a/browsers/egress-allowlists.mdx +++ b/browsers/egress-allowlists.mdx @@ -150,6 +150,67 @@ kernel.browsers.update( 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. +## 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. + + + +```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"], + }, +) +``` + + + +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: + + + +```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"], + }, +) +``` + + + +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). @@ -167,4 +228,3 @@ Don't give the constrained agent code execution inside the browser VM. Code runn - 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. -- [Browser pools](/browsers/pools) don't support allowlists. You can't set one on a pool, so a browser acquired from a pool can't have one. diff --git a/browsers/pools.mdx b/browsers/pools.mdx index ceccaf35..926966d4 100644 --- a/browsers/pools.mdx +++ b/browsers/pools.mdx @@ -3,7 +3,7 @@ title: "Browser Pools" description: "Configure a pool of ready-to-use browsers for instant acquisition" --- -A browser pool is a fixed set of identical browsers that Kernel keeps running for you. Configure it once — stealth, proxies, [private networking](/browsers/private-networking), extensions, viewport, a [profile](#profiles-with-browser-pools) — then acquire a browser whenever a task needs one and release it when you're done. +A browser pool is a fixed set of identical browsers that Kernel keeps running for you. Configure it once — stealth, proxies, [private networking](/browsers/private-networking), [egress allowlists](/browsers/egress-allowlists#use-allowlists-with-browser-pools), extensions, viewport, a [profile](#profiles-with-browser-pools) — then acquire a browser whenever a task needs one and release it when you're done. Acquiring is faster than creating an on-demand browser because the browser is already running: you skip start-up, including the [Chromium restart](/browsers/performance#troubleshooting-latency) that some settings trigger, and you aren't subject to the [rate limit](/browsers/concurrency-and-limits#rate-limits) on browser creation. @@ -164,7 +164,7 @@ When you're done with a browser, release it back to the browser pool. By default Reuse controls whether browser state carries across acquisitions: -- **`reuse: true`** (default) returns the same browser instance to the pool without resetting it. Cookies, local storage, logged-in sessions, and open tabs persist, and the next caller to acquire it inherits that state. Fast, but not isolated between acquirers. +- **`reuse: true`** (default) returns the same browser instance to the pool without clearing its browser state. Cookies, local storage, logged-in sessions, and open tabs persist, and the next caller to acquire it inherits that state. Fast, but not isolated between acquirers. Kernel restores the pool's current [egress allowlist](/browsers/egress-allowlists#use-allowlists-with-browser-pools) before reuse, or replaces the browser if that baseline can't be safely restored and enforced. - **`reuse: false`** destroys the browser and refills the pool with a fresh one. Isolated and clean, at the cost of a rebuild. @@ -302,6 +302,10 @@ Browser pools shine at higher concurrency, but even a small one is worth it when Update the browser pool's configuration. By default, existing idle browsers keep their current configuration and only newly created browsers use the new one. Pass `discard_all_idle: true` to discard all idle browsers and rebuild them immediately with the new configuration. +Egress allowlists are the exception: changing `network.allowed_hosts` automatically replaces idle browsers. Already acquired browsers keep their list until release, when Kernel restores the current pool baseline or replaces the browser. See [allowlists with browser pools](/browsers/egress-allowlists#use-allowlists-with-browser-pools) for examples. + +Providing `network` replaces the whole object, not individual fields. Include every network setting you want to preserve; omit `network` to leave it unchanged, or send `network: {}` to clear it. + ```typescript Typescript/Javascript const updatedPool = await kernel.browserPools.update("my-pool", { @@ -331,9 +335,9 @@ _ = updatedPool -Browsers that are acquired during an update are never touched, even with `discard_all_idle: true`. An in-use browser keeps its original configuration, and if you release it with `reuse: true` (the default) it re-enters the pool still carrying that configuration and keeps getting handed out that way. +Browsers that are acquired during an update are never touched, even with `discard_all_idle: true`. Their allowlist is restored to the current pool baseline on release, or the browser is replaced if that can't be done safely. For other configuration fields, an in-use browser keeps its original configuration, and if you release it with `reuse: true` (the default) it re-enters the pool still carrying those settings and keeps getting handed out that way. -You have two ways to get an in-use browser onto the new configuration: +You have two ways to get an in-use browser onto the new configuration for those other fields: - **Prevent it on release:** release with `reuse: false`. The browser is destroyed and rebuilt with the browser pool's current configuration instead of the old one returning to the pool. - **Clean it up after the fact:** `flush()` the pool, or run a later `update()` with `discard_all_idle: true`, once the in-use browsers have been released. Both destroy every idle browser and let the pool refill on the current configuration; neither touches a browser that's currently acquired, so both are safe to call under load. diff --git a/browsers/private-networking.mdx b/browsers/private-networking.mdx index b5f972e2..b9deedd5 100644 --- a/browsers/private-networking.mdx +++ b/browsers/private-networking.mdx @@ -169,6 +169,6 @@ kernel browser-pools create private-services \ ``` -A browser-pool update applies only to browsers created after the update. Pass `discard_all_idle: true` in an SDK request, or `--discard-all-idle` in the CLI, to immediately replace idle browsers with the new configuration. Acquired browsers keep their original configuration until you release them with reuse disabled. +Changes to a browser pool's `private_hosts` apply only to browsers created after the update. Pass `discard_all_idle: true` in an SDK request, or `--discard-all-idle` in the CLI, to immediately replace idle browsers with the new configuration. Acquired browsers keep their original private-host configuration until you release them with reuse disabled. [Egress allowlist updates](/browsers/egress-allowlists#use-allowlists-with-browser-pools) follow different replacement and release rules. Use `kernel browser-pools update private-services --clear-private-hosts --discard-all-idle` to remove a pool override and restore the default private IP ranges. To configure an explicit empty list, use an SDK request with `network.private_hosts: []`. From 75a38be48d9b787797747f3c8e611285f8fb45b6 Mon Sep 17 00:00:00 2001 From: meliaj <17581886+meliaj@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:16:31 +0000 Subject: [PATCH 10/12] docs(browsers): qualify allowlist denial telemetry --- browsers/egress-allowlists.mdx | 10 ++++++---- proxies/errors.mdx | 6 ++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/browsers/egress-allowlists.mdx b/browsers/egress-allowlists.mdx index 131aff3a..1d57289c 100644 --- a/browsers/egress-allowlists.mdx +++ b/browsers/egress-allowlists.mdx @@ -74,11 +74,13 @@ Kernel rejects: ## When a destination is blocked -Kernel 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`. `fetch()`, XHR, and subresource requests fail with the same status and header, and WebSocket handshakes fail. +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. -HTTPS connections on ports other than 443 get a bare `403` without the page, which Chromium reports as `net::ERR_TUNNEL_CONNECTION_FAILED`. +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`. -Blocked requests are also reported as `proxy_error` [telemetry](/browsers/telemetry/overview) events with the code `network_policy_denied`. The bare `403`s on other ports are the exception: Chromium doesn't expose them, so those connections appear only as `network_loading_failed` events. See [proxy errors](/proxies/errors) for the other codes. +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 @@ -90,7 +92,7 @@ The CAPTCHA itself loads from the CAPTCHA provider, not from the site that shows - 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 host shows up as a `network_policy_denied` `proxy_error` event that names the host. +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 diff --git a/proxies/errors.mdx b/proxies/errors.mdx index 1fa9b081..2283f2a7 100644 --- a/proxies/errors.mdx +++ b/proxies/errors.mdx @@ -4,7 +4,7 @@ title: "Proxy Errors" When a request cannot be completed at the proxy layer, Kernel serves a branded error page and sets the `X-Kernel-Proxy-Error` response header to a typed code. -The header is set on every branded proxy-layer error response, including WebSocket and subresource requests where the page body never renders. The status is `502` for every proxy-layer failure, including timeouts, and `403` when the session's [egress allowlist](/browsers/egress-allowlists) blocks the destination. Use the header rather than the status to decide what went wrong. +The header is set on every branded proxy-layer error response, including WebSocket and subresource requests where the page body never renders. The status is `502` for every proxy-layer failure, including timeouts, and `403` when the session's [egress allowlist](/browsers/egress-allowlists) blocks the destination. When Chromium exposes the response, use the header rather than the status to decide what went wrong. Cross-origin requests can fail CORS or opaque response blocking (ORB) checks before page JavaScript can inspect the response; see [blocked destinations](/browsers/egress-allowlists#when-a-destination-is-blocked). ## Error codes @@ -57,6 +57,8 @@ async function navigate(page, url, attempts = 3) { ## Observing proxy errors after the fact -Proxy failures are also reported as `proxy_error` [browser telemetry](/browsers/telemetry/overview) events in the `network` category. Use the header to recover in the moment, and telemetry to attribute failures per session and per URL afterwards. +With network [browser telemetry](/browsers/telemetry/overview) enabled, the collector classifies branded `502` or `403` responses exposed in Chromium's `Network.responseReceived` as `proxy_error` events. These events aren't a complete record of proxy refusals: cross-origin requests blocked by CORS or ORB can appear only as `network_loading_failed`, without the proxy status or header. WebSocket handshake failures and bare CONNECT refusals on ports other than 443 aren't classified as `proxy_error` either. + +Use the header when it's available to recover in the moment, and correlate network events by request and URL when investigating failures. A generic loading error alone doesn't identify a proxy refusal. A header value the browser image does not recognize is reported as `unknown`, with the original value in `raw_code`. Read the header when you need the exact code. From f59203cbb0f6b0f7a624e169f0927b56a402758a Mon Sep 17 00:00:00 2001 From: meliaj <17581886+meliaj@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:21:21 +0000 Subject: [PATCH 11/12] docs(security): add egress allowlists to shared responsibility model --- security.mdx | 3 ++- shared-responsibility-model.mdx | 13 ++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/security.mdx b/security.mdx index f682eb90..cf18c6dd 100644 --- a/security.mdx +++ b/security.mdx @@ -3,7 +3,7 @@ title: "Security Practices" description: "How Kernel protects your data with enterprise-grade security controls" --- -Last Modified: August 27, 2026 +Last Modified: October 9, 2026 ## Overview @@ -63,6 +63,7 @@ The scope, reward table, severity methodology, safe harbor terms, and rules of e Kernel's platform includes built-in security features: - **Unikernel Isolation** — Every Kernel browser session runs inside a dedicated Unikraft-based unikernel virtual machine, isolated at the hypervisor level. Unlike container-based approaches where multiple tenants share a host kernel, each Kernel session is a single-tenant VM with no shared operating system underneath. There is no traditional host to escape to — the unikernel is the entire system for that session. This architecture provides significantly stronger isolation guarantees than containers or processes, comparable to how other VM-based sandbox providers safely grant root access because VM boundaries make it secure. In Kernel's case, features like [SSH access](/browsers/ssh) and full shell control are safe by design: users operate within their own ephemeral VM, and any modifications are contained to that session with no impact on other customers or platform infrastructure. +- **Egress Allowlists** — Browser sessions can be limited to an allowlist of destinations enforced at Kernel's egress proxy, so requests to unlisted destinations are refused. This limits where an agent tricked by a prompt injection can send data. Allowlists cover browser traffic through the proxy rather than all traffic leaving the VM; see [egress allowlists](/browsers/egress-allowlists) and how [responsibility for this control is shared](/shared-responsibility-model#restricting-agent-egress) - **Encryption** — All data is encrypted in transit (TLS 1.2+ minimum) and at rest (AES-256) using cloud provider key management services with keys rotated at least annually - **Multi-Factor Authentication** — MFA is enforced for administrative access to the production platform, company email, version control, and cloud infrastructure - **Logical Separation** — Customer environments are logically separated, with production systems isolated from non-production environments diff --git a/shared-responsibility-model.mdx b/shared-responsibility-model.mdx index ba0a6859..31c5983c 100644 --- a/shared-responsibility-model.mdx +++ b/shared-responsibility-model.mdx @@ -3,7 +3,7 @@ title: "Shared Responsibility Model" description: "Understanding the division of security responsibilities between Kernel and its customers" --- -Last Modified: March 16, 2026 +Last Modified: October 9, 2026 ## Overview @@ -40,6 +40,17 @@ Some security controls apply to both Kernel and the customer, each operating in | **Patch Management** | Patching platform infrastructure, unikernel images, and runtime dependencies | Keeping automation dependencies and libraries up to date within their workflows | | **Configuration Management** | Securing default platform configurations and enforcing isolation boundaries | Configuring browser session options, network policies, and integration settings appropriately | | **Awareness and Training** | Training Kernel employees on security best practices and secure coding | Training their own teams on secure use of the Kernel platform and responsible data handling | +| **Agent Egress Restriction** | Enforcing each browser's [egress allowlist](/browsers/egress-allowlists) at Kernel's egress proxy and refusing CDP browser contexts that set their own proxy on allowlisted browsers | Defining the allowlist and keeping Kernel API credentials and code execution inside the browser VM out of the constrained agent's reach | + +## Restricting Agent Egress + +[Egress allowlists](/browsers/egress-allowlists) limit which destinations a browser session can reach, so an agent tricked by a prompt injection into sending data to an attacker's server gets a refusal instead of a connection. Responsibility for this control is split: + +- **Kernel enforces the allowlist** — Kernel's egress proxy refuses Chromium's HTTP(S) and WebSocket requests to unlisted destinations, starting with the browser's first request. On allowlisted browsers, Kernel also refuses CDP browser contexts that set their own proxy and rejects Chrome policies that can send traffic around the proxy. If Kernel can't start a browser that enforces the allowlist, browser creation fails instead of starting the browser without it. +- **You control who can change it** — Anyone holding your Kernel API credentials can replace or remove an allowlist, or create a browser without one. Set allowlists from your own code, and keep your API keys and the Kernel [MCP server](/reference/mcp-server) out of the constrained agent's reach. +- **You control what runs inside the VM** — Enforcement covers browser traffic through Kernel's egress proxy, not all traffic leaving the browser VM. Code executing inside the VM can send traffic directly and disable the VM-side CDP restriction, so don't give the constrained agent code execution inside the VM. Extensions with the `proxy` permission and destinations in `network.private_hosts` also aren't filtered. + +An allowlist isn't full VM-level network isolation. Review its [limitations](/browsers/egress-allowlists#limitations) before relying on it. ## Session Lifecycle and Data Responsibility From dba4bc40bb10c2a93be10b4d8a100e4468b62538 Mon Sep 17 00:00:00 2001 From: meliaj <17581886+meliaj@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:47:18 +0000 Subject: [PATCH 12/12] docs(security): describe allowlists on browser pools --- shared-responsibility-model.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/shared-responsibility-model.mdx b/shared-responsibility-model.mdx index 31c5983c..7516be44 100644 --- a/shared-responsibility-model.mdx +++ b/shared-responsibility-model.mdx @@ -49,6 +49,7 @@ Some security controls apply to both Kernel and the customer, each operating in - **Kernel enforces the allowlist** — Kernel's egress proxy refuses Chromium's HTTP(S) and WebSocket requests to unlisted destinations, starting with the browser's first request. On allowlisted browsers, Kernel also refuses CDP browser contexts that set their own proxy and rejects Chrome policies that can send traffic around the proxy. If Kernel can't start a browser that enforces the allowlist, browser creation fails instead of starting the browser without it. - **You control who can change it** — Anyone holding your Kernel API credentials can replace or remove an allowlist, or create a browser without one. Set allowlists from your own code, and keep your API keys and the Kernel [MCP server](/reference/mcp-server) out of the constrained agent's reach. - **You control what runs inside the VM** — Enforcement covers browser traffic through Kernel's egress proxy, not all traffic leaving the browser VM. Code executing inside the VM can send traffic directly and disable the VM-side CDP restriction, so don't give the constrained agent code execution inside the VM. Extensions with the `proxy` permission and destinations in `network.private_hosts` also aren't filtered. +- **Pools keep the allowlist between leases** — A [browser pool](/browsers/pools) can set an allowlist that applies to every browser it creates. When you release a browser for reuse, Kernel restores the pool's current allowlist before handing it out again, or replaces the browser if it can't safely restore and enforce that allowlist. Reuse still keeps cookies and other browser state, so release with `reuse: false` when the next lease mustn't inherit the previous one's state. An allowlist isn't full VM-level network isolation. Review its [limitations](/browsers/egress-allowlists#limitations) before relying on it.