Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .changeset/custom-port-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ You can now pass a custom token when exposing ports to maintain consistent previ
// With custom token - URL stays the same across restarts
const { url } = await sandbox.exposePort(8080, {
hostname: 'example.com',
token: 'my-token-v1' // 1-16 chars: a-z, 0-9, -, _
token: 'my_token_v1' // 1-16 chars: a-z, 0-9, _
});
// url: https://8080-sandbox-id-my-token-v1.example.com

Expand All @@ -21,4 +21,4 @@ const { url } = await sandbox.exposePort(8080, {
// url: https://8080-sandbox-id-abc123random4567.example.com
```

Custom tokens must be 1-16 characters and contain only lowercase letters, numbers, hyphens, and underscores to ensure URL compatibility.
Custom tokens must be 1-16 characters containing only lowercase letters, numbers, and underscores.
53 changes: 37 additions & 16 deletions packages/sandbox/src/request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,35 +115,56 @@ export async function proxyToSandbox<
}

function extractSandboxRoute(url: URL): RouteInfo | null {
// Parse subdomain pattern: port-sandboxId-token.domain (tokens mandatory)
// Token can be 1-16 chars (SDK validates) or up to 63 chars (DNS limit for forward compatibility)
const subdomainMatch = url.hostname.match(
/^(\d{4,5})-([^.-][^.]*?[^.-]|[^.-])-([a-z0-9_-]+)\.(.+)$/
);
// URL format: {port}-{sandboxId}-{token}.{domain}
// Tokens are [a-z0-9_]+, so we split at the last hyphen to handle sandboxIds with hyphens (UUIDs)
const dotIndex = url.hostname.indexOf('.');
if (dotIndex === -1) {
return null;
}

if (!subdomainMatch) {
const subdomain = url.hostname.slice(0, dotIndex);
const domain = url.hostname.slice(dotIndex + 1);

// Extract port (digits at start followed by hyphen)
const firstHyphen = subdomain.indexOf('-');
if (firstHyphen === -1) {
return null;
}

const portStr = subdomainMatch[1];
const sandboxId = subdomainMatch[2];
const token = subdomainMatch[3]; // Mandatory token
const domain = subdomainMatch[4];
const portStr = subdomain.slice(0, firstHyphen);
if (!/^\d{4,5}$/.test(portStr)) {
return null;
}

const port = parseInt(portStr, 10);
if (!validatePort(port)) {
return null;
}

let sanitizedSandboxId: string;
try {
sanitizedSandboxId = sanitizeSandboxId(sandboxId);
} catch (error) {
// Extract token (last hyphen-delimited segment) and sandboxId (everything between port and token)
const rest = subdomain.slice(firstHyphen + 1);
const lastHyphen = rest.lastIndexOf('-');
if (lastHyphen === -1) {
return null;
}

// DNS subdomain length limit is 63 characters (applies to each component)
if (sandboxId.length > 63 || token.length > 63) {
const sandboxId = rest.slice(0, lastHyphen);
const token = rest.slice(lastHyphen + 1);

// Validate token format (no hyphens allowed)
if (!/^[a-z0-9_]+$/.test(token) || token.length === 0 || token.length > 63) {
return null;
}

// Validate and sanitize sandboxId
if (sandboxId.length === 0 || sandboxId.length > 63) {
return null;
}

let sanitizedSandboxId: string;
try {
sanitizedSandboxId = sanitizeSandboxId(sandboxId);
} catch {
return null;
}

Expand Down
7 changes: 3 additions & 4 deletions packages/sandbox/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2311,9 +2311,9 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
);
}

if (!/^[a-z0-9_-]+$/.test(token)) {
if (!/^[a-z0-9_]+$/.test(token)) {
throw new SecurityError(
`Custom token must contain only lowercase letters (a-z), numbers (0-9), hyphens (-), and underscores (_). Invalid token provided.`
`Custom token must contain only lowercase letters (a-z), numbers (0-9), and underscores (_). Invalid token provided.`
);
}
}
Expand All @@ -2324,10 +2324,9 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
const array = new Uint8Array(12); // 12 bytes = 16 base64url chars (after padding removal)
crypto.getRandomValues(array);

// Convert to base64url format (URL-safe, no padding, lowercase)
const base64 = btoa(String.fromCharCode(...array));
return base64
.replace(/\+/g, '-')
.replace(/\+/g, '_')
.replace(/\//g, '_')
.replace(/=/g, '')
.toLowerCase();
Expand Down
8 changes: 6 additions & 2 deletions packages/sandbox/tests/sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -910,9 +910,9 @@ describe('Sandbox - Automatic Session Management', () => {
it('should validate token format and length', async () => {
const result = await sandbox.exposePort(8080, {
hostname: 'example.com',
token: 'abc-123_xyz'
token: 'abc_123_xyz'
});
expect(result.url).toContain('abc-123_xyz');
expect(result.url).toContain('abc_123_xyz');

await expect(
sandbox.exposePort(8080, { hostname: 'example.com', token: '' })
Expand All @@ -928,6 +928,10 @@ describe('Sandbox - Automatic Session Management', () => {
await expect(
sandbox.exposePort(8080, { hostname: 'example.com', token: 'ABC123' })
).rejects.toThrow('lowercase letters');

await expect(
sandbox.exposePort(8080, { hostname: 'example.com', token: 'abc-123' })
).rejects.toThrow('underscores (_)');
});

it('should prevent token collision across different ports', async () => {
Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/process-readiness-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,12 +497,12 @@ Bun.serve({
const exposeResponse = await fetch(`${workerUrl}/api/port/expose`, {
method: 'POST',
headers: portHeaders,
body: JSON.stringify({ port: 9093, token: 'my-stable-token' })
body: JSON.stringify({ port: 9093, token: 'my_stable_token' })
});

expect(exposeResponse.status).toBe(200);
const { url } = (await exposeResponse.json()) as PortExposeResult;
expect(url).toContain('my-stable-token');
expect(url).toContain('my_stable_token');

const apiResponse = await fetch(url);
expect(apiResponse.status).toBe(200);
Expand Down
3 changes: 2 additions & 1 deletion tests/e2e/test-worker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ FROM docker.io/cloudflare/sandbox-test:0.6.11
# 9090: process-readiness-workflow.test.ts (waitForPort test)
# 9091: process-readiness-workflow.test.ts (chained waitForLog/Port test)
# 9092: process-readiness-workflow.test.ts (port exposure test)
# 9093: process-readiness-workflow.test.ts (custom token port exposure test)
# 9998: reserved for process-lifecycle-workflow.test.ts
# 9999: reserved for websocket-workflow.test.ts
EXPOSE 8080 9090 9091 9092 9998 9999
EXPOSE 8080 9090 9091 9092 9093 9998 9999
3 changes: 2 additions & 1 deletion tests/e2e/test-worker/Dockerfile.python
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ FROM docker.io/cloudflare/sandbox-test:0.6.11-python
# 9090: process-readiness-workflow.test.ts (waitForPort test)
# 9091: process-readiness-workflow.test.ts (chained waitForLog/Port test)
# 9092: process-readiness-workflow.test.ts (port exposure test)
# 9093: process-readiness-workflow.test.ts (custom token port exposure test)
# 9998: reserved for process-lifecycle-workflow.test.ts
# 9999: reserved for websocket-workflow.test.ts
EXPOSE 8080 9090 9091 9092 9998 9999
EXPOSE 8080 9090 9091 9092 9093 9998 9999
Loading