1 Commits
Author SHA1 Message Date
mward4 8be81b51e1 Provide the paired-device service so the Skill Center works remotely
@linxin666/dsh-client-ui-skill-explorer authorizes a request either by a
loopback peer or by a live paired device, reading that second path through
a service named remoteWebUiPairing. Nothing on a normal install provides
it, and the plugin's read of it throws: it calls
ctx.get("remoteWebUiPairing", false) intending a default, but Cordis's
signature is get(name, strict), so that call returns undefined and falls
through to ctx.remoteWebUiPairing, which Cordis answers by throwing for a
service nobody provided. The webserver turns the throw into an opaque 400,
so the plugin's own 403 never reaches the client.

The throw sits behind the loopback test, which is why the Skill Center
works on the host and fails from a LAN address or a tunnel.

grantPairing (default true) provides the service, satisfying the check the
plugin designed for it and removing the throw, without reporting a false
peer address. It defers to an existing provider and must stay off when
@linxin666/dsh-remote-web-ui is installed, since two providers for one
service name is a load error.

spoofSocketAddress remains for plugins that fence on the peer address with
no pairing path of their own.
2026-09-27 05:34:28 -04:00
5 changed files with 163 additions and 24 deletions

No files matched your search

+46 -14
View File
@@ -43,7 +43,8 @@ identity fence, which is what actually produces the 403.
## What it changes
Two things, in two different places.
Three things, in three different places, each one reachable only by a different
kind of fence.
**1. Request identity, at the server.** The plugin installs `request` and
`upgrade` listeners with `prependListener` on the raw `node:http` server the
@@ -65,6 +66,13 @@ packaged Desktop build publishes, using the harness's own
`webserver/index-inject` channel — the same mechanism
`@deepseek-ai/dsh-web-app` uses for `__DSH_CONNECTION_RECOVERY__`.
**3. The paired-device service, for plugins that authorize an identity rather
than an origin.** Some plugins accept a request on a loopback peer *or* on a
live paired device, and read the second path through a service called
`remoteWebUiPairing` rather than through any header — so neither the rewrite nor
the browser grant reaches them. The plugin provides that service. See
`grantPairing` below.
It deliberately does **not** touch `sec-fetch-site`. The harness refuses
`cross-site` outright, and that refusal is the last judgement a browser makes
on its own behalf. Rewriting it would make a page on another origin look
@@ -110,23 +118,46 @@ Override any key in your profile's `cordis.patch.yml`:
| `enabled` | `true` | Master switch. `false` loads the plugin and changes nothing. |
| `disableAuth` | `true` | Also removes the browser-session check, so no process token is needed. **This is the one that removes access control.** |
| `forceClientLoopback` | `true` | Grants the browser-side `isLoopback` flag so the settings and model surfaces act as they do on the host. |
| `spoofSocketAddress` | `false` | Also reports the peer address as loopback. See below. |
| `grantPairing` | `true` | Provides the paired-device service that plugins like the Skill Center accept instead of a loopback peer. See below. |
| `spoofSocketAddress` | `false` | Also reports the peer address as loopback, for plugins with no pairing path. See below. |
### `grantPairing` — the Skill Center
Some plugins authorize a request either by a loopback peer or by a **live paired
device**, and they read that second path through a service named
`remoteWebUiPairing` rather than through any header. Providing the service is
the seam those plugins designed for this situation, and it is what makes
`@linxin666/dsh-client-ui-skill-explorer` work from a LAN address or a tunnel.
It also repairs a defect in that plugin. It reads the service as
`ctx.get("remoteWebUiPairing", false)`, intending `false` as a default, but
Cordis's signature is `get(name, strict)` — so `false` is read as the strict
flag, the call returns `undefined`, and evaluation falls through to
`ctx.remoteWebUiPairing`, which Cordis answers by **throwing** for a service
nobody provided. The plugin's own `403 forbidden: loopback-only` therefore
never reaches the client; the webserver turns the throw into an opaque
`400`. Because the throw sits behind the loopback test, it happens only for a
remote window — which is why the Skill Center appears to work on the host and
fails everywhere else.
**Set `grantPairing: false` if you install `@linxin666/dsh-remote-web-ui`.** It
provides the same service, and two providers for one name is a load error. The
plugin detects an existing provider and defers to it, but the reverse order
cannot be detected, so leave one of them off.
### `spoofSocketAddress`
A minority of plugins fence on `request.socket.remoteAddress` rather than on
`Host`. No header rewrite can satisfy those; the plugin has to report the peer
address as loopback. The skill explorer (`@linxin666/dsh-client-ui-skill-explorer`)
is the one shipped today — over a LAN or a tunnel its routes answer
`403 forbidden: loopback-only`, and its skill cards do not work until this is
enabled.
For plugins that fence on `request.socket.remoteAddress` and offer **no**
paired-device path, no header rewrite can reach them and no service can satisfy
them; the plugin has to report the peer address as loopback.
The socket is reused across keep-alive requests, so this is a property of the
*connection*, not the request, and it is a lie every consumer of the peer
address will believe: a reverse-proxy plugin's client-IP attribution, an access
log, or a ban list. If you also run `@goodandready/dsh-lanmode`, leave this
off — lanmode's device registry, per-device revocation, and IP bans all read
the peer address, and this makes them see every client as the same machine.
log, or a ban list. If you run `@goodandready/dsh-lanmode`, leave this off —
its device registry, per-device revocation, and IP bans all read the peer
address, and this makes them see every client as the same machine. Prefer
`grantPairing` where a plugin supports it.
---
@@ -161,10 +192,11 @@ listener. The two are not mutually exclusive, but running both is redundant and
### What it does not fix
- Plugin routes that require a **paired device** rather than a trusted origin
(`@linxin666/dsh-remote-web-ui` and anything keying on its pairing service).
Those authorize an identity, not an origin, so no header rewrite reaches them.
- Anything gating on TLS state, or on a proxy's own authentication.
- A plugin that both fences on the peer address *and* offers no pairing path —
enable `spoofSocketAddress` for that, with the caveat above.
- Whether a remote window is *allowed* is separate from whether it is *safe*.
This plugin answers the first question only; see the warning at the top.
---
+4 -2
View File
@@ -9,8 +9,9 @@
#
# - id: disable-untrusted-origin
# config:
# disableAuth: false # keep the process token, disarm only the fence
# spoofSocketAddress: true # also satisfy socket-based fences
# disableAuth: false # keep the process token, disarm only the fence
# grantPairing: false # when dsh-remote-web-ui provides the pairing service itself
# spoofSocketAddress: true # also satisfy socket-based fences with no pairing path
#
# Set `enabled: false` to suspend the plugin without removing the bundle.
- insert:
@@ -20,4 +21,5 @@
enabled: true
disableAuth: true
forceClientLoopback: true
grantPairing: true
spoofSocketAddress: false
+48 -6
View File
@@ -47,6 +47,13 @@ export const name = 'disable-untrusted-origin'
/** The webserver owns the routes; the connection service owns the fence. */
export const inject = ['webServer', 'connection']
/**
* Service name sibling plugins look up to ask "is this a paired device?".
* `@linxin666/dsh-remote-web-ui` provides it; so does this plugin, when
* `grantPairing` is on and nothing else has.
*/
const PAIRING_SERVICE = 'remoteWebUiPairing'
export const Config = z.object({
enabled: z
.boolean()
@@ -72,16 +79,25 @@ export const Config = z.object({
+ 'channel, so the settings and model surfaces act as they do on the '
+ 'host instead of falling back to in-memory state.',
),
grantPairing: z
.boolean()
.default(true)
.description(
'Provide the paired-device service that plugins such as the skill '
+ 'explorer accept as an alternative to a loopback peer, so their '
+ 'routes answer a remote window instead of refusing it. Leave this '
+ 'off when @linxin666/dsh-remote-web-ui is installed: it provides the '
+ 'same service, and two providers for one name is a load error.',
),
spoofSocketAddress: z
.boolean()
.default(false)
.description(
'Also report the peer address as loopback, for the few plugins that '
+ 'fence on request.socket.remoteAddress instead of Host (the skill '
+ 'explorer is the one shipped today). This is a property of the '
+ 'connection, not the request, and every consumer of the peer address '
+ 'will believe it — including a reverse proxy plugin\'s client-IP '
+ 'attribution and its ban list.',
'Also report the peer address as loopback, for plugins that fence on '
+ 'request.socket.remoteAddress with no paired-device path of their '
+ 'own. This is a property of the connection, not the request, and '
+ 'every consumer of the peer address will believe it — including a '
+ 'reverse proxy plugin\'s client-IP attribution and its ban list.',
),
})
@@ -153,6 +169,32 @@ export function apply(ctx, config) {
)
}
if (config.grantPairing) {
// Some plugins authorize a request either by a loopback peer or by a live
// paired device, and read that second path through a service named
// `remoteWebUiPairing` rather than through any header. Providing the
// service is the seam those plugins designed for this situation.
//
// It also repairs a defect they carry. `@linxin666/dsh-client-ui-skill-explorer`
// reads the service as `ctx.get("remoteWebUiPairing", false)`, intending
// `false` as a default, but Cordis's signature is `get(name, strict)`, so
// that call returns `undefined` and falls through to `ctx.remoteWebUiPairing`
// — and Cordis answers a read of a service nobody provided by throwing.
// The plugin's own 403 never reaches the client; the webserver turns the
// throw into an opaque 400. Because the throw sits behind the loopback
// test, it only happens for a remote window, which is why the Skill Center
// works on the host and fails everywhere else.
//
// Registered on this plugin's own fiber, so unload withdraws it.
if (ctx.get(PAIRING_SERVICE) === undefined) {
ctx.provide(PAIRING_SERVICE, { isPairedDevice: () => true })
} else {
ctx.logger?.info(
`disable-untrusted-origin: ${PAIRING_SERVICE} is already provided; leaving that provider in place`,
)
}
}
if (config.forceClientLoopback) {
ctx.inject(['webServer'], (webCtx) => {
// `on` takes options, not a label; the subscription lives with the
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dsh-disable-untrusted-origin",
"version": "0.1.0",
"version": "0.1.1",
"description": "Treat every browser window as if it were running on the DeepSeek Harness host itself: answers each request with the loopback authority so no Host/Origin fence refuses it, and optionally removes the browser-session check.",
"type": "module",
"main": "lib/index.js",
+64 -1
View File
@@ -32,6 +32,7 @@ async function mockHost() {
},
}
const connection = { ...originals }
const provided = new Map()
const server = createServer((_request, response) => {
response.writeHead(200)
response.end('route handler')
@@ -52,6 +53,17 @@ async function mockHost() {
on(name, listener) {
hooks.set(name, listener)
},
// Cordis's `get` returns undefined for a service nobody provided, and its
// `provide` throws on a second registration of the same name — both
// behaviours the plugin has to respect.
get(service) {
return provided.get(service)
},
provide(service, value) {
if (provided.has(service)) throw new Error(`service "${service}" has been registered`)
provided.set(service, value)
return () => provided.delete(service)
},
}
return {
ctx,
@@ -59,13 +71,20 @@ async function mockHost() {
connection,
originals,
hooks,
provided,
unload() {
for (const dispose of disposers.reverse()) dispose()
},
}
}
const CONFIG = { enabled: true, disableAuth: true, forceClientLoopback: true, spoofSocketAddress: false }
const CONFIG = {
enabled: true,
disableAuth: true,
forceClientLoopback: true,
grantPairing: true,
spoofSocketAddress: false,
}
test('the interceptor is prepended ahead of the route handler', async () => {
const host = await mockHost()
@@ -158,3 +177,47 @@ test('unload removes the interceptor', async () => {
assert.equal(host.server.listeners('upgrade').length, 0)
await new Promise((resolve) => host.server.close(resolve))
})
test('the pairing grant is provided and answers every request', async () => {
const host = await mockHost()
const { apply } = await import(PLUGIN)
apply(host.ctx, CONFIG)
try {
const service = host.provided.get('remoteWebUiPairing')
assert.notEqual(service, undefined, 'service is provided')
assert.equal(typeof service.isPairedDevice, 'function')
assert.equal(service.isPairedDevice({ headers: {} }), true, 'any request reads as a paired device')
} finally {
host.unload()
await new Promise((resolve) => host.server.close(resolve))
}
})
test('an existing pairing provider is never clobbered', async () => {
// dsh-remote-web-ui provides the same service; a second registration is a
// load error, so the plugin must defer to whatever is already there.
const host = await mockHost()
const real = { isPairedDevice: () => false }
host.provided.set('remoteWebUiPairing', real)
const { apply } = await import(PLUGIN)
apply(host.ctx, CONFIG)
try {
assert.equal(host.provided.get('remoteWebUiPairing'), real, 'the existing provider survives')
assert.equal(host.provided.get('remoteWebUiPairing').isPairedDevice({}), false)
} finally {
host.unload()
await new Promise((resolve) => host.server.close(resolve))
}
})
test('grantPairing false provides nothing', async () => {
const host = await mockHost()
const { apply } = await import(PLUGIN)
apply(host.ctx, { ...CONFIG, grantPairing: false })
try {
assert.equal(host.provided.has('remoteWebUiPairing'), false)
} finally {
host.unload()
await new Promise((resolve) => host.server.close(resolve))
}
})