Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8be81b51e1 |
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user