Skip to content

Commit 7032307

Browse files
committed
feat(rpc)!: reject cross-origin WebSocket upgrades
The RPC WebSocket accepted an upgrade based only on the URL path and never inspected the Origin header. Browsers allow cross-origin WebSocket connections (unlike fetch), so any web page open in the developer's browser could connect to the RPC socket and speak the full protocol - Cross-Site WebSocket Hijacking, and the same gap enables DNS rebinding. Add a loopback-permissive origin check: allow requests with no Origin (native, non-browser clients), allow any loopback Origin (so cross-port localhost dev setups keep working), and reject everything else unless explicitly allow-listed via the new allowedOrigins option on attachWsRpcTransport / startHttpAndWs. Pass allowedOrigins: false to disable the check entirely. BREAKING CHANGE: tools reached from a non-loopback origin (LAN IP, tunnel, custom domain) now need that origin added to allowedOrigins, or must pass allowedOrigins: false. Implements plans/003-ws-origin-check.md; removes the plan file now that it's done.
1 parent fd8b1c6 commit 7032307

7 files changed

Lines changed: 145 additions & 255 deletions

File tree

‎packages/devframe/src/node/server.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ export interface StartHttpAndWsOptions {
5656
* Default: `true`.
5757
*/
5858
auth?: boolean
59+
/**
60+
* Extra origins to accept on the WS upgrade beyond the loopback default
61+
* (`localhost`/`127.0.0.1`/`::1` and any `Origin`-less request from a
62+
* native client). Add your LAN/tunnel origin here when reaching the tool
63+
* from another host. Pass `false` to disable origin checking entirely
64+
* (not recommended). Default: loopback-only.
65+
*/
66+
allowedOrigins?: readonly string[] | false
5967
/**
6068
* Called once the WS server is bound so callers can mount static
6169
* handlers whose origin depends on the resolved port, or print their
@@ -136,6 +144,7 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise<St
136144
// off-route attempts promptly. A shared (caller-owned) server may host
137145
// other sockets, so leave non-matching upgrades for them.
138146
destroyUnmatched: ownsHttpServer,
147+
allowedOrigins: options.allowedOrigins,
139148
onDisconnected: (_peer, meta) => {
140149
rpcHost._emitSessionDisconnected(meta)
141150
},

‎packages/devframe/src/rpc/transports/ws-server.ts‎

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ export interface WsRpcTransportOptions {
6464
destroyUnmatched?: boolean
6565
/** When set, a new https.Server is created and the WS endpoint is attached to it. */
6666
https?: HttpsServerOptions
67+
/**
68+
* Extra origins to accept on the WS upgrade beyond the loopback default.
69+
* Add your LAN/tunnel origin here when reaching the tool from another host.
70+
* Pass `false` to disable origin checking entirely (not recommended).
71+
* Default: loopback-only.
72+
*/
73+
allowedOrigins?: readonly string[] | false
6774
/**
6875
* RPC function definitions, used by the per-call wire serializer to
6976
* dispatch between strict-JSON and structured-clone encoding based
@@ -109,6 +116,32 @@ function pathMatches(a: string, b: string): boolean {
109116
return strip(a) === strip(b)
110117
}
111118

119+
export function isLoopbackHostname(hostname: string): boolean {
120+
const h = hostname.replace(/^\[|\]$/g, '') // strip IPv6 brackets
121+
return h === 'localhost' || h === '127.0.0.1' || h === '::1'
122+
|| h.endsWith('.localhost') || h.startsWith('127.')
123+
}
124+
125+
/**
126+
* Default origin policy for a localhost dev tool: allow requests with no
127+
* `Origin` header (native, non-browser clients), allow any loopback origin
128+
* (so cross-port localhost dev setups keep working), and allow explicitly
129+
* configured origins. Everything else — a real remote page in the dev's
130+
* browser — is rejected.
131+
*/
132+
export function isAllowedOrigin(origin: string | undefined, allowedOrigins: readonly string[]): boolean {
133+
if (!origin)
134+
return true
135+
if (allowedOrigins.includes(origin))
136+
return true
137+
try {
138+
return isLoopbackHostname(new URL(origin).hostname)
139+
}
140+
catch {
141+
return false
142+
}
143+
}
144+
112145
/**
113146
* Route `upgrade` events on a server to the crossws adapter, optionally
114147
* filtered to a single `path`. Non-matching requests are left untouched so
@@ -121,6 +154,7 @@ function routeUpgrades(
121154
ws: NodeAdapter,
122155
path: string | undefined,
123156
destroyUnmatched: boolean,
157+
allowedOrigins: readonly string[] | false | undefined,
124158
): () => void {
125159
const listener = (req: IncomingMessage, socket: Duplex, head: Buffer) => {
126160
if (path) {
@@ -135,6 +169,10 @@ function routeUpgrades(
135169
return
136170
}
137171
}
172+
if (allowedOrigins !== false && !isAllowedOrigin(req.headers.origin, allowedOrigins ?? [])) {
173+
socket.destroy()
174+
return
175+
}
138176
void ws.handleUpgrade(req, socket, head)
139177
}
140178
server.on('upgrade', listener)
@@ -165,6 +203,7 @@ export function attachWsRpcTransport<
165203
path,
166204
destroyUnmatched = false,
167205
https,
206+
allowedOrigins,
168207
onConnected = NOOP,
169208
onDisconnected = NOOP,
170209
definitions = EMPTY_DEFS,
@@ -264,11 +303,11 @@ export function attachWsRpcTransport<
264303
if (server) {
265304
// Share an existing HTTP(S) server's port. Route upgrades ourselves so we
266305
// can coexist with the host's own upgrade handlers.
267-
detach = routeUpgrades(server, ws, path, destroyUnmatched)
306+
detach = routeUpgrades(server, ws, path, destroyUnmatched, allowedOrigins)
268307
}
269308
else if (https) {
270309
ownedServer = createHttpsServer(https)
271-
detach = routeUpgrades(ownedServer, ws, path, true)
310+
detach = routeUpgrades(ownedServer, ws, path, true, allowedOrigins)
272311
ownedServer.listen(port, host)
273312
}
274313
else {
@@ -278,7 +317,7 @@ export function attachWsRpcTransport<
278317
res.writeHead(426, { 'content-type': 'text/plain' })
279318
res.end('Upgrade Required')
280319
})
281-
detach = routeUpgrades(ownedServer, ws, path, true)
320+
detach = routeUpgrades(ownedServer, ws, path, true, allowedOrigins)
282321
ownedServer.listen(port, host)
283322
}
284323

‎packages/devframe/src/rpc/transports/ws.test.ts‎

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { WebSocket, WebSocketServer } from 'ws'
55
import { createRpcClient } from '../client'
66
import { createRpcServer } from '../server'
77
import { createWsRpcChannel } from './ws-client'
8-
import { attachWsRpcTransport } from './ws-server'
8+
import { attachWsRpcTransport, isAllowedOrigin, isLoopbackHostname } from './ws-server'
99

1010
vi.stubGlobal('WebSocket', WebSocket)
1111

@@ -162,3 +162,91 @@ describe('devframe rpc', () => {
162162
}
163163
})
164164
})
165+
166+
describe('ws origin check', () => {
167+
it('isLoopbackHostname / isAllowedOrigin recognize loopback hosts', () => {
168+
expect(isLoopbackHostname('localhost')).toBe(true)
169+
expect(isLoopbackHostname('127.0.0.1')).toBe(true)
170+
expect(isLoopbackHostname('127.5.5.5')).toBe(true)
171+
expect(isLoopbackHostname('::1')).toBe(true)
172+
expect(isLoopbackHostname('foo.localhost')).toBe(true)
173+
expect(isLoopbackHostname('evil.example')).toBe(false)
174+
175+
expect(isAllowedOrigin(undefined, [])).toBe(true)
176+
expect(isAllowedOrigin('http://localhost:5173', [])).toBe(true)
177+
expect(isAllowedOrigin('http://evil.example', [])).toBe(false)
178+
expect(isAllowedOrigin('http://evil.example', ['http://evil.example'])).toBe(true)
179+
})
180+
181+
async function connectRaw(url: string, origin?: string): Promise<'open' | 'closed'> {
182+
return await new Promise((resolve) => {
183+
const ws = new WebSocket(url, origin ? { headers: { origin } } : undefined)
184+
ws.on('open', () => {
185+
ws.close()
186+
resolve('open')
187+
})
188+
ws.on('error', () => resolve('closed'))
189+
ws.on('unexpected-response', () => resolve('closed'))
190+
})
191+
}
192+
193+
it('rejects a cross-origin browser upgrade', async () => {
194+
const HOST = '127.0.0.1'
195+
const PORT = await getPort({ host: HOST, random: true })
196+
const server = createRpcServer<Record<string, never>, Record<string, never>>({})
197+
const { close } = attachWsRpcTransport(server, { port: PORT, host: HOST })
198+
199+
try {
200+
const result = await connectRaw(`ws://${HOST}:${PORT}`, 'http://evil.example')
201+
expect(result).toBe('closed')
202+
}
203+
finally {
204+
await close()
205+
}
206+
})
207+
208+
it('allows a loopback origin', async () => {
209+
const HOST = '127.0.0.1'
210+
const PORT = await getPort({ host: HOST, random: true })
211+
const server = createRpcServer<Record<string, never>, Record<string, never>>({})
212+
const { close } = attachWsRpcTransport(server, { port: PORT, host: HOST })
213+
214+
try {
215+
const result = await connectRaw(`ws://${HOST}:${PORT}`, 'http://localhost:12345')
216+
expect(result).toBe('open')
217+
}
218+
finally {
219+
await close()
220+
}
221+
})
222+
223+
it('allows a request with no Origin header (native client)', async () => {
224+
const HOST = '127.0.0.1'
225+
const PORT = await getPort({ host: HOST, random: true })
226+
const server = createRpcServer<Record<string, never>, Record<string, never>>({})
227+
const { close } = attachWsRpcTransport(server, { port: PORT, host: HOST })
228+
229+
try {
230+
const result = await connectRaw(`ws://${HOST}:${PORT}`)
231+
expect(result).toBe('open')
232+
}
233+
finally {
234+
await close()
235+
}
236+
})
237+
238+
it('honors allowedOrigins for an otherwise-disallowed origin', async () => {
239+
const HOST = '127.0.0.1'
240+
const PORT = await getPort({ host: HOST, random: true })
241+
const server = createRpcServer<Record<string, never>, Record<string, never>>({})
242+
const { close } = attachWsRpcTransport(server, { port: PORT, host: HOST, allowedOrigins: ['http://evil.example'] })
243+
244+
try {
245+
const result = await connectRaw(`ws://${HOST}:${PORT}`, 'http://evil.example')
246+
expect(result).toBe('open')
247+
}
248+
finally {
249+
await close()
250+
}
251+
})
252+
})

0 commit comments

Comments
 (0)