diff --git a/packages/devframe/src/adapters/__tests__/dev.test.ts b/packages/devframe/src/adapters/__tests__/dev.test.ts index 3f15e9d9..39729ded 100644 --- a/packages/devframe/src/adapters/__tests__/dev.test.ts +++ b/packages/devframe/src/adapters/__tests__/dev.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { defineDevframe } from 'devframe' import { createRpcClient } from 'devframe/rpc/client' import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' +import { createWsOriginRegistry } from 'devframe/rpc/transports/ws-server' import { open } from 'devframe/utils/open' import { getPort } from 'get-port-please' import { describe, expect, it, vi } from 'vitest' @@ -31,6 +32,19 @@ function makeTmpDist(): string { return dir } +async function connectRaw(url: string, origin?: string): Promise<'open' | 'closed'> { + return await new Promise((resolve) => { + const ws = new WebSocket(url, origin ? { headers: { origin } } : undefined) + ws.on('open', () => { + ws.close() + resolve('open') + }) + ws.on('error', () => resolve('closed')) + ws.on('unexpected-response', () => resolve('closed')) + ws.on('close', () => resolve('closed')) + }) +} + describe('adapters/dev', () => { it('createDevServer starts, exposes __connection.json, and closes', async () => { const distDir = makeTmpDist() @@ -256,6 +270,99 @@ describe('adapters/dev', () => { } }) + it('allowedOrigins: default stays loopback-only', async () => { + const devframe = defineDevframe({ + id: 'devframe-origins-default', + name: 'Origins Default', + version: '0.0.0', + packageName: 'devframe-test', + homepage: 'https://example.test', + description: 'Test devframe.', + setup: () => {}, + }) + const host = '127.0.0.1' + const port = await getPort({ port: 19450, host }) + const handle = await createDevServer(devframe, { host, port, openBrowser: false }) + + try { + await expect(connectRaw(`ws://${host}:${port}/__ws`)).resolves.toBe('open') + await expect(connectRaw(`ws://${host}:${port}/__ws`, `http://${host}:12345`)).resolves.toBe('open') + await expect(connectRaw(`ws://${host}:${port}/__ws`, 'http://evil.example')).resolves.toBe('closed') + } + finally { + await handle.close() + } + }) + + it('allowedOrigins: an array of extra origins is honored on top of the loopback default', async () => { + const devframe = defineDevframe({ + id: 'devframe-origins-array', + name: 'Origins Array', + version: '0.0.0', + packageName: 'devframe-test', + homepage: 'https://example.test', + description: 'Test devframe.', + setup: () => {}, + }) + const host = '127.0.0.1' + const port = await getPort({ port: 19440, host }) + const handle = await createDevServer(devframe, { + host, + port, + openBrowser: false, + allowedOrigins: ['http://evil.example'], + }) + + try { + await expect(connectRaw(`ws://${host}:${port}/__ws`, 'http://evil.example')).resolves.toBe('open') + // Still rejects an origin that's neither loopback nor allowlisted. + await expect(connectRaw(`ws://${host}:${port}/__ws`, 'http://other.example')).resolves.toBe('closed') + } + finally { + await handle.close() + } + }) + + it('allowedOrigins: a WsOriginRegistry gates the upgrade end-to-end', async () => { + const devframe = defineDevframe({ + id: 'devframe-origins-registry', + name: 'Origins Registry', + version: '0.0.0', + packageName: 'devframe-test', + homepage: 'https://example.test', + description: 'Test devframe.', + setup: () => {}, + }) + const host = '127.0.0.1' + const port = await getPort({ port: 19430, host }) + const origin = 'chrome-extension://abcdefghijklmnop' + const registry = createWsOriginRegistry({ + validateOrigin: value => value.startsWith('chrome-extension://'), + }) + const handle = await createDevServer(devframe, { + host, + port, + openBrowser: false, + allowedOrigins: registry, + }) + + try { + // Unregistered, the registry rejects the same origin it'll accept below. + await expect(connectRaw(`ws://${host}:${port}/__ws`, origin)).resolves.toBe('closed') + + const params = new URLSearchParams({ + devframe_viewer_origin: origin, + devframe_viewer_origin_token: registry.token, + }) + expect(registry.registerFromUrl(`/__connection.json?${params}`)).toBe(origin) + + await expect(connectRaw(`ws://${host}:${port}/__ws`, origin)).resolves.toBe('open') + } + finally { + await handle.close() + } + }) + it('createDevServer runs in bridge mode when no distDir is configured', async () => { const devframe = defineDevframe({ id: 'devframe-test-nodist', diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts index aa549b9c..59881515 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -1,4 +1,4 @@ -import type { DevframeRpcConnection } from 'devframe/rpc/transports/ws-server' +import type { DevframeRpcConnection, WsOriginRegistry } from 'devframe/rpc/transports/ws-server' import type { DevframeAuthHandler } from '../node/auth/handler' import type { StartedServer } from '../node/instance-shell' import type { DevframeDefinition, DevframeSseOptions, DevframeWsOptions, McpRouteOptions } from '../types/devframe' @@ -50,6 +50,13 @@ export interface CreateDevServerOptions { * clients connect over the SSE endpoint instead (`backend: 'sse'`). */ ws?: DevframeWsOptions | false + /** + * Extra origins to accept on the WS upgrade beyond the loopback default. + * Add your LAN/tunnel origin here when reaching the tool from another + * host. Pass `false` to disable origin checking entirely (not + * recommended). Default: loopback-only. + */ + allowedOrigins?: readonly string[] | WsOriginRegistry | false /** * Override the SSE RPC endpoint control (`def.cli?.sse`) — enabled by * default at `__sse`. Pass `false` to disable, or a @@ -175,6 +182,7 @@ export async function createDevServer( host, origin, ws: options.ws, + allowedOrigins: options.allowedOrigins, sse: options.sse, // The `--no-auth` flag forces the gate off regardless of the `auth` // option / definition default (which the instance resolves itself). diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts index dcfcfa0b..6a355dcd 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts @@ -9,6 +9,7 @@ export interface CreateDevServerOptions { distDir?: string; basePath?: string; ws?: DevframeWsOptions | false; + allowedOrigins?: readonly string[] | WsOriginRegistry | false; sse?: boolean | DevframeSseOptions; app?: H3; openBrowser?: boolean | string;