Skip to content

Commit 0b58c1e

Browse files
authored
fix(devframe): forward allowedOrigins through createDevServer (#230)
1 parent 6ffeeb3 commit 0b58c1e

3 files changed

Lines changed: 117 additions & 1 deletion

File tree

packages/devframe/src/adapters/__tests__/dev.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { join } from 'node:path'
55
import { defineDevframe } from 'devframe'
66
import { createRpcClient } from 'devframe/rpc/client'
77
import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client'
8+
import { createWsOriginRegistry } from 'devframe/rpc/transports/ws-server'
89
import { open } from 'devframe/utils/open'
910
import { getPort } from 'get-port-please'
1011
import { describe, expect, it, vi } from 'vitest'
@@ -31,6 +32,19 @@ function makeTmpDist(): string {
3132
return dir
3233
}
3334

35+
async function connectRaw(url: string, origin?: string): Promise<'open' | 'closed'> {
36+
return await new Promise((resolve) => {
37+
const ws = new WebSocket(url, origin ? { headers: { origin } } : undefined)
38+
ws.on('open', () => {
39+
ws.close()
40+
resolve('open')
41+
})
42+
ws.on('error', () => resolve('closed'))
43+
ws.on('unexpected-response', () => resolve('closed'))
44+
ws.on('close', () => resolve('closed'))
45+
})
46+
}
47+
3448
describe('adapters/dev', () => {
3549
it('createDevServer starts, exposes __connection.json, and closes', async () => {
3650
const distDir = makeTmpDist()
@@ -256,6 +270,99 @@ describe('adapters/dev', () => {
256270
}
257271
})
258272

273+
it('allowedOrigins: default stays loopback-only', async () => {
274+
const devframe = defineDevframe({
275+
id: 'devframe-origins-default',
276+
name: 'Origins Default',
277+
version: '0.0.0',
278+
packageName: 'devframe-test',
279+
homepage: 'https://example.test',
280+
description: 'Test devframe.',
281+
setup: () => {},
282+
})
283+
const host = '127.0.0.1'
284+
const port = await getPort({ port: 19450, host })
285+
const handle = await createDevServer(devframe, { host, port, openBrowser: false })
286+
287+
try {
288+
await expect(connectRaw(`ws://${host}:${port}/__ws`)).resolves.toBe('open')
289+
await expect(connectRaw(`ws://${host}:${port}/__ws`, `http://${host}:12345`)).resolves.toBe('open')
290+
await expect(connectRaw(`ws://${host}:${port}/__ws`, 'http://evil.example')).resolves.toBe('closed')
291+
}
292+
finally {
293+
await handle.close()
294+
}
295+
})
296+
297+
it('allowedOrigins: an array of extra origins is honored on top of the loopback default', async () => {
298+
const devframe = defineDevframe({
299+
id: 'devframe-origins-array',
300+
name: 'Origins Array',
301+
version: '0.0.0',
302+
packageName: 'devframe-test',
303+
homepage: 'https://example.test',
304+
description: 'Test devframe.',
305+
setup: () => {},
306+
})
307+
const host = '127.0.0.1'
308+
const port = await getPort({ port: 19440, host })
309+
const handle = await createDevServer(devframe, {
310+
host,
311+
port,
312+
openBrowser: false,
313+
allowedOrigins: ['http://evil.example'],
314+
})
315+
316+
try {
317+
await expect(connectRaw(`ws://${host}:${port}/__ws`, 'http://evil.example')).resolves.toBe('open')
318+
// Still rejects an origin that's neither loopback nor allowlisted.
319+
await expect(connectRaw(`ws://${host}:${port}/__ws`, 'http://other.example')).resolves.toBe('closed')
320+
}
321+
finally {
322+
await handle.close()
323+
}
324+
})
325+
326+
it('allowedOrigins: a WsOriginRegistry gates the upgrade end-to-end', async () => {
327+
const devframe = defineDevframe({
328+
id: 'devframe-origins-registry',
329+
name: 'Origins Registry',
330+
version: '0.0.0',
331+
packageName: 'devframe-test',
332+
homepage: 'https://example.test',
333+
description: 'Test devframe.',
334+
setup: () => {},
335+
})
336+
const host = '127.0.0.1'
337+
const port = await getPort({ port: 19430, host })
338+
const origin = 'chrome-extension://abcdefghijklmnop'
339+
const registry = createWsOriginRegistry({
340+
validateOrigin: value => value.startsWith('chrome-extension://'),
341+
})
342+
const handle = await createDevServer(devframe, {
343+
host,
344+
port,
345+
openBrowser: false,
346+
allowedOrigins: registry,
347+
})
348+
349+
try {
350+
// Unregistered, the registry rejects the same origin it'll accept below.
351+
await expect(connectRaw(`ws://${host}:${port}/__ws`, origin)).resolves.toBe('closed')
352+
353+
const params = new URLSearchParams({
354+
devframe_viewer_origin: origin,
355+
devframe_viewer_origin_token: registry.token,
356+
})
357+
expect(registry.registerFromUrl(`/__connection.json?${params}`)).toBe(origin)
358+
359+
await expect(connectRaw(`ws://${host}:${port}/__ws`, origin)).resolves.toBe('open')
360+
}
361+
finally {
362+
await handle.close()
363+
}
364+
})
365+
259366
it('createDevServer runs in bridge mode when no distDir is configured', async () => {
260367
const devframe = defineDevframe({
261368
id: 'devframe-test-nodist',

packages/devframe/src/adapters/dev.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { DevframeRpcConnection } from 'devframe/rpc/transports/ws-server'
1+
import type { DevframeRpcConnection, WsOriginRegistry } from 'devframe/rpc/transports/ws-server'
22
import type { DevframeAuthHandler } from '../node/auth/handler'
33
import type { StartedServer } from '../node/instance-shell'
44
import type { DevframeDefinition, DevframeSseOptions, DevframeWsOptions, McpRouteOptions } from '../types/devframe'
@@ -50,6 +50,13 @@ export interface CreateDevServerOptions {
5050
* clients connect over the SSE endpoint instead (`backend: 'sse'`).
5151
*/
5252
ws?: DevframeWsOptions | false
53+
/**
54+
* Extra origins to accept on the WS upgrade beyond the loopback default.
55+
* Add your LAN/tunnel origin here when reaching the tool from another
56+
* host. Pass `false` to disable origin checking entirely (not
57+
* recommended). Default: loopback-only.
58+
*/
59+
allowedOrigins?: readonly string[] | WsOriginRegistry | false
5360
/**
5461
* Override the SSE RPC endpoint control (`def.cli?.sse`) — enabled by
5562
* default at `<base>__sse`. Pass `false` to disable, or a
@@ -175,6 +182,7 @@ export async function createDevServer(
175182
host,
176183
origin,
177184
ws: options.ws,
185+
allowedOrigins: options.allowedOrigins,
178186
sse: options.sse,
179187
// The `--no-auth` flag forces the gate off regardless of the `auth`
180188
// option / definition default (which the instance resolves itself).

tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface CreateDevServerOptions {
99
distDir?: string;
1010
basePath?: string;
1111
ws?: DevframeWsOptions | false;
12+
allowedOrigins?: readonly string[] | WsOriginRegistry | false;
1213
sse?: boolean | DevframeSseOptions;
1314
app?: H3;
1415
openBrowser?: boolean | string;

0 commit comments

Comments
 (0)