@@ -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
0 commit comments