Skip to content

Commit 2a942e3

Browse files
authored
Merge pull request #171 from cloudflare/gv/tls
Add interceptHttps option to allow intercepting HTTPS and allow/deny list
2 parents 0d4f80b + da5627d commit 2a942e3

33 files changed

Lines changed: 6234 additions & 2099 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@cloudflare/containers': minor
3+
---
4+
5+
Opt in to interceptOutboundHttps when interceptHttps = true

docs/egress.md

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
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, copy it into the distro's certificate
43+
directory and refresh the trust store in your entrypoint before your
44+
application starts.
45+
46+
| Distribution | Certificate directory | Update command |
47+
| ------------- | -------------------------------------------- | ------------------------ |
48+
| Alpine | `/usr/local/share/ca-certificates/` | `update-ca-certificates` |
49+
| Debian/Ubuntu | `/usr/local/share/ca-certificates/` | `update-ca-certificates` |
50+
| Fedora/RHEL | `/etc/pki/ca-trust/source/anchors/` | `update-ca-trust` |
51+
| Arch | `/etc/ca-certificates/trust-source/anchors/` | `trust extract-compat` |
52+
53+
Example entrypoint snippets:
54+
55+
Alpine or Debian/Ubuntu:
56+
57+
```sh
58+
cp /etc/cloudflare/certs/cloudflare-containers-ca.crt \
59+
/usr/local/share/ca-certificates/cloudflare-containers-ca.crt && \
60+
update-ca-certificates
61+
```
62+
63+
Fedora or RHEL:
64+
65+
```sh
66+
cp /etc/cloudflare/certs/cloudflare-containers-ca.crt \
67+
/etc/pki/ca-trust/source/anchors/cloudflare-containers-ca.crt && \
68+
update-ca-trust
69+
```
70+
71+
Arch:
72+
73+
```sh
74+
cp /etc/cloudflare/certs/cloudflare-containers-ca.crt \
75+
/etc/ca-certificates/trust-source/anchors/cloudflare-containers-ca.crt && \
76+
trust extract-compat
77+
```
78+
79+
Alpine/Debian/Ubuntu `Container` entrypoint example:
80+
81+
```ts
82+
export class MyContainer extends Container {
83+
interceptHttps = true;
84+
entrypoint = [
85+
'sh',
86+
'-lc',
87+
'cp /etc/cloudflare/certs/cloudflare-containers-ca.crt /usr/local/share/ca-certificates/cloudflare-containers-ca.crt && update-ca-certificates && exec node server.js',
88+
];
89+
}
90+
```
91+
92+
For Fedora/RHEL or Arch, swap the destination path and trust-store refresh
93+
command to match the table above.
94+
95+
Most languages and HTTP clients (curl, Node.js, Python requests, Go's
96+
`net/http`) will then trust it automatically via the system root store.
97+
98+
If your runtime does not use the system store, you can point it at the
99+
certificate directly via environment variables:
100+
101+
```bash
102+
# Node.js
103+
export NODE_EXTRA_CA_CERTS=/etc/cloudflare/certs/cloudflare-containers-ca.crt
104+
105+
# Python (requests)
106+
export REQUESTS_CA_BUNDLE=/etc/cloudflare/certs/cloudflare-containers-ca.crt
107+
108+
# curl
109+
curl --cacert /etc/cloudflare/certs/cloudflare-containers-ca.crt https://example.com
110+
```
111+
112+
### `allowedHosts`
113+
114+
A list of hostname patterns. When non-empty, it acts as a **whitelist gate**:
115+
only matching hosts can proceed past this check. Hosts that do not match are
116+
blocked with HTTP 520.
117+
118+
Supports simple glob patterns where `*` matches any sequence of characters.
119+
120+
```ts
121+
allowedHosts = ['api.stripe.com', '*.example.com'];
122+
```
123+
124+
Behaviour depends on whether a catch-all `outbound` handler is defined:
125+
126+
- **With `outbound`:** Only matching hosts reach the catch-all handler.
127+
Everything else is blocked, even if `enableInternet` is `true`.
128+
- **Without `outbound`:** Matching hosts get internet access, regardless of
129+
`enableInternet`. Non-matching hosts fall through to `enableInternet`.
130+
131+
### `deniedHosts`
132+
133+
A list of hostname patterns. Matching hosts are **blocked unconditionally**
134+
(HTTP 520). This overrides everything else in the chain, including per-host
135+
handlers set via `outboundByHost`.
136+
137+
Supports the same simple glob patterns as `allowedHosts`.
138+
139+
```ts
140+
deniedHosts = ['evil.com', '*.malware.net'];
141+
```
142+
143+
## Outbound handlers
144+
145+
### `outboundByHost`
146+
147+
Map of hostname to handler function. Handles requests to specific hosts.
148+
149+
```ts
150+
MyContainer.outboundByHost = {
151+
'api.openai.com': (req, env, ctx) => {
152+
// custom logic for openai
153+
return fetch(req);
154+
},
155+
};
156+
```
157+
158+
### `outbound`
159+
160+
Catch-all handler invoked for any host that was not handled by a more specific
161+
rule. When this is defined, all outbound HTTP is intercepted (not just specific
162+
hosts).
163+
164+
```ts
165+
MyContainer.outbound = (req, env, ctx) => {
166+
console.log('outbound request to', new URL(req.url).hostname);
167+
return fetch(req);
168+
};
169+
```
170+
171+
### `outboundHandlers`
172+
173+
Named handlers that can be referenced at runtime via `setOutboundHandler()` or
174+
`setOutboundByHost()`. This is how you swap handler logic without redeploying.
175+
176+
```ts
177+
MyContainer.outboundHandlers = {
178+
async github(req, env, ctx) {
179+
return new Response('handled by github handler');
180+
},
181+
async logging(req, env, ctx) {
182+
console.log(req.url);
183+
return fetch(req);
184+
},
185+
};
186+
```
187+
188+
## Runtime methods
189+
190+
These methods modify the outbound configuration of a running container
191+
instance. Changes are persisted across Durable Object restarts.
192+
193+
| Method | Description |
194+
| ---------------------------------------------- | ------------------------------------------------------------ |
195+
| `setOutboundHandler(name, ...params)` | Set the catch-all to a named handler from `outboundHandlers` |
196+
| `setOutboundByHost(hostname, name, ...params)` | Set a per-host handler at runtime |
197+
| `removeOutboundByHost(hostname)` | Remove a runtime per-host override |
198+
| `setOutboundByHosts(handlers)` | Replace all runtime per-host overrides at once |
199+
| `setAllowedHosts(hosts)` | Replace the allowed hosts list |
200+
| `setDeniedHosts(hosts)` | Replace the denied hosts list |
201+
| `allowHost(hostname)` | Add a single host to the allowed list |
202+
| `denyHost(hostname)` | Add a single host to the denied list |
203+
| `removeAllowedHost(hostname)` | Remove a host from the allowed list |
204+
| `removeDeniedHost(hostname)` | Remove a host from the denied list |
205+
206+
## Processing order
207+
208+
When the container makes an outbound request, it is evaluated against the
209+
following rules **in order**. The first match wins.
210+
211+
### Step 1 — Denied hosts
212+
213+
If the hostname matches any `deniedHosts` pattern, the request is blocked
214+
(HTTP 520). This is the highest priority check. It overrides everything else:
215+
per-host handlers, the catch-all, `allowedHosts`, and `enableInternet`.
216+
217+
### Step 2 — Allowed hosts gate
218+
219+
If `allowedHosts` is non-empty and the hostname does **not** match any pattern,
220+
the request is blocked (HTTP 520). When `allowedHosts` is empty this step is
221+
skipped entirely and all hosts proceed.
222+
223+
This gates everything below, including `outboundByHost`. Setting an
224+
`outboundByHost` handler for a hostname does not allow it — it only maps what
225+
handler runs when the host _is_ allowed. If you use `allowedHosts`, you must
226+
include the hostname there too.
227+
228+
### Step 3 — Per-host handler (runtime)
229+
230+
If `setOutboundByHost()` was called for this exact hostname, the registered
231+
handler is invoked.
232+
233+
### Step 4 — Per-host handler (static)
234+
235+
If `outboundByHost` contains this exact hostname, the registered handler
236+
is invoked.
237+
238+
### Step 5 — Catch-all handler (runtime)
239+
240+
If `setOutboundHandler()` was called at runtime, that handler is invoked.
241+
242+
### Step 6 — Catch-all handler (static)
243+
244+
If `outbound` is defined, it is invoked.
245+
246+
### Step 7 — Allowed host internet fallback
247+
248+
If the hostname matched `allowedHosts` but no outbound handler above handled
249+
it, the request is forwarded to the public internet. This is the mechanism that
250+
lets `allowedHosts` grant internet access when `enableInternet` is `false`.
251+
252+
### Step 8 — `enableInternet` fallback
253+
254+
If `enableInternet` is `true`, the request is forwarded to the public internet.
255+
256+
### Step 9 — Default deny
257+
258+
The request is blocked (HTTP 520).
259+
260+
## Interception strategy
261+
262+
The library avoids intercepting all outbound traffic when it is not necessary,
263+
but only keeps the per-host optimization for the narrow static case.
264+
265+
- **Intercept-all mode** (`interceptAllOutboundHttp`) is used whenever the
266+
container needs to evaluate all hosts, including catch-all `outbound`, a
267+
runtime `setOutboundHandler` override, any `allowedHosts` / `deniedHosts`
268+
configuration, or runtime-mutated outbound config such as
269+
`setOutboundByHost()`.
270+
- **Per-host interception** (`interceptOutboundHttp`) is only used for static
271+
`outboundByHost` rules when there is no catch-all handler and no allow/deny
272+
configuration. Only those known static hosts are routed through
273+
`ContainerProxy`; everything else follows the container's default network
274+
behaviour (`enableInternet`).
275+
276+
When `interceptHttps` is `true`:
277+
278+
- In intercept-all mode, `interceptOutboundHttps('*', ...)` intercepts all HTTPS.
279+
- In per-host mode, `interceptOutboundHttps(host, ...)` is called for each
280+
known host individually.
281+
282+
## Glob patterns
283+
284+
Both `allowedHosts` and `deniedHosts` support simple glob patterns where `*`
285+
matches any sequence of characters.
286+
287+
| Pattern | Matches | Does not match |
288+
| --------------- | ------------------------------------ | ------------------ |
289+
| `example.com` | `example.com` | `sub.example.com` |
290+
| `*.example.com` | `api.example.com`, `a.b.example.com` | `example.com` |
291+
| `google.*` | `google.com`, `google.co.uk` | `maps.google.com` |
292+
| `api.*.com` | `api.stripe.com`, `api.test.com` | `api.stripe.co.uk` |
293+
| `*` | everything | |
294+
295+
## Full example
296+
297+
```ts
298+
import { Container, getContainer } from '@cloudflare/containers';
299+
export { ContainerProxy } from '@cloudflare/containers';
300+
301+
export class MyContainer extends Container {
302+
defaultPort = 8080;
303+
enableInternet = false;
304+
interceptHttps = true;
305+
306+
allowedHosts = ['api.stripe.com', 'google.com', 'github.com'];
307+
deniedHosts = ['evil.com'];
308+
}
309+
310+
MyContainer.outboundByHost = {
311+
'google.com': (req, env, ctx) => {
312+
return new Response('intercepted google for ' + ctx.containerId);
313+
},
314+
};
315+
316+
MyContainer.outboundHandlers = {
317+
async github(req, env, ctx) {
318+
return new Response('github handler, ' + ctx.params?.hello);
319+
},
320+
};
321+
322+
MyContainer.outbound = req => {
323+
return new Response(`catch-all for ${new URL(req.url).hostname}`);
324+
};
325+
326+
export default {
327+
async fetch(request, env) {
328+
const container = getContainer(env.MY_CONTAINER);
329+
await container.setOutboundByHost('github.com', 'github', { hello: 'world' });
330+
return await container.fetch(request);
331+
},
332+
};
333+
```
334+
335+
With this configuration:
336+
337+
| Outbound request | Result |
338+
| ------------------------ | -------------------------------------------------------- |
339+
| `http://evil.com` | Blocked (denied host, even if it were in `allowedHosts`) |
340+
| `http://google.com` | Passes allowed gate, handled by `outboundByHost` handler |
341+
| `http://github.com` | Passes allowed gate, handled by runtime `github` handler |
342+
| `http://api.stripe.com` | Passes allowed gate, handled by `outbound` catch-all |
343+
| `http://random.com` | Blocked (not in `allowedHosts`) |
344+
| `https://api.stripe.com` | Same as HTTP, intercepted via HTTPS interception |

0 commit comments

Comments
 (0)