Skip to content

Commit 4d38f10

Browse files
committed
docs on how to use
1 parent 9b32add commit 4d38f10

2 files changed

Lines changed: 308 additions & 8 deletions

File tree

docs/egress.md

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
# Egress (Outbound Interception)
2+
3+
Containers make outbound HTTP and HTTPS requests to talk to external services.
4+
By default, whether those requests succeed depends on `enableInternet`. The
5+
egress system lets you intercept, rewrite, allow, or block those requests
6+
before they leave the Cloudflare network.
7+
8+
## Setup
9+
10+
Export `ContainerProxy` from your Worker entrypoint. Without this export,
11+
outbound interception will not work.
12+
13+
```ts
14+
export { ContainerProxy } from '@cloudflare/containers';
15+
```
16+
17+
## Configuration
18+
19+
All of the following are instance properties on your `Container` subclass.
20+
21+
### `enableInternet`
22+
23+
Controls whether the container can access the public internet by default.
24+
25+
```ts
26+
enableInternet = false; // block all outbound by default
27+
```
28+
29+
### `interceptHttps`
30+
31+
When `true`, outbound HTTPS traffic is also intercepted through the same
32+
handler chain as HTTP. The container must trust the Cloudflare-provided CA
33+
certificate at `/etc/cloudflare/certs/cloudflare-containers-ca.crt`.
34+
35+
```ts
36+
interceptHttps = true;
37+
```
38+
39+
**Trusting the CA certificate in your container:**
40+
41+
The CA certificate is ephemeral and only available at runtime, so it cannot be
42+
baked into your Docker image. Instead, trust it in your entrypoint before your
43+
application starts.
44+
45+
On Debian/Ubuntu based containers:
46+
47+
```bash
48+
cp /etc/cloudflare/certs/cloudflare-containers-ca.crt \
49+
/usr/local/share/ca-certificates/cloudflare-containers-ca.crt && \
50+
update-ca-certificates
51+
```
52+
53+
Most languages and HTTP clients (curl, Node.js, Python requests, Go's
54+
`net/http`) will then trust it automatically via the system root store.
55+
56+
If your runtime does not use the system store, you can point it at the
57+
certificate directly via environment variables:
58+
59+
```bash
60+
# Node.js
61+
export NODE_EXTRA_CA_CERTS=/etc/cloudflare/certs/cloudflare-containers-ca.crt
62+
63+
# Python (requests)
64+
export REQUESTS_CA_BUNDLE=/etc/cloudflare/certs/cloudflare-containers-ca.crt
65+
66+
# curl
67+
curl --cacert /etc/cloudflare/certs/cloudflare-containers-ca.crt https://example.com
68+
```
69+
70+
### `allowedHosts`
71+
72+
A list of hostname patterns. When non-empty, it acts as a **whitelist gate**:
73+
only matching hosts can proceed past this check. Hosts that do not match are
74+
blocked with HTTP 520.
75+
76+
Supports simple glob patterns where `*` matches any sequence of characters.
77+
78+
```ts
79+
allowedHosts = ['api.stripe.com', '*.example.com'];
80+
```
81+
82+
Behaviour depends on whether a catch-all `outbound` handler is defined:
83+
84+
- **With `outbound`:** Only matching hosts reach the catch-all handler.
85+
Everything else is blocked, even if `enableInternet` is `true`.
86+
- **Without `outbound`:** Matching hosts get internet access, regardless of
87+
`enableInternet`. Non-matching hosts fall through to `enableInternet`.
88+
89+
### `deniedHosts`
90+
91+
A list of hostname patterns. Matching hosts are **blocked unconditionally**
92+
(HTTP 520). This overrides everything else in the chain, including per-host
93+
handlers set via `outboundByHost`.
94+
95+
Supports the same simple glob patterns as `allowedHosts`.
96+
97+
```ts
98+
deniedHosts = ['evil.com', '*.malware.net'];
99+
```
100+
101+
## Outbound handlers
102+
103+
### `outboundByHost`
104+
105+
Map of hostname to handler function. Handles requests to specific hosts.
106+
107+
```ts
108+
MyContainer.outboundByHost = {
109+
'api.openai.com': (req, env, ctx) => {
110+
// custom logic for openai
111+
return fetch(req);
112+
},
113+
};
114+
```
115+
116+
### `outbound`
117+
118+
Catch-all handler invoked for any host that was not handled by a more specific
119+
rule. When this is defined, all outbound HTTP is intercepted (not just specific
120+
hosts).
121+
122+
```ts
123+
MyContainer.outbound = (req, env, ctx) => {
124+
console.log('outbound request to', new URL(req.url).hostname);
125+
return fetch(req);
126+
};
127+
```
128+
129+
### `outboundHandlers`
130+
131+
Named handlers that can be referenced at runtime via `setOutboundHandler()` or
132+
`setOutboundByHost()`. This is how you swap handler logic without redeploying.
133+
134+
```ts
135+
MyContainer.outboundHandlers = {
136+
async github(req, env, ctx) {
137+
return new Response('handled by github handler');
138+
},
139+
async logging(req, env, ctx) {
140+
console.log(req.url);
141+
return fetch(req);
142+
},
143+
};
144+
```
145+
146+
## Runtime methods
147+
148+
These methods modify the outbound configuration of a running container
149+
instance. Changes are persisted across Durable Object restarts.
150+
151+
| Method | Description |
152+
| ---------------------------------------------- | ------------------------------------------------------------ |
153+
| `setOutboundHandler(name, ...params)` | Set the catch-all to a named handler from `outboundHandlers` |
154+
| `setOutboundByHost(hostname, name, ...params)` | Set a per-host handler at runtime |
155+
| `removeOutboundByHost(hostname)` | Remove a runtime per-host override |
156+
| `setOutboundByHosts(handlers)` | Replace all runtime per-host overrides at once |
157+
| `setAllowedHosts(hosts)` | Replace the allowed hosts list |
158+
| `setDeniedHosts(hosts)` | Replace the denied hosts list |
159+
| `allowHost(hostname)` | Add a single host to the allowed list |
160+
| `denyHost(hostname)` | Add a single host to the denied list |
161+
| `removeAllowedHost(hostname)` | Remove a host from the allowed list |
162+
| `removeDeniedHost(hostname)` | Remove a host from the denied list |
163+
164+
## Processing order
165+
166+
When the container makes an outbound request, it is evaluated against the
167+
following rules **in order**. The first match wins.
168+
169+
### Step 1 — Denied hosts
170+
171+
If the hostname matches any `deniedHosts` pattern, the request is blocked
172+
(HTTP 520). This is the highest priority check. It overrides everything else:
173+
per-host handlers, the catch-all, `allowedHosts`, and `enableInternet`.
174+
175+
### Step 2 — Allowed hosts gate
176+
177+
If `allowedHosts` is non-empty and the hostname does **not** match any pattern,
178+
the request is blocked (HTTP 520). When `allowedHosts` is empty this step is
179+
skipped entirely and all hosts proceed.
180+
181+
This gates everything below, including `outboundByHost`. Setting an
182+
`outboundByHost` handler for a hostname does not allow it — it only maps what
183+
handler runs when the host _is_ allowed. If you use `allowedHosts`, you must
184+
include the hostname there too.
185+
186+
### Step 3 — Per-host handler (runtime)
187+
188+
If `setOutboundByHost()` was called for this exact hostname, the registered
189+
handler is invoked.
190+
191+
### Step 4 — Per-host handler (static)
192+
193+
If `outboundByHost` contains this exact hostname, the registered handler
194+
is invoked.
195+
196+
### Step 5 — Catch-all handler (runtime)
197+
198+
If `setOutboundHandler()` was called at runtime, that handler is invoked.
199+
200+
### Step 6 — Catch-all handler (static)
201+
202+
If `outbound` is defined, it is invoked.
203+
204+
### Step 7 — Allowed host internet fallback
205+
206+
If the hostname matched `allowedHosts` but no outbound handler above handled
207+
it, the request is forwarded to the public internet. This is the mechanism that
208+
lets `allowedHosts` grant internet access when `enableInternet` is `false`.
209+
210+
### Step 8 — `enableInternet` fallback
211+
212+
If `enableInternet` is `true`, the request is forwarded to the public internet.
213+
214+
### Step 9 — Default deny
215+
216+
The request is blocked (HTTP 520).
217+
218+
## Interception strategy
219+
220+
The library avoids intercepting all outbound traffic when it is not necessary.
221+
222+
- **Catch-all interception** (`interceptAllOutboundHttp`) is only used when a
223+
catch-all `outbound` handler or a runtime `setOutboundHandler` override is
224+
configured. All outbound HTTP goes through `ContainerProxy`.
225+
- **Per-host interception** (`interceptOutboundHttp`) is used in all other
226+
cases. Only traffic to known hosts (from `outboundByHost`, `allowedHosts`,
227+
`deniedHosts`, and runtime overrides) is routed through `ContainerProxy`.
228+
Everything else follows the container's default network behaviour
229+
(`enableInternet`).
230+
231+
When `interceptHttps` is `true`:
232+
233+
- In catch-all mode, `interceptOutboundHttps('*', ...)` intercepts all HTTPS.
234+
- In per-host mode, `interceptOutboundHttps(host, ...)` is called for each
235+
known host individually.
236+
237+
## Glob patterns
238+
239+
Both `allowedHosts` and `deniedHosts` support simple glob patterns where `*`
240+
matches any sequence of characters.
241+
242+
| Pattern | Matches | Does not match |
243+
| --------------- | ------------------------------------ | ------------------ |
244+
| `example.com` | `example.com` | `sub.example.com` |
245+
| `*.example.com` | `api.example.com`, `a.b.example.com` | `example.com` |
246+
| `google.*` | `google.com`, `google.co.uk` | `maps.google.com` |
247+
| `api.*.com` | `api.stripe.com`, `api.test.com` | `api.stripe.co.uk` |
248+
| `*` | everything | |
249+
250+
## Full example
251+
252+
```ts
253+
import { Container, getContainer } from '@cloudflare/containers';
254+
export { ContainerProxy } from '@cloudflare/containers';
255+
256+
export class MyContainer extends Container {
257+
defaultPort = 8080;
258+
enableInternet = false;
259+
interceptHttps = true;
260+
261+
allowedHosts = ['api.stripe.com', 'google.com', 'github.com'];
262+
deniedHosts = ['evil.com'];
263+
}
264+
265+
MyContainer.outboundByHost = {
266+
'google.com': (req, env, ctx) => {
267+
return new Response('intercepted google for ' + ctx.containerId);
268+
},
269+
};
270+
271+
MyContainer.outboundHandlers = {
272+
async github(req, env, ctx) {
273+
return new Response('github handler, ' + ctx.params?.hello);
274+
},
275+
};
276+
277+
MyContainer.outbound = req => {
278+
return new Response(`catch-all for ${new URL(req.url).hostname}`);
279+
};
280+
281+
export default {
282+
async fetch(request, env) {
283+
const container = getContainer(env.MY_CONTAINER);
284+
await container.setOutboundByHost('github.com', 'github', { hello: 'world' });
285+
return await container.fetch(request);
286+
},
287+
};
288+
```
289+
290+
With this configuration:
291+
292+
| Outbound request | Result |
293+
| ------------------------ | -------------------------------------------------------- |
294+
| `http://evil.com` | Blocked (denied host, even if it were in `allowedHosts`) |
295+
| `http://google.com` | Passes allowed gate, handled by `outboundByHost` handler |
296+
| `http://github.com` | Passes allowed gate, handled by runtime `github` handler |
297+
| `http://api.stripe.com` | Passes allowed gate, handled by `outbound` catch-all |
298+
| `http://random.com` | Blocked (not in `allowedHosts`) |
299+
| `https://api.stripe.com` | Same as HTTP, intercepted via HTTPS interception |

