Skip to content

Commit 1fa119c

Browse files
authored
feat(rpc)!: reject cross-origin WebSocket upgrades (#64)
1 parent c224e92 commit 1fa119c

7 files changed

Lines changed: 155 additions & 256 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: 51 additions & 4 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,20 +154,33 @@ 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) => {
160+
socket.on('error', () => {
161+
// Prevent unhandled ECONNRESET crashes when destroying the socket
162+
// or when the client abruptly disconnects.
163+
})
164+
126165
if (path) {
127166
let pathname = req.url ?? '/'
128167
try {
129168
pathname = new URL(req.url ?? '/', 'http://localhost').pathname
130169
}
131170
catch {}
132171
if (!pathMatches(pathname, path)) {
133-
if (destroyUnmatched)
172+
if (destroyUnmatched) {
173+
socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
134174
socket.destroy()
175+
}
135176
return
136177
}
137178
}
179+
if (allowedOrigins !== false && !isAllowedOrigin(req.headers.origin, allowedOrigins ?? [])) {
180+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
181+
socket.destroy()
182+
return
183+
}
138184
void ws.handleUpgrade(req, socket, head)
139185
}
140186
server.on('upgrade', listener)
@@ -165,6 +211,7 @@ export function attachWsRpcTransport<
165211
path,
166212
destroyUnmatched = false,
167213
https,
214+
allowedOrigins,
168215
onConnected = NOOP,
169216
onDisconnected = NOOP,
170217
definitions = EMPTY_DEFS,
@@ -264,11 +311,11 @@ export function attachWsRpcTransport<
264311
if (server) {
265312
// Share an existing HTTP(S) server's port. Route upgrades ourselves so we
266313
// can coexist with the host's own upgrade handlers.
267-
detach = routeUpgrades(server, ws, path, destroyUnmatched)
314+
detach = routeUpgrades(server, ws, path, destroyUnmatched, allowedOrigins)
268315
}
269316
else if (https) {
270317
ownedServer = createHttpsServer(https)
271-
detach = routeUpgrades(ownedServer, ws, path, true)
318+
detach = routeUpgrades(ownedServer, ws, path, true, allowedOrigins)
272319
ownedServer.listen(port, host)
273320
}
274321
else {
@@ -278,7 +325,7 @@ export function attachWsRpcTransport<
278325
res.writeHead(426, { 'content-type': 'text/plain' })
279326
res.end('Upgrade Required')
280327
})
281-
detach = routeUpgrades(ownedServer, ws, path, true)
328+
detach = routeUpgrades(ownedServer, ws, path, true, allowedOrigins)
282329
ownedServer.listen(port, host)
283330
}
284331

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

Lines changed: 90 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,92 @@ 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+
ws.on('close', () => resolve('closed'))
191+
})
192+
}
193+
194+
it('rejects a cross-origin browser upgrade', async () => {
195+
const HOST = '127.0.0.1'
196+
const PORT = await getPort({ host: HOST, random: true })
197+
const server = createRpcServer<Record<string, never>, Record<string, never>>({})
198+
const { close } = attachWsRpcTransport(server, { port: PORT, host: HOST })
199+
200+
try {
201+
const result = await connectRaw(`ws://${HOST}:${PORT}`, 'http://evil.example')
202+
expect(result).toBe('closed')
203+
}
204+
finally {
205+
await close()
206+
}
207+
})
208+
209+
it('allows a loopback origin', async () => {
210+
const HOST = '127.0.0.1'
211+
const PORT = await getPort({ host: HOST, random: true })
212+
const server = createRpcServer<Record<string, never>, Record<string, never>>({})
213+
const { close } = attachWsRpcTransport(server, { port: PORT, host: HOST })
214+
215+
try {
216+
const result = await connectRaw(`ws://${HOST}:${PORT}`, 'http://localhost:12345')
217+
expect(result).toBe('open')
218+
}
219+
finally {
220+
await close()
221+
}
222+
})
223+
224+
it('allows a request with no Origin header (native client)', async () => {
225+
const HOST = '127.0.0.1'
226+
const PORT = await getPort({ host: HOST, random: true })
227+
const server = createRpcServer<Record<string, never>, Record<string, never>>({})
228+
const { close } = attachWsRpcTransport(server, { port: PORT, host: HOST })
229+
230+
try {
231+
const result = await connectRaw(`ws://${HOST}:${PORT}`)
232+
expect(result).toBe('open')
233+
}
234+
finally {
235+
await close()
236+
}
237+
})
238+
239+
it('honors allowedOrigins for an otherwise-disallowed origin', async () => {
240+
const HOST = '127.0.0.1'
241+
const PORT = await getPort({ host: HOST, random: true })
242+
const server = createRpcServer<Record<string, never>, Record<string, never>>({})
243+
const { close } = attachWsRpcTransport(server, { port: PORT, host: HOST, allowedOrigins: ['http://evil.example'] })
244+
245+
try {
246+
const result = await connectRaw(`ws://${HOST}:${PORT}`, 'http://evil.example')
247+
expect(result).toBe('open')
248+
}
249+
finally {
250+
await close()
251+
}
252+
})
253+
})

0 commit comments

Comments
 (0)