From 578f07c412604a7fd419ad85de9777c2a3a2bfcf Mon Sep 17 00:00:00 2001 From: Kam Date: Thu, 1 Oct 2026 15:40:16 +0300 Subject: [PATCH 1/5] docs: explain how to gate the socket upgrade in a host server --- docs/content/1.guide/14.security.md | 1 + docs/content/2.adapters/1.initiate.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/content/1.guide/14.security.md b/docs/content/1.guide/14.security.md index cf9973e9..b61c80d4 100644 --- a/docs/content/1.guide/14.security.md +++ b/docs/content/1.guide/14.security.md @@ -75,6 +75,7 @@ For your own auth UI, disable built-in handling with `otpParam: false`, then cal - **Stay on loopback.** Bind to a routable address only intentionally, and require authentication when you do. - **Keep `auth: false` local.** The hosted bridges (`devframeViteBridge`, `@devframes/next`'s handler) gate their side-car by default; opt out with an explicit `auth: false` only when the host framework owns the trust boundary another way. +- **Gate the socket in a listener you own.** The `server` option binds its `upgrade` listener after async setup, so a guard that wraps the listeners present at startup won't cover it. To run your own check first (loopback peer, `Host`, a session cookie), leave `server` unset, own the listener, and call `devtools.handleUpgrade(req, socket, head)` once the check passes. The built-in socket gate checks `Origin` only and lets `Origin`-less clients through, so with `auth: false` on a non-loopback bind any client that reaches the port can open the socket. - **The MCP route trusts same-machine callers, harden it when that's not your boundary.** Two gates enforce that default: an origin gate (loopback-only, `Origin`-less rejected) is browser DNS-rebinding hardening, and a peer-address gate rejects a non-loopback caller even with a forged loopback `Origin` (the socket address can't be forged the way a header can). So the `'auto'` default - which mounts the route once agent tools exist - and `mcp: true` are enough for a local dev tool. Neither gate proves *which* caller it is, though, so to intentionally reach the route beyond loopback (a widened `allowedOrigins`, a hosted app) or to expose destructive tools, add an identity check with `mcp: { authorization }` (a bearer from an env var, or a callback), which also lifts the loopback-peer restriction - or turn the route off with `mcp: false`. See [MCP](/adapters/mcp). - **Treat tokens as secrets.** Never log the bearer token or the one-time code, or bake either into build output. - **Authorize every handler.** Validate inputs, and mark state-changing functions `type: 'destructive'` so MCP and agent clients prompt before invoking them. diff --git a/docs/content/2.adapters/1.initiate.md b/docs/content/2.adapters/1.initiate.md index ff625b6e..f8da36bc 100644 --- a/docs/content/2.adapters/1.initiate.md +++ b/docs/content/2.adapters/1.initiate.md @@ -121,7 +121,7 @@ Fetch handlers only hand over `Request`s, so the host framework binds the RPC so 1. **`ws.port`**: a side-car server on that exact port. 2. **`server`**: share the host framework's `node:http` server; the upgrade binds at `__ws`. No extra ports. 3. **`ws: { sidecar: true }`**: a side-car server on a free port, for host frameworks whose handlers never see upgrades (Next.js route handlers, Nitro, Rsbuild). -4. **The host framework's own upgrades.** With none set, the socket waits: `devtools.attach(server)` routes a server's `upgrade` events (returning a detach fn); `devtools.handleUpgrade(req, socket, head)` completes a single one from a listener you own. +4. **The host framework's own upgrades.** With none set, the socket waits: `devtools.attach(server)` routes a server's `upgrade` events (returning a detach fn); `devtools.handleUpgrade(req, socket, head)` completes a single one from a listener you own. Use `handleUpgrade` when you need your own check before the socket opens: the `server` option attaches its listener asynchronously, so don't rely on wrapping the listeners present at startup. `ws.url` controls the *advertisement* instead, so the browser dials it verbatim. Alone, an external WebSocket server owns the transport and its auth (wire the running devframe's `context` via `createContextRpcServer` + a WS transport); alongside a local binding it overrides only the advertisement (the tunnel pattern). From bac5f3700743d3743b8eca52fb055c243b39bbae Mon Sep 17 00:00:00 2001 From: Kam Date: Thu, 1 Oct 2026 15:55:46 +0300 Subject: [PATCH 2/5] docs: check the peer address, not Host, before the socket opens --- docs/content/1.guide/14.security.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/1.guide/14.security.md b/docs/content/1.guide/14.security.md index b61c80d4..c73512cf 100644 --- a/docs/content/1.guide/14.security.md +++ b/docs/content/1.guide/14.security.md @@ -75,7 +75,7 @@ For your own auth UI, disable built-in handling with `otpParam: false`, then cal - **Stay on loopback.** Bind to a routable address only intentionally, and require authentication when you do. - **Keep `auth: false` local.** The hosted bridges (`devframeViteBridge`, `@devframes/next`'s handler) gate their side-car by default; opt out with an explicit `auth: false` only when the host framework owns the trust boundary another way. -- **Gate the socket in a listener you own.** The `server` option binds its `upgrade` listener after async setup, so a guard that wraps the listeners present at startup won't cover it. To run your own check first (loopback peer, `Host`, a session cookie), leave `server` unset, own the listener, and call `devtools.handleUpgrade(req, socket, head)` once the check passes. The built-in socket gate checks `Origin` only and lets `Origin`-less clients through, so with `auth: false` on a non-loopback bind any client that reaches the port can open the socket. +- **Gate the socket in a listener you own.** The `server` option binds its `upgrade` listener after async setup, so a guard that wraps the listeners present at startup won't cover it. To run your own check first (the peer address from `req.socket.remoteAddress`, a session cookie), leave `server` unset, own the listener, and call `devtools.handleUpgrade(req, socket, head)` once the check passes. The built-in socket gate checks `Origin` only and lets `Origin`-less clients through, so with `auth: false` on a non-loopback bind any client that reaches the port can open the socket. - **The MCP route trusts same-machine callers, harden it when that's not your boundary.** Two gates enforce that default: an origin gate (loopback-only, `Origin`-less rejected) is browser DNS-rebinding hardening, and a peer-address gate rejects a non-loopback caller even with a forged loopback `Origin` (the socket address can't be forged the way a header can). So the `'auto'` default - which mounts the route once agent tools exist - and `mcp: true` are enough for a local dev tool. Neither gate proves *which* caller it is, though, so to intentionally reach the route beyond loopback (a widened `allowedOrigins`, a hosted app) or to expose destructive tools, add an identity check with `mcp: { authorization }` (a bearer from an env var, or a callback), which also lifts the loopback-peer restriction - or turn the route off with `mcp: false`. See [MCP](/adapters/mcp). - **Treat tokens as secrets.** Never log the bearer token or the one-time code, or bake either into build output. - **Authorize every handler.** Validate inputs, and mark state-changing functions `type: 'destructive'` so MCP and agent clients prompt before invoking them. From e3bd26dcd557c3ab9d748570da0c7b24451cf3ee Mon Sep 17 00:00:00 2001 From: Kam Date: Thu, 1 Oct 2026 16:09:04 +0300 Subject: [PATCH 3/5] docs: close rejected upgrade sockets and keep the framing positive --- docs/content/1.guide/14.security.md | 2 +- docs/content/2.adapters/1.initiate.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/content/1.guide/14.security.md b/docs/content/1.guide/14.security.md index c73512cf..3d5c0246 100644 --- a/docs/content/1.guide/14.security.md +++ b/docs/content/1.guide/14.security.md @@ -75,7 +75,7 @@ For your own auth UI, disable built-in handling with `otpParam: false`, then cal - **Stay on loopback.** Bind to a routable address only intentionally, and require authentication when you do. - **Keep `auth: false` local.** The hosted bridges (`devframeViteBridge`, `@devframes/next`'s handler) gate their side-car by default; opt out with an explicit `auth: false` only when the host framework owns the trust boundary another way. -- **Gate the socket in a listener you own.** The `server` option binds its `upgrade` listener after async setup, so a guard that wraps the listeners present at startup won't cover it. To run your own check first (the peer address from `req.socket.remoteAddress`, a session cookie), leave `server` unset, own the listener, and call `devtools.handleUpgrade(req, socket, head)` once the check passes. The built-in socket gate checks `Origin` only and lets `Origin`-less clients through, so with `auth: false` on a non-loopback bind any client that reaches the port can open the socket. +- **Gate the socket in a listener you own.** The `server` option binds its `upgrade` listener after async setup, so a guard that wraps the listeners present at startup misses it. To run your own check first, such as the peer address from `req.socket.remoteAddress` or a session cookie, leave `server` unset and own the listener: call `devtools.handleUpgrade(req, socket, head)` when the check passes, and write a `403` response and destroy the socket when it fails. The built-in socket gate checks `Origin` and lets `Origin`-less clients through, so with `auth: false` on a non-loopback bind any client that reaches the port can open the socket. - **The MCP route trusts same-machine callers, harden it when that's not your boundary.** Two gates enforce that default: an origin gate (loopback-only, `Origin`-less rejected) is browser DNS-rebinding hardening, and a peer-address gate rejects a non-loopback caller even with a forged loopback `Origin` (the socket address can't be forged the way a header can). So the `'auto'` default - which mounts the route once agent tools exist - and `mcp: true` are enough for a local dev tool. Neither gate proves *which* caller it is, though, so to intentionally reach the route beyond loopback (a widened `allowedOrigins`, a hosted app) or to expose destructive tools, add an identity check with `mcp: { authorization }` (a bearer from an env var, or a callback), which also lifts the loopback-peer restriction - or turn the route off with `mcp: false`. See [MCP](/adapters/mcp). - **Treat tokens as secrets.** Never log the bearer token or the one-time code, or bake either into build output. - **Authorize every handler.** Validate inputs, and mark state-changing functions `type: 'destructive'` so MCP and agent clients prompt before invoking them. diff --git a/docs/content/2.adapters/1.initiate.md b/docs/content/2.adapters/1.initiate.md index f8da36bc..dd7fb860 100644 --- a/docs/content/2.adapters/1.initiate.md +++ b/docs/content/2.adapters/1.initiate.md @@ -121,7 +121,7 @@ Fetch handlers only hand over `Request`s, so the host framework binds the RPC so 1. **`ws.port`**: a side-car server on that exact port. 2. **`server`**: share the host framework's `node:http` server; the upgrade binds at `__ws`. No extra ports. 3. **`ws: { sidecar: true }`**: a side-car server on a free port, for host frameworks whose handlers never see upgrades (Next.js route handlers, Nitro, Rsbuild). -4. **The host framework's own upgrades.** With none set, the socket waits: `devtools.attach(server)` routes a server's `upgrade` events (returning a detach fn); `devtools.handleUpgrade(req, socket, head)` completes a single one from a listener you own. Use `handleUpgrade` when you need your own check before the socket opens: the `server` option attaches its listener asynchronously, so don't rely on wrapping the listeners present at startup. +4. **The host framework's own upgrades.** With none set, the socket waits: `devtools.attach(server)` routes a server's `upgrade` events (returning a detach fn); `devtools.handleUpgrade(req, socket, head)` completes a single one from a listener you own. To run your own check before the socket opens, own the listener and call `handleUpgrade` once the check passes; the `server` option attaches its listener asynchronously, after any guard that wraps the listeners present at startup. `ws.url` controls the *advertisement* instead, so the browser dials it verbatim. Alone, an external WebSocket server owns the transport and its auth (wire the running devframe's `context` via `createContextRpcServer` + a WS transport); alongside a local binding it overrides only the advertisement (the tunnel pattern). From 0e8a90804d7f2e8fedc07590d1c34a3aa273f319 Mon Sep 17 00:00:00 2001 From: Kam Date: Thu, 1 Oct 2026 16:20:31 +0300 Subject: [PATCH 4/5] docs: handle socket errors on the rejection path --- docs/content/1.guide/14.security.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/1.guide/14.security.md b/docs/content/1.guide/14.security.md index 3d5c0246..a7a36ffe 100644 --- a/docs/content/1.guide/14.security.md +++ b/docs/content/1.guide/14.security.md @@ -75,7 +75,7 @@ For your own auth UI, disable built-in handling with `otpParam: false`, then cal - **Stay on loopback.** Bind to a routable address only intentionally, and require authentication when you do. - **Keep `auth: false` local.** The hosted bridges (`devframeViteBridge`, `@devframes/next`'s handler) gate their side-car by default; opt out with an explicit `auth: false` only when the host framework owns the trust boundary another way. -- **Gate the socket in a listener you own.** The `server` option binds its `upgrade` listener after async setup, so a guard that wraps the listeners present at startup misses it. To run your own check first, such as the peer address from `req.socket.remoteAddress` or a session cookie, leave `server` unset and own the listener: call `devtools.handleUpgrade(req, socket, head)` when the check passes, and write a `403` response and destroy the socket when it fails. The built-in socket gate checks `Origin` and lets `Origin`-less clients through, so with `auth: false` on a non-loopback bind any client that reaches the port can open the socket. +- **Gate the socket in a listener you own.** The `server` option binds its `upgrade` listener after async setup, so a guard that wraps the listeners present at startup misses it. To run your own check first, such as the peer address from `req.socket.remoteAddress` or a session cookie, leave `server` unset and own the listener: attach an `error` handler to the socket, then call `devtools.handleUpgrade(req, socket, head)` when the check passes, or write a `403` response and destroy the socket when it fails. The built-in socket gate checks `Origin` and lets `Origin`-less clients through, so with `auth: false` on a non-loopback bind any client that reaches the port can open the socket. - **The MCP route trusts same-machine callers, harden it when that's not your boundary.** Two gates enforce that default: an origin gate (loopback-only, `Origin`-less rejected) is browser DNS-rebinding hardening, and a peer-address gate rejects a non-loopback caller even with a forged loopback `Origin` (the socket address can't be forged the way a header can). So the `'auto'` default - which mounts the route once agent tools exist - and `mcp: true` are enough for a local dev tool. Neither gate proves *which* caller it is, though, so to intentionally reach the route beyond loopback (a widened `allowedOrigins`, a hosted app) or to expose destructive tools, add an identity check with `mcp: { authorization }` (a bearer from an env var, or a callback), which also lifts the loopback-peer restriction - or turn the route off with `mcp: false`. See [MCP](/adapters/mcp). - **Treat tokens as secrets.** Never log the bearer token or the one-time code, or bake either into build output. - **Authorize every handler.** Validate inputs, and mark state-changing functions `type: 'destructive'` so MCP and agent clients prompt before invoking them. From 8d1fdca53508a2fe5dcb8957e3cc93dc4d54584c Mon Sep 17 00:00:00 2001 From: Kam Date: Thu, 1 Oct 2026 16:27:16 +0300 Subject: [PATCH 5/5] docs: limit a custom upgrade check to the devframe socket path --- docs/content/1.guide/14.security.md | 2 +- docs/content/2.adapters/1.initiate.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/content/1.guide/14.security.md b/docs/content/1.guide/14.security.md index a7a36ffe..b17bfc34 100644 --- a/docs/content/1.guide/14.security.md +++ b/docs/content/1.guide/14.security.md @@ -75,7 +75,7 @@ For your own auth UI, disable built-in handling with `otpParam: false`, then cal - **Stay on loopback.** Bind to a routable address only intentionally, and require authentication when you do. - **Keep `auth: false` local.** The hosted bridges (`devframeViteBridge`, `@devframes/next`'s handler) gate their side-car by default; opt out with an explicit `auth: false` only when the host framework owns the trust boundary another way. -- **Gate the socket in a listener you own.** The `server` option binds its `upgrade` listener after async setup, so a guard that wraps the listeners present at startup misses it. To run your own check first, such as the peer address from `req.socket.remoteAddress` or a session cookie, leave `server` unset and own the listener: attach an `error` handler to the socket, then call `devtools.handleUpgrade(req, socket, head)` when the check passes, or write a `403` response and destroy the socket when it fails. The built-in socket gate checks `Origin` and lets `Origin`-less clients through, so with `auth: false` on a non-loopback bind any client that reaches the port can open the socket. +- **Gate the socket in a listener you own.** The `server` option binds its `upgrade` listener after async setup, so a guard that wraps the listeners present at startup misses it. To run your own check first, such as the peer address from `req.socket.remoteAddress` or a session cookie, leave `server` unset and own the listener. It sees every upgrade on the server, so act only on requests to `__ws` and leave the rest to the host framework. For those, attach an `error` handler to the socket, then call `devtools.handleUpgrade(req, socket, head)` when the check passes, or write a `403` response and destroy the socket when it fails. The built-in socket gate checks `Origin` and lets `Origin`-less clients through, so with `auth: false` on a non-loopback bind any client that reaches the port can open the socket. - **The MCP route trusts same-machine callers, harden it when that's not your boundary.** Two gates enforce that default: an origin gate (loopback-only, `Origin`-less rejected) is browser DNS-rebinding hardening, and a peer-address gate rejects a non-loopback caller even with a forged loopback `Origin` (the socket address can't be forged the way a header can). So the `'auto'` default - which mounts the route once agent tools exist - and `mcp: true` are enough for a local dev tool. Neither gate proves *which* caller it is, though, so to intentionally reach the route beyond loopback (a widened `allowedOrigins`, a hosted app) or to expose destructive tools, add an identity check with `mcp: { authorization }` (a bearer from an env var, or a callback), which also lifts the loopback-peer restriction - or turn the route off with `mcp: false`. See [MCP](/adapters/mcp). - **Treat tokens as secrets.** Never log the bearer token or the one-time code, or bake either into build output. - **Authorize every handler.** Validate inputs, and mark state-changing functions `type: 'destructive'` so MCP and agent clients prompt before invoking them. diff --git a/docs/content/2.adapters/1.initiate.md b/docs/content/2.adapters/1.initiate.md index dd7fb860..fd21331a 100644 --- a/docs/content/2.adapters/1.initiate.md +++ b/docs/content/2.adapters/1.initiate.md @@ -121,7 +121,7 @@ Fetch handlers only hand over `Request`s, so the host framework binds the RPC so 1. **`ws.port`**: a side-car server on that exact port. 2. **`server`**: share the host framework's `node:http` server; the upgrade binds at `__ws`. No extra ports. 3. **`ws: { sidecar: true }`**: a side-car server on a free port, for host frameworks whose handlers never see upgrades (Next.js route handlers, Nitro, Rsbuild). -4. **The host framework's own upgrades.** With none set, the socket waits: `devtools.attach(server)` routes a server's `upgrade` events (returning a detach fn); `devtools.handleUpgrade(req, socket, head)` completes a single one from a listener you own. To run your own check before the socket opens, own the listener and call `handleUpgrade` once the check passes; the `server` option attaches its listener asynchronously, after any guard that wraps the listeners present at startup. +4. **The host framework's own upgrades.** With none set, the socket waits: `devtools.attach(server)` routes a server's `upgrade` events (returning a detach fn); `devtools.handleUpgrade(req, socket, head)` completes a single one from a listener you own. To run your own check before the socket opens, own the listener, match the `__ws` path first (the listener sees every upgrade on the server, including the host framework's own), and call `handleUpgrade` once the check passes; the `server` option attaches its listener asynchronously, after any guard that wraps the listeners present at startup. `ws.url` controls the *advertisement* instead, so the browser dials it verbatim. Alone, an external WebSocket server owns the transport and its auth (wire the running devframe's `context` via `createContextRpcServer` + a WS transport); alongside a local binding it overrides only the advertisement (the tunnel pattern).