From b461bbfa9a37be9ff1a6d89e234729953f0cd200 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:00:17 -0400 Subject: [PATCH] [Fix] HMR WebSockets rejected in sandbox previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vite refuses requests and HMR WebSocket upgrades whose Host header is not in `server.allowedHosts`, and Astro, Nuxt and SvelteKit inherit that check. Two different hosts reach a sandbox dev server: the sandbox's own host on plain HTTP, because the preview proxy changes origin, and the public preview host on upgrades, because the auth-proxy rewrites Host there. Only the first was ever allowlisted — by each repo hardcoding a preview domain in its own config — so previews rendered while HMR died with a 400 and live reload silently stopped working. Set `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` in the worker-managed shell env file to both hosts, as exact hostnames for the current task rather than a wildcard suffix. Vite reads the variable when it resolves config and appends it to allowedHosts, so HMR works without per-repo configuration. An explicit deployment-provided value still wins. --- .../src/commands/__tests__/utils.test.ts | 134 ++++++++++++++++++ apps/worker/src/commands/utils/env-vars.ts | 41 ++++++ 2 files changed, 175 insertions(+) diff --git a/apps/worker/src/commands/__tests__/utils.test.ts b/apps/worker/src/commands/__tests__/utils.test.ts index 329d1595f..1b4209ddf 100644 --- a/apps/worker/src/commands/__tests__/utils.test.ts +++ b/apps/worker/src/commands/__tests__/utils.test.ts @@ -1,5 +1,7 @@ import * as fs from 'fs'; +import { CODE_SERVER_NAMED_PORT } from '@roomote/types'; + import { injectEnvVars, writeBashrc, @@ -212,6 +214,138 @@ describe('injectEnvVars', () => { expect(envVars.ROOMOTE_EDITOR_PREVIEW_URL).toBeUndefined(); }); + describe('Vite allowed-hosts derivation', () => { + it('pins the additional allowed hosts to the exact preview hostnames', async () => { + const envVars: Record = {}; + const taskRun = { + taskId: 'task-123', + machineDomains: { + WEB: 'https://sandbox-web.modal.host', + MY_APP: 'https://sandbox-my-app.modal.host', + }, + proxyPorts: { WEB: 4321, MY_APP: 3000 }, + } as unknown as TaskRun; + + await injectEnvVars(envVars, taskRun, { + previewProxyBaseUrl: 'https://preview.octomote.run', + }); + + expect( + envVars.__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS?.split(','), + ).toEqual([ + 'task-123-web.preview.octomote.run', + 'task-123-my-app.preview.octomote.run', + 'sandbox-web.modal.host', + 'sandbox-my-app.modal.host', + ]); + }); + + it('includes the subdomain suffix used by nested previews', async () => { + const envVars: Record = {}; + const taskRun = { + taskId: 'task-123', + machineDomains: { WEB: 'https://sandbox-web.modal.host' }, + proxyPorts: { WEB: 4321 }, + } as unknown as TaskRun; + + await injectEnvVars(envVars, taskRun, { + previewProxyBaseUrl: 'https://preview.octomote.run', + previewProxySubdomainSuffix: 'outer-task', + }); + + expect( + envVars.__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS?.split(','), + ).toContain('task-123-web-outer-task.preview.octomote.run'); + }); + + it('allowlists the sandbox host that proxied HTTP requests arrive with', async () => { + const envVars: Record = {}; + const taskRun = { + taskId: 'task-123', + machineDomains: { WEB: 'https://sandbox-web.modal.host' }, + proxyPorts: { WEB: 4321 }, + } as unknown as TaskRun; + + await injectEnvVars(envVars, taskRun, { + previewProxyBaseUrl: 'https://preview.octomote.run', + }); + + expect( + envVars.__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS?.split(','), + ).toContain('sandbox-web.modal.host'); + }); + + it('deduplicates hosts shared across named ports', async () => { + const envVars: Record = {}; + const taskRun = { + taskId: 'task-123', + machineDomains: { + WEB: 'http://sandbox-abc:4321', + API: 'http://sandbox-abc:3001', + }, + proxyPorts: { WEB: 4321, API: 3001 }, + } as unknown as TaskRun; + + await injectEnvVars(envVars, taskRun, { + previewProxyBaseUrl: 'https://preview.octomote.run', + }); + + const hosts = + envVars.__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS?.split(',') ?? []; + + expect(hosts.filter((host) => host === 'sandbox-abc')).toHaveLength(1); + }); + + it('omits the code-server host, which is not a user dev server', async () => { + const envVars: Record = {}; + const taskRun = { + taskId: 'task-123', + machineDomains: { + [CODE_SERVER_NAMED_PORT.name]: 'https://sandbox-editor.modal.host', + }, + proxyPorts: {}, + } as unknown as TaskRun; + + await injectEnvVars(envVars, taskRun, { + previewProxyBaseUrl: 'https://preview.octomote.run', + }); + + expect(envVars.__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS).toBeUndefined(); + }); + + it('keeps a deployment-provided allowed-hosts value', async () => { + const envVars: Record = { + __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: 'custom.example.com', + }; + const taskRun = { + taskId: 'task-123', + machineDomains: { WEB: 'https://sandbox-web.modal.host' }, + proxyPorts: { WEB: 4321 }, + } as unknown as TaskRun; + + await injectEnvVars(envVars, taskRun, { + previewProxyBaseUrl: 'https://preview.octomote.run', + }); + + expect(envVars.__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS).toBe( + 'custom.example.com', + ); + }); + + it('leaves the variable unset without a preview-proxy base URL', async () => { + const envVars: Record = {}; + const taskRun = { + taskId: 'task-123', + machineDomains: { WEB: 'https://sandbox-web.modal.host' }, + proxyPorts: { WEB: 4321 }, + } as unknown as TaskRun; + + await injectEnvVars(envVars, taskRun); + + expect(envVars.__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS).toBeUndefined(); + }); + }); + describe('PREVIEW_DOMAINS derivation', () => { it('derives PREVIEW_DOMAINS from the preview-proxy base URL hostname', async () => { const envVars: Record = {}; diff --git a/apps/worker/src/commands/utils/env-vars.ts b/apps/worker/src/commands/utils/env-vars.ts index f2d8f4521..08ae5e363 100644 --- a/apps/worker/src/commands/utils/env-vars.ts +++ b/apps/worker/src/commands/utils/env-vars.ts @@ -30,6 +30,12 @@ const ENV_VARS_END = `# END ${PRODUCT_NAME} environment variables`; /** POSIX-compliant env var name: letters, digits, underscores; must not start with a digit. */ const VALID_ENV_VAR_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/; +/** + * Characters Vite treats as reserved in __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS. + * A single occurrence makes Vite skip the entire variable. + */ +const VITE_UNSAFE_ALLOWED_HOST_CHARS = /["'\\]/; + /** * Returns true when `name` is a safe POSIX environment variable name. * Rejects names containing shell metacharacters that could lead to command @@ -195,6 +201,8 @@ export async function injectEnvVars( const previewProxySubdomainSuffix = options?.previewProxySubdomainSuffix ?? process.env.PREVIEW_PROXY_SUBDOMAIN_SUFFIX; + const previewHostnames: string[] = []; + const machineHostnames: string[] = []; if (identity && identity.taskId && previewProxyBaseUrl) { for (const [name, domain] of Object.entries(identity.machineDomains)) { @@ -216,6 +224,17 @@ export async function injectEnvVars( delete envVars[previewUrlEnvVarName]; } else { envVars[previewUrlEnvVarName] = previewUrl; + previewHostnames.push(new URL(previewUrl).hostname); + + // Requests that arrive through the preview proxy reach the dev server + // with the sandbox's own host (http-proxy rewrites Host when it changes + // origin), and unproxied ports are browsed at that host directly. + try { + machineHostnames.push(new URL(domain).hostname); + } catch { + // Providers that report a bare host rather than a URL have nothing + // extra to allowlist beyond the preview hostname above. + } } if (isProxied) { @@ -230,6 +249,28 @@ export async function injectEnvVars( } } + // Vite rejects requests and HMR WebSocket upgrades whose Host header is not + // in `server.allowedHosts` (Astro, Nuxt and SvelteKit inherit this). Two + // different hosts reach a sandbox dev server: the sandbox's own host on plain + // HTTP, because the preview proxy changes origin, and the public preview host + // on upgrades, because the auth-proxy rewrites Host there. Allowlisting only + // the first is why previews render but HMR dies with a 400. Vite reads this + // variable when it resolves config and appends it to allowedHosts, so setting + // both fixes HMR without every repo hardcoding a preview domain of its own. + // Pin exact hostnames rather than a wildcard suffix, and let an explicit + // deployment-provided value win. + if (!envVars.__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS) { + // Vite discards the whole list when it contains quoting metacharacters; + // hostnames never do, so drop anything malformed instead of losing the rest. + const allowedHosts = [ + ...new Set([...previewHostnames, ...machineHostnames]), + ].filter((hostname) => !VITE_UNSAFE_ALLOWED_HOST_CHARS.test(hostname)); + + if (allowedHosts.length > 0) { + envVars.__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS = allowedHosts.join(','); + } + } + // Dev servers running in the sandbox (e.g. Next.js) reject cross-origin // requests to internal dev resources (fonts, HMR, overlay assets) unless the // preview hostname is allowlisted; apps derive that allowlist from