Skip to content

Commit 8503265

Browse files
Non-breaking option to fix preview URLs (#213)
Hostnames are case-insensitive at the DNS level, so the URL class automatically converts the hostname to lowercase. But as a result, preview URLs for sandbox IDs with uppercase characters was being routed to incorrect DO instances. Add opt-in notmalizeId option that normalizes IDs to lowercase. This is done in order to prevent existing services from not being able to access sandbox instances started by an older version. We instead raise warnings in logs and errors right now so there is no breaking change for anyone, but giving developers sufficient time to transition to the new behaviour before we make this the default.
1 parent a6bedc2 commit 8503265

File tree

6 files changed

+187
-20
lines changed

6 files changed

+187
-20
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@cloudflare/sandbox': minor
3+
---
4+
5+
Add opt-in `normalizeId` option to `getSandbox()` for preview URL compatibility.
6+
7+
Sandbox IDs with uppercase letters cause preview URL requests to route to different Durable Object instances (hostnames are case-insensitive). Use `{ normalizeId: true }` to lowercase IDs for preview URL support:
8+
9+
```typescript
10+
getSandbox(ns, 'MyProject-123', { normalizeId: true }); // Creates DO with key "myproject-123"
11+
```
12+
13+
**Important:** Different `normalizeId` values create different DO instances. If you have an existing sandbox with uppercase letters, create a new one with `normalizeId: true`.
14+
15+
**Deprecation warning:** IDs with uppercase letters will trigger a warning. In a future version, `normalizeId` will default to `true`.

packages/sandbox/src/request-handler.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ export async function proxyToSandbox<E extends SandboxEnv>(
3636
}
3737

3838
const { sandboxId, port, path, token } = routeInfo;
39-
const sandbox = getSandbox(env.Sandbox, sandboxId);
39+
// Preview URLs always use normalized (lowercase) IDs
40+
const sandbox = getSandbox(env.Sandbox, sandboxId, { normalizeId: true });
4041

4142
// Critical security check: Validate token (mandatory for all user ports)
4243
// Skip check for control plane port 3000

packages/sandbox/src/sandbox.ts

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,24 @@ export function getSandbox(
3737
id: string,
3838
options?: SandboxOptions
3939
): Sandbox {
40-
const stub = getContainer(ns, id) as unknown as Sandbox;
40+
const sanitizedId = sanitizeSandboxId(id);
41+
const effectiveId = options?.normalizeId
42+
? sanitizedId.toLowerCase()
43+
: sanitizedId;
44+
45+
const hasUppercase = /[A-Z]/.test(sanitizedId);
46+
if (!options?.normalizeId && hasUppercase) {
47+
const logger = createLogger({ component: 'sandbox-do' });
48+
logger.warn(
49+
`Sandbox ID "${sanitizedId}" contains uppercase letters, which causes issues with preview URLs (hostnames are case-insensitive). ` +
50+
`normalizeId will default to true in a future version to prevent this. ` +
51+
`Use lowercase IDs or pass { normalizeId: true } to prepare.`
52+
);
53+
}
4154

42-
// Store the name on first access
43-
stub.setSandboxName?.(id);
55+
const stub = getContainer(ns, effectiveId) as unknown as Sandbox;
56+
57+
stub.setSandboxName?.(effectiveId, options?.normalizeId);
4458

4559
if (options?.baseUrl) {
4660
stub.setBaseUrl(options.baseUrl);
@@ -63,7 +77,6 @@ export function connect(stub: {
6377
fetch: (request: Request) => Promise<Response>;
6478
}) {
6579
return async (request: Request, port: number) => {
66-
// Validate port before routing
6780
if (!validatePort(port)) {
6881
throw new SecurityError(
6982
`Invalid or restricted port: ${port}. Ports must be in range 1024-65535 and not reserved.`
@@ -81,6 +94,7 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
8194
client: SandboxClient;
8295
private codeInterpreter: CodeInterpreter;
8396
private sandboxName: string | null = null;
97+
private normalizeId: boolean = false;
8498
private baseUrl: string | null = null;
8599
private portTokens: Map<number, string> = new Map();
86100
private defaultSession: string | null = null;
@@ -115,10 +129,11 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
115129
// The CodeInterpreter extracts client.interpreter from the sandbox
116130
this.codeInterpreter = new CodeInterpreter(this);
117131

118-
// Load the sandbox name, port tokens, and default session from storage on initialization
119132
this.ctx.blockConcurrencyWhile(async () => {
120133
this.sandboxName =
121134
(await this.ctx.storage.get<string>('sandboxName')) || null;
135+
this.normalizeId =
136+
(await this.ctx.storage.get<boolean>('normalizeId')) || false;
122137
this.defaultSession =
123138
(await this.ctx.storage.get<string>('defaultSession')) || null;
124139
const storedTokens =
@@ -133,11 +148,12 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
133148
});
134149
}
135150

136-
// RPC method to set the sandbox name
137-
async setSandboxName(name: string): Promise<void> {
151+
async setSandboxName(name: string, normalizeId?: boolean): Promise<void> {
138152
if (!this.sandboxName) {
139153
this.sandboxName = name;
154+
this.normalizeId = normalizeId || false;
140155
await this.ctx.storage.put('sandboxName', name);
156+
await this.ctx.storage.put('normalizeId', this.normalizeId);
141157
}
142158
}
143159

@@ -1031,20 +1047,29 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
10311047
);
10321048
}
10331049

1034-
// Validate sandbox ID (will throw SecurityError if invalid)
1035-
const sanitizedSandboxId = sanitizeSandboxId(sandboxId);
1050+
// Hostnames are case-insensitive, routing requests to wrong DO instance when keys contain uppercase letters
1051+
const effectiveId = this.sandboxName || sandboxId;
1052+
const hasUppercase = /[A-Z]/.test(effectiveId);
1053+
if (!this.normalizeId && hasUppercase) {
1054+
throw new SecurityError(
1055+
`Preview URLs require lowercase sandbox IDs. Your ID "${effectiveId}" contains uppercase letters.\n\n` +
1056+
`To fix this:\n` +
1057+
`1. Create a new sandbox with: getSandbox(ns, "${effectiveId}", { normalizeId: true })\n` +
1058+
`2. This will create a sandbox with ID: "${effectiveId.toLowerCase()}"\n\n` +
1059+
`Note: Due to DNS case-insensitivity, IDs with uppercase letters cannot be used with preview URLs.`
1060+
);
1061+
}
1062+
1063+
const sanitizedSandboxId = sanitizeSandboxId(sandboxId).toLowerCase();
10361064

10371065
const isLocalhost = isLocalhostPattern(hostname);
10381066

10391067
if (isLocalhost) {
1040-
// Unified subdomain approach for localhost (RFC 6761)
10411068
const [host, portStr] = hostname.split(':');
10421069
const mainPort = portStr || '80';
10431070

1044-
// Use URL constructor for safe URL building
10451071
try {
10461072
const baseUrl = new URL(`http://${host}:${mainPort}`);
1047-
// Construct subdomain safely with mandatory token
10481073
const subdomainHost = `${port}-${sanitizedSandboxId}-${token}.${host}`;
10491074
baseUrl.hostname = subdomainHost;
10501075

@@ -1058,13 +1083,8 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
10581083
}
10591084
}
10601085

1061-
// Production subdomain logic - enforce HTTPS
10621086
try {
1063-
// Always use HTTPS for production (non-localhost)
1064-
const protocol = 'https';
1065-
const baseUrl = new URL(`${protocol}://${hostname}`);
1066-
1067-
// Construct subdomain safely with mandatory token
1087+
const baseUrl = new URL(`https://${hostname}`);
10681088
const subdomainHost = `${port}-${sanitizedSandboxId}-${token}.${hostname}`;
10691089
baseUrl.hostname = subdomainHost;
10701090

packages/sandbox/tests/get-sandbox.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,10 @@ describe('getSandbox', () => {
4444
const sandbox = getSandbox(mockNamespace, 'test-sandbox');
4545

4646
expect(sandbox).toBeDefined();
47-
expect(sandbox.setSandboxName).toHaveBeenCalledWith('test-sandbox');
47+
expect(sandbox.setSandboxName).toHaveBeenCalledWith(
48+
'test-sandbox',
49+
undefined
50+
);
4851
});
4952

5053
it('should apply sleepAfter option when provided as string', () => {
@@ -146,4 +149,24 @@ describe('getSandbox', () => {
146149
expect(sandbox.setBaseUrl).toHaveBeenCalledWith('https://example.com');
147150
expect(sandbox.setKeepAlive).toHaveBeenCalledWith(true);
148151
});
152+
153+
it('should preserve sandbox ID case by default', () => {
154+
const mockNamespace = {} as any;
155+
getSandbox(mockNamespace, 'MyProject-ABC123');
156+
157+
expect(mockGetContainer).toHaveBeenCalledWith(
158+
mockNamespace,
159+
'MyProject-ABC123'
160+
);
161+
});
162+
163+
it('should normalize sandbox ID to lowercase when normalizeId option is true', () => {
164+
const mockNamespace = {} as any;
165+
getSandbox(mockNamespace, 'MyProject-ABC123', { normalizeId: true });
166+
167+
expect(mockGetContainer).toHaveBeenCalledWith(
168+
mockNamespace,
169+
'myproject-abc123'
170+
);
171+
});
149172
});

packages/sandbox/tests/sandbox.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,4 +736,90 @@ describe('Sandbox - Automatic Session Management', () => {
736736
expect(result.sessionId).toBe('custom-session');
737737
});
738738
});
739+
740+
describe('constructPreviewUrl validation', () => {
741+
it('should throw clear error for ID with uppercase letters without normalizeId', async () => {
742+
await sandbox.setSandboxName('MyProject-123', false);
743+
744+
vi.spyOn(sandbox.client.ports, 'exposePort').mockResolvedValue({
745+
port: 8080,
746+
token: 'test-token-1234',
747+
previewUrl: ''
748+
});
749+
750+
await expect(
751+
sandbox.exposePort(8080, { hostname: 'example.com' })
752+
).rejects.toThrow(/Preview URLs require lowercase sandbox IDs/);
753+
});
754+
755+
it('should construct valid URL for lowercase ID', async () => {
756+
await sandbox.setSandboxName('my-project', false);
757+
758+
vi.spyOn(sandbox.client.ports, 'exposePort').mockResolvedValue({
759+
port: 8080,
760+
token: 'mock-token',
761+
previewUrl: ''
762+
});
763+
764+
const result = await sandbox.exposePort(8080, {
765+
hostname: 'example.com'
766+
});
767+
768+
expect(result.url).toMatch(
769+
/^https:\/\/8080-my-project-[a-z0-9_-]{16}\.example\.com\/?$/
770+
);
771+
expect(result.port).toBe(8080);
772+
});
773+
774+
it('should construct valid URL with normalized ID', async () => {
775+
await sandbox.setSandboxName('myproject-123', true);
776+
777+
vi.spyOn(sandbox.client.ports, 'exposePort').mockResolvedValue({
778+
port: 4000,
779+
token: 'mock-token',
780+
previewUrl: ''
781+
});
782+
783+
const result = await sandbox.exposePort(4000, { hostname: 'my-app.dev' });
784+
785+
expect(result.url).toMatch(
786+
/^https:\/\/4000-myproject-123-[a-z0-9_-]{16}\.my-app\.dev\/?$/
787+
);
788+
expect(result.port).toBe(4000);
789+
});
790+
791+
it('should construct valid localhost URL', async () => {
792+
await sandbox.setSandboxName('test-sandbox', false);
793+
794+
vi.spyOn(sandbox.client.ports, 'exposePort').mockResolvedValue({
795+
port: 8080,
796+
token: 'mock-token',
797+
previewUrl: ''
798+
});
799+
800+
const result = await sandbox.exposePort(8080, {
801+
hostname: 'localhost:3000'
802+
});
803+
804+
expect(result.url).toMatch(
805+
/^http:\/\/8080-test-sandbox-[a-z0-9_-]{16}\.localhost:3000\/?$/
806+
);
807+
});
808+
809+
it('should include helpful guidance in error message', async () => {
810+
await sandbox.setSandboxName('MyProject-ABC', false);
811+
812+
vi.spyOn(sandbox.client.ports, 'exposePort').mockResolvedValue({
813+
port: 8080,
814+
token: 'test-token-1234',
815+
previewUrl: ''
816+
});
817+
818+
await expect(
819+
sandbox.exposePort(8080, { hostname: 'example.com' })
820+
).rejects.toThrow(
821+
/getSandbox\(ns, "MyProject-ABC", \{ normalizeId: true \}\)/
822+
);
823+
});
824+
});
739825
});

packages/shared/src/types.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,28 @@ export interface SandboxOptions {
286286
* Default: false
287287
*/
288288
keepAlive?: boolean;
289+
290+
/**
291+
* Normalize sandbox ID to lowercase for preview URL compatibility
292+
*
293+
* Required for preview URLs because hostnames are case-insensitive (RFC 3986), which
294+
* would route requests to a different Durable Object instance with IDs containing uppercase letters.
295+
*
296+
* **Important:** Different normalizeId values create different Durable Object instances:
297+
* - `getSandbox(ns, "MyProject")` → DO key: "MyProject"
298+
* - `getSandbox(ns, "MyProject", {normalizeId: true})` → DO key: "myproject"
299+
*
300+
* **Future change:** In a future version, this will default to `true` (automatically lowercase all IDs).
301+
* IDs with uppercase letters will trigger a warning. To prepare, use lowercase IDs or explicitly
302+
* pass `normalizeId: true`.
303+
*
304+
* @example
305+
* getSandbox(ns, "my-project") // Works with preview URLs (lowercase)
306+
* getSandbox(ns, "MyProject", {normalizeId: true}) // Normalized to "myproject"
307+
*
308+
* @default false
309+
*/
310+
normalizeId?: boolean;
289311
}
290312

291313
/**

0 commit comments

Comments
 (0)