Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/regional-egress-repro.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/containers": patch
---

Add deployed egress interception tests covering global and regional Worker placement.
120 changes: 61 additions & 59 deletions examples/egress-tests/test/egress.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { randomUUID } from 'node:crypto';
import { describe, expect, vi } from 'vitest';
import { test, WranglerDevRunner } from '../../test-helpers';
import { describe, expect, test, vi } from 'vitest';

/**
* Egress interception tests.
Expand All @@ -14,142 +13,145 @@ import { test, WranglerDevRunner } from '../../test-helpers';
* outbound = catch-all handler
*/
describe('egress interception', () => {
describe('local', () => {
async function proxyVia(
runner: WranglerDevRunner,
id: string,
target: string
): Promise<Response> {
const url = await runner.getUrl();
return vi.waitFor(
async () => {
const res = await fetch(`${url}/proxy?id=${id}&proxy=${encodeURIComponent(target)}`);
if (res.status === 500 || res.status === 503) {
throw new Error(`Container not ready, got ${res.status}`);
}
return res;
},
{ timeout: 15000 }
);
const baseUrl = process.env.EGRESS_TEST_BASE_URL;

if (!baseUrl) {
throw new Error('EGRESS_TEST_BASE_URL must be set to the deployed Worker URL.');
}

describe(process.env.EGRESS_TEST_ENV ?? 'deployed', () => {
async function proxyVia(id: string, target: string): Promise<Response> {
let lastResponse = '';
try {
return await vi.waitFor(
async () => {
const res = await fetch(`${baseUrl}/proxy?id=${id}&proxy=${encodeURIComponent(target)}`);
if (res.status === 500 || res.status === 503) {
lastResponse = await res.text();
throw new Error(`Container not ready, got ${res.status}: ${lastResponse}`);
}
return res;
},
{ timeout: 120000 }
);
} catch (error) {
if (lastResponse) {
throw new Error(`${error instanceof Error ? error.message : String(error)}
Last response: ${lastResponse}`);
}
throw error;
}
}

async function destroyContainer(runner: WranglerDevRunner, id: string) {
// Tell the worker to destroy the container so the in-container onStop
// hook can fire before the fixture tears down wrangler dev itself.
// The full wrangler+workerd cleanup is handled automatically by the
// `runner` test fixture.
const url = await runner.getUrl();
await fetch(`${url}/destroy?id=${id}`);
async function destroyContainer(id: string) {
await fetch(`${baseUrl}/destroy?id=${id}`);
await new Promise(resolve => setTimeout(resolve, 1000));
}

async function denyHost(runner: WranglerDevRunner, id: string, hostname: string) {
const url = await runner.getUrl();
async function denyHost(id: string, hostname: string) {
const res = await fetch(
`${url}/config/deny-host?id=${id}&hostname=${encodeURIComponent(hostname)}`
`${baseUrl}/config/deny-host?id=${id}&hostname=${encodeURIComponent(hostname)}`
);
expect(res.status).toBe(200);
}

test('deniedHosts blocks the request', async ({ runner }) => {
test('deniedHosts blocks the request', async () => {
const id = randomUUID();

const res = await proxyVia(runner, id, 'denied.com');
const res = await proxyVia(id, 'denied.com');
expect(res.status).toBe(520);
const body = await res.text();
expect(body).toContain('Origin is disallowed');

await destroyContainer(runner, id);
await destroyContainer(id);
});

test('allowedHosts gate blocks non-allowed hosts', async ({ runner }) => {
test('allowedHosts gate blocks non-allowed hosts', async () => {
const id = randomUUID();

const res = await proxyVia(runner, id, 'random.com');
const res = await proxyVia(id, 'random.com');
expect(res.status).toBe(520);
const body = await res.text();
expect(body).toContain('Origin is disallowed');

await destroyContainer(runner, id);
await destroyContainer(id);
});

test('outboundByHost handler is invoked for matching allowed host', async ({ runner }) => {
test('outboundByHost handler is invoked for matching allowed host', async () => {
const id = randomUUID();

const res = await proxyVia(runner, id, 'by-host.com');
const res = await proxyVia(id, 'by-host.com');
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toBe('outboundByHost: by-host.com');

await destroyContainer(runner, id);
await destroyContainer(id);
});

test('catch-all outbound handler is invoked for allowed host without specific handler', async ({
runner,
}) => {
test('catch-all outbound handler is invoked for allowed host without specific handler', async () => {
const id = randomUUID();

const res = await proxyVia(runner, id, 'allowed.com');
const res = await proxyVia(id, 'allowed.com');
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toBe('catch-all: allowed.com');

await destroyContainer(runner, id);
await destroyContainer(id);
});

test('denied host is blocked even if it would match allowedHosts', async ({ runner }) => {
test('denied host is blocked even if it would match allowedHosts', async () => {
const id = randomUUID();

const res = await proxyVia(runner, id, 'denied.com');
const res = await proxyVia(id, 'denied.com');
expect(res.status).toBe(520);

await destroyContainer(runner, id);
await destroyContainer(id);
});

test('glob pattern in outboundByHost matches subdomains', async ({ runner }) => {
test('glob pattern in outboundByHost matches subdomains', async () => {
const id = randomUUID();

const res = await proxyVia(runner, id, 'api.globtest.com');
const res = await proxyVia(id, 'api.globtest.com');
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toBe('outboundByHost glob: api.globtest.com');

await destroyContainer(runner, id);
await destroyContainer(id);
});

test('glob pattern in outboundByHost matches deeply nested subdomains', async ({ runner }) => {
test('glob pattern in outboundByHost matches deeply nested subdomains', async () => {
const id = randomUUID();

const res = await proxyVia(runner, id, 'a.b.globtest.com');
const res = await proxyVia(id, 'a.b.globtest.com');
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toBe('outboundByHost glob: a.b.globtest.com');

await destroyContainer(runner, id);
await destroyContainer(id);
});

test('glob pattern in allowedHosts blocks non-matching host', async ({ runner }) => {
test('glob pattern in allowedHosts blocks non-matching host', async () => {
const id = randomUUID();

// globtest.com itself does NOT match *.globtest.com
const res = await proxyVia(runner, id, 'globtest.com');
const res = await proxyVia(id, 'globtest.com');
expect(res.status).toBe(520);

await destroyContainer(runner, id);
await destroyContainer(id);
});

test('denyHost also blocks the same hostname with a trailing dot', async ({ runner }) => {
test('denyHost also blocks the same hostname with a trailing dot', async () => {
const id = randomUUID();
const hostname = `allowed-${randomUUID()}.example.com`;

await denyHost(runner, id, hostname);
await denyHost(id, hostname);

const res = await proxyVia(runner, id, `${hostname}.`);
const res = await proxyVia(id, `${hostname}.`);
expect(res.status).toBe(520);
const body = await res.text();
expect(body).toContain('Origin is disallowed');

await destroyContainer(runner, id);
await destroyContainer(id);
});
});
});
2 changes: 1 addition & 1 deletion examples/egress-tests/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
testTimeout: 30000,
testTimeout: 180000,
},
});
57 changes: 56 additions & 1 deletion examples/egress-tests/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"name": "egress-tests",
"main": "src/index.ts",
"compatibility_date": "2026-05-12",
"workers_dev": true,
"observability": {
"enabled": true,
},
Expand All @@ -10,7 +11,7 @@
"image": "./Dockerfile",
"class_name": "EgressTestContainer",
"name": "egress-test-container",
"max_instances": 2,
"max_instances": 12,
},
],
"durable_objects": {
Expand All @@ -27,4 +28,58 @@
"new_sqlite_classes": ["EgressTestContainer"],
},
],
"env": {
"global": {
"containers": [
{
"image": "./Dockerfile",
"class_name": "EgressTestContainer",
"name": "egress-test-container-global",
"max_instances": 12,
},
],
"durable_objects": {
"bindings": [
{
"class_name": "EgressTestContainer",
"name": "CONTAINER",
},
],
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["EgressTestContainer"],
},
],
},
"regional": {
"placement": {
"mode": "targeted",
"region": "aws:us-east-1",
},
"containers": [
{
"image": "./Dockerfile",
"class_name": "EgressTestContainer",
"name": "egress-test-container-regional",
"max_instances": 12,
},
],
"durable_objects": {
"bindings": [
{
"class_name": "EgressTestContainer",
"name": "CONTAINER",
},
],
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["EgressTestContainer"],
},
],
},
},
}