src/lib/container.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,14 @@ export class ContainerProxy extends WorkerEntrypoint<Cloudflare.Env, ContainerPr
326326
return new Response('Origin is disallowed', { status: 520 });
327327
}
328328

329-
// 2. outboundByHost always gets preference (runtime override)
329+
// 2. allowedHosts: when set, acts as a whitelist gate — only matching
330+
// hosts can proceed. This gates everything below, including outboundByHost.
331+
// outboundByHost only maps a handler for a hostname, it does not allow it.
332+
if (allowedHosts && allowedHosts.length > 0 && !matchesHostList(url.hostname, allowedHosts)) {
333+
return new Response('Origin is disallowed', { status: 520 });
334+
}
335+
336+
// 3. outboundByHost (runtime override)
330337
const outboundByHostOverride = outboundByHostOverrides?.[url.hostname];
331338
if (outboundByHostOverride && handlers?.[outboundByHostOverride.method]) {
332339
return handlers[outboundByHostOverride.method](request, this.env, {
@@ -335,18 +342,12 @@ export class ContainerProxy extends WorkerEntrypoint<Cloudflare.Env, ContainerPr
335342
});
336343
}
337344

338-
// 3. outboundByHost always gets preference (static)
345+
// 4. outboundByHost (static)
339346
const handlersByHost = outboundByHostRegistry.get(className);
340347
if (handlersByHost && url.hostname in handlersByHost) {
341348
return handlersByHost[url.hostname](request, this.env, baseCtx);
342349
}
343350

344-
// 4. allowedHosts: when set, acts as a whitelist gate — only matching
345-
// hosts can proceed to the catch-all outbound or internet.
346-
if (allowedHosts && allowedHosts.length > 0 && !matchesHostList(url.hostname, allowedHosts)) {
347-
return new Response('Origin is disallowed', { status: 520 });
348-
}
349-
350351
// 5. Runtime catch-all handler override
351352
if (outboundHandlerOverride && handlers?.[outboundHandlerOverride.method]) {
352353
return handlers[outboundHandlerOverride.method](request, this.env, {

0 commit comments

Comments
 (0)