Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6ae15f829 |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 dsh-disable-untrusted-origin contributors
|
||||
Copyright (c) 2026 dsh-trustworthy contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
# dsh-disable-untrusted-origin
|
||||
# dsh-trustworthy
|
||||
|
||||
Treat every browser window as if it were running on the DeepSeek Harness host itself.
|
||||
**Removes the untrusted-origin restriction from DeepSeek Harness.** Every browser
|
||||
window is treated as if it were running on the harness machine itself: no process
|
||||
token, no declared trusted host, and no per-plugin origin fence, because one
|
||||
interceptor answers every request with the loopback authority.
|
||||
|
||||
A window that reaches the harness by a LAN address, a DNS name, or a tunnel
|
||||
authenticates normally and then fails every mutating request with
|
||||
`403 {"error":"untrusted origin"}`. Cards render, but Install, Update, toggle,
|
||||
and "Update all" do nothing that the user can see. This plugin removes the
|
||||
distinction between a local window and a remote one. No token, no
|
||||
`--trusted-host`, no per-plugin configuration.
|
||||
distinction between a local window and a remote one — with no token, no
|
||||
`--trusted-host`, no per-plugin configuration, and no second listener on a
|
||||
second port.
|
||||
|
||||
---
|
||||
|
||||
@@ -91,10 +95,10 @@ exactly like a window on this machine.
|
||||
|
||||
```bash
|
||||
# from a checkout or tarball on disk
|
||||
dsh plugin --profile web add file:/path/to/dsh-disable-untrusted-origin
|
||||
dsh plugin --profile web add file:/path/to/dsh-trustworthy
|
||||
|
||||
# or from a published package
|
||||
dsh plugin --profile web add dsh-disable-untrusted-origin
|
||||
dsh plugin --profile web add dsh-trustworthy
|
||||
```
|
||||
|
||||
Then restart the host so the new bundle is loaded.
|
||||
@@ -102,7 +106,7 @@ Then restart the host so the new bundle is loaded.
|
||||
The bundle patch inserts the row automatically. Verify it loaded:
|
||||
|
||||
```bash
|
||||
dsh plugin --profile web list | grep disable-untrusted-origin
|
||||
dsh plugin --profile web list | grep trustworthy
|
||||
```
|
||||
|
||||
---
|
||||
@@ -112,7 +116,7 @@ dsh plugin --profile web list | grep disable-untrusted-origin
|
||||
Override any key in your profile's `cordis.patch.yml`:
|
||||
|
||||
```yaml
|
||||
- id: disable-untrusted-origin
|
||||
- id: trustworthy
|
||||
config:
|
||||
enabled: true
|
||||
disableAuth: true
|
||||
@@ -187,7 +191,7 @@ context**, and the browser withholds two APIs from it:
|
||||
The UUID is minted from `crypto.getRandomValues`, which is *not* restricted to
|
||||
secure contexts; the clipboard falls back to a hidden textarea and
|
||||
`document.execCommand('copy')`. `shimPwa` additionally serves a manifest at
|
||||
`/dsh-disable-untrusted-origin/manifest.webmanifest`, links it, and widens the
|
||||
`/dsh-trustworthy/manifest.webmanifest`, links it, and widens the
|
||||
viewport tag to `viewport-fit=cover` so Add to Home Screen produces a
|
||||
standalone app rather than a bookmark.
|
||||
|
||||
@@ -212,8 +216,75 @@ them will break it:
|
||||
| `connection.requestRejection` / `authorizeIndex` / `authenticatedUrl` | `@deepseek-ai/dsh-client-connection` |
|
||||
| the `webserver/index-inject` channel and the `ownsHost` marker | webserver + connection client |
|
||||
|
||||
If `webServer.server` is ever absent the plugin throws at load with a clear
|
||||
message rather than failing quietly.
|
||||
Every one of those is probed before use. If a release reshapes the surface, the
|
||||
plugin logs which capability it lost and carries on with the rest, rather than
|
||||
failing the fiber and taking the parts that still work down with it. Losing the
|
||||
raw server is the one that matters — it is reported as such:
|
||||
|
||||
```
|
||||
trustworthy: the webserver exposes no raw server, so the request-identity
|
||||
interceptor was not installed and a Host fence will still refuse a remote
|
||||
window; the harness surface may have changed
|
||||
```
|
||||
|
||||
### Why it never writes the trusted-host lists
|
||||
|
||||
The obvious fix for this problem is to add the arriving host to
|
||||
`connection.trustedHosts`. This plugin deliberately does not, for two reasons.
|
||||
|
||||
The harness assembles **two** trusted-host arrays — one on `webRuntime`, one on
|
||||
`connection` — and they are separate objects cloned at assembly time. A host
|
||||
declared into one is invisible to the other, so half the fix silently does
|
||||
nothing. More importantly, a fence that compares against a **literal loopback
|
||||
value** rather than against the list is not moved by any declaration at all.
|
||||
Plugin markets published before the list existed hardcode exactly that check, and
|
||||
they are the ones producing the 403 users actually see. Rewriting the request
|
||||
identity satisfies every fence, of every shape and every age; a list entry
|
||||
satisfies only the fences that read the list.
|
||||
|
||||
### Reload safety
|
||||
|
||||
The plugin mutates objects the harness owns, and a reload gives those mutations
|
||||
two authors. The harness does not promise an ordering between them — in practice
|
||||
the **successor applies before the predecessor's cleanup runs** — and it does not
|
||||
always run disposers at all when a plugin is switched off by editing its patch
|
||||
row.
|
||||
|
||||
Three guards follow from that, and each has a test:
|
||||
|
||||
- Replacements are recorded through a **handover carrier** keyed on the target
|
||||
itself, with the installing generation stamped as its owner. A cleanup whose
|
||||
owner is no longer current returns without touching anything, so a
|
||||
predecessor cannot restore the fence underneath a live successor. The carrier
|
||||
key is `Symbol.for('dsh-trustworthy.handover')` — a registry symbol, not a
|
||||
module-local one, so a reload that re-imports this file still shares it.
|
||||
- The service methods answer through a **liveness check on the plugin's own
|
||||
fiber**. If the fiber is gone the replacement stops admitting and delegates to
|
||||
the true original instead, read from the carrier rather than captured at
|
||||
install time. A plugin switched off without its disposers therefore closes
|
||||
rather than leaving the gateway open — the failure mode is a working fence, not
|
||||
a silent hole.
|
||||
- The interceptor is tagged `Symbol.for('dsh-trustworthy.interceptor')`, and a new
|
||||
one **retires any predecessor bearing the tag** before prepending itself. Two
|
||||
live interceptors would both run, and the older one is a closure over a dead
|
||||
generation's configuration — it would keep spoofing the peer address after
|
||||
`spoofSocketAddress` was turned off.
|
||||
|
||||
### Known limits
|
||||
|
||||
- `sec-fetch-site: cross-site` is refused **unconditionally** by
|
||||
`isTrustedApiRequest`, before the `Origin` check and regardless of the
|
||||
trusted-host list. No header rewrite can defeat it, and this plugin does not
|
||||
try. It is inert with the default `disableAuth: true`, because the gateway
|
||||
never reaches the fence. It is the one thing that can still refuse a request
|
||||
with `disableAuth: false`.
|
||||
- Rewriting `Host` changes the **cookie authority** the harness derives from it
|
||||
(`dsh-auth-<sha256(authority)>`). With `disableAuth: false` that invalidates
|
||||
sessions minted under the hostname you used before — one re-login — and
|
||||
afterwards a single login covers every entry domain instead of one per domain.
|
||||
Inert with `disableAuth: true`.
|
||||
- `Referer` is read by no layer of the harness, core or plugin. It is rewritten
|
||||
only for third-party code that might read it.
|
||||
|
||||
### Relationship to `@goodandready/dsh-lanmode`
|
||||
|
||||
@@ -240,6 +311,33 @@ So the two overlap almost entirely. Running both is redundant, and
|
||||
`spoofSocketAddress` in particular will confuse lanmode's device tracking — but
|
||||
neither will break the other if you do.
|
||||
|
||||
`shimClipboard`, `shimRandomUuid`, and `shimPwa` exist so that lanmode is not
|
||||
needed for the browser side either; with them on, the only things it still owns
|
||||
are its mobile navigation UI, its QR tool, and its device registry.
|
||||
|
||||
### Relationship to `@mzzsfy/dsh-auto-trust-all`
|
||||
|
||||
The other published approach to this problem, and a good one — it solves it by
|
||||
**declaring** the arriving host, wrapping the webserver's route-registration
|
||||
methods and adding each `Host` it sees to both trusted-host lists, under an LRU
|
||||
cap.
|
||||
|
||||
The difference is reach. A declaration only helps a fence that consults the
|
||||
list, and the fences users actually hit do not: they compare against a literal
|
||||
loopback value, in versions published before the list existed. Declaring the
|
||||
host leaves those untouched; rewriting the request identity satisfies them. This
|
||||
plugin also has nothing to cap, because it declares nothing.
|
||||
|
||||
The reload guards here — a generation-owned carrier, identity-checked teardown,
|
||||
per-request liveness, and a tagged interceptor that retires its predecessor —
|
||||
come from the same problem being solved there. That project's convention is also
|
||||
worth stating, because it predicts what is coming: its own review standard
|
||||
declares *any* audit finding of "missing Host validation / DNS-rebinding defence
|
||||
/ token validation" **invalid** in its packages. Third-party loopback-only
|
||||
fences are therefore a deliberate ecosystem position, not an oversight, and more
|
||||
of them will appear. `sec-fetch-site` and the cookie authority above are the two
|
||||
limits inherited from the harness side.
|
||||
|
||||
### What it does not fix
|
||||
|
||||
- Anything gating on TLS state, or on a proxy's own authentication.
|
||||
@@ -256,9 +354,10 @@ neither will break the other if you do.
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
node test/fence.test.mjs # drives real requests past the real plugin-market fence
|
||||
node test/apply.test.mjs # wiring, including that unload restores every original
|
||||
node test/shims.test.mjs # executes the generated shim in a context without the APIs
|
||||
node test/fence.test.mjs # drives real requests past the real plugin-market fence
|
||||
node test/apply.test.mjs # wiring, including that unload restores every original
|
||||
node test/handover.test.mjs # the reload orderings: predecessor vs. successor, liveness
|
||||
node test/shims.test.mjs # executes the generated shim in a context without the APIs
|
||||
node scripts/live-probe.mjs # against the running harness, over a non-loopback peer
|
||||
```
|
||||
|
||||
@@ -267,8 +366,10 @@ profile is present and says which source it used, so a run against a bundled
|
||||
copy is never mistaken for a run against the real thing. `test/shims.test.mjs`
|
||||
evaluates the shim source in a `node:vm` context that lacks `crypto.randomUUID`
|
||||
and `navigator.clipboard`, so it checks the delivered code rather than the string
|
||||
that delivers it. The suites need no dependency tree beyond a resolvable
|
||||
`@deepseek-ai/schemastery` for the second.
|
||||
that delivers it. `test/handover.test.mjs` exercises the orderings directly,
|
||||
including the one the harness does not promise — a predecessor's cleanup running
|
||||
*after* its successor installed. The suites need no dependency tree beyond a
|
||||
resolvable `@deepseek-ai/schemastery` for the second.
|
||||
|
||||
`scripts/live-probe.mjs` is the one that matters after a change: it connects to a
|
||||
**non-loopback** local address, because the Skill Center fences on the socket
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
# dsh-disable-untrusted-origin — bundle layer
|
||||
# dsh-trustworthy — bundle layer
|
||||
#
|
||||
# Applied automatically when the package is installed as a profile bundle
|
||||
# (package.json declares dsh.bundle.patch).
|
||||
@@ -8,7 +8,7 @@
|
||||
# host's own isLoopback flag, and the APIs a non-secure page is denied are
|
||||
# restored. Override any key in your profile patch:
|
||||
#
|
||||
# - id: disable-untrusted-origin
|
||||
# - id: trustworthy
|
||||
# config:
|
||||
# disableAuth: false # keep the process token, disarm only the fence
|
||||
# grantPairing: false # when dsh-remote-web-ui provides the pairing service itself
|
||||
@@ -19,8 +19,8 @@
|
||||
#
|
||||
# Set `enabled: false` to suspend the plugin without removing the bundle.
|
||||
- insert:
|
||||
- id: disable-untrusted-origin
|
||||
name: 'dsh-disable-untrusted-origin'
|
||||
- id: trustworthy
|
||||
name: 'dsh-trustworthy'
|
||||
config:
|
||||
enabled: true
|
||||
disableAuth: true
|
||||
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Handover between generations of the same plugin.
|
||||
*
|
||||
* Everything this plugin installs is a mutation of an object somebody else
|
||||
* owns: methods on the `connection` service, and listeners on the raw HTTP
|
||||
* server. A reload — the plugin market switching it off and on, a line-level
|
||||
* config change, a bundle patch reloaded — runs `apply` again and, sooner or
|
||||
* later, the previous generation's cleanup. Those two events have no
|
||||
* guaranteed order, and the unordered case is destructive:
|
||||
*
|
||||
* generation 1 captures the true originals, installs A
|
||||
* generation 2 captures A as its "originals", installs B
|
||||
* generation 1 cleans up, restoring the true originals
|
||||
*
|
||||
* Generation 2 is now silently inert while believing it is installed, because
|
||||
* the value it displaced was itself a replacement. The fix is to record the
|
||||
* originals once, on the shared object, together with the identity of the
|
||||
* generation that currently owns them; a cleanup runs only while it still owns
|
||||
* the installation. That is what {@link installReplacements} does, and the
|
||||
* interceptor needs the mirror of it — a listener that survives its author
|
||||
* must not be joined by its successor.
|
||||
*
|
||||
* Deliberately free of imports, like `authority.js` and `shims.js`, so the
|
||||
* tests can load it with no resolvable dependency tree.
|
||||
*
|
||||
* The sharing channels are `Symbol.for` keys, not module-local state: a reload
|
||||
* can arrive as a fresh module instance with a fresh set of module bindings, and
|
||||
* only the global symbol registry is common to both.
|
||||
*/
|
||||
|
||||
/** Carrier for the recorded originals and the owning generation. */
|
||||
export const HANDOVER = Symbol.for('dsh-trustworthy.handover')
|
||||
|
||||
/** Marks a listener as this plugin's request interceptor. */
|
||||
export const INTERCEPTOR = Symbol.for('dsh-trustworthy.interceptor')
|
||||
|
||||
/** Fallback carrier for a target that refuses a symbol property. */
|
||||
const carriers = new WeakMap()
|
||||
|
||||
/**
|
||||
* Read the carrier on a shared object.
|
||||
*
|
||||
* @param target - the object the installation lives on.
|
||||
* @param create - whether to make one when it is absent.
|
||||
* @returns the carrier, or `undefined` when absent and not creating.
|
||||
*/
|
||||
function carrierFor(target, create) {
|
||||
const existing = target[HANDOVER]
|
||||
if (existing !== undefined) return existing
|
||||
const mapped = carriers.get(target)
|
||||
if (mapped !== undefined) return mapped
|
||||
if (!create) return undefined
|
||||
const carrier = { originals: new Map(), owner: null }
|
||||
try {
|
||||
Object.defineProperty(target, HANDOVER, {
|
||||
value: carrier,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
enumerable: false,
|
||||
})
|
||||
} catch {
|
||||
// A frozen or exotic host object still gets coordination, just only within
|
||||
// this module instance rather than across a fresh one.
|
||||
carriers.set(target, carrier)
|
||||
}
|
||||
return carrier
|
||||
}
|
||||
|
||||
/**
|
||||
* Install replacement properties, with a handover between generations.
|
||||
*
|
||||
* The originals are captured exactly once and kept on the target, so a
|
||||
* successor inherits them rather than treating its predecessor's replacements
|
||||
* as the state to return to. A cleanup restores only while its own generation
|
||||
* still owns the installation, and only for keys still holding its own value —
|
||||
* so a third party that replaced one of them in the meantime keeps it.
|
||||
*
|
||||
* @param target - the object whose properties are replaced.
|
||||
* @param replacements - a map of property name to replacement value.
|
||||
* @returns a cleanup function, safe to call more than once.
|
||||
*/
|
||||
export function installReplacements(target, replacements) {
|
||||
const carrier = carrierFor(target, true)
|
||||
const keys = Object.keys(replacements)
|
||||
|
||||
// Recorded before the first overwrite, and never again: from the second
|
||||
// generation on, the live values are this plugin's own replacements.
|
||||
for (const key of keys) {
|
||||
if (!carrier.originals.has(key)) carrier.originals.set(key, target[key])
|
||||
}
|
||||
|
||||
const token = {}
|
||||
carrier.owner = token
|
||||
for (const key of keys) target[key] = replacements[key]
|
||||
|
||||
let restored = false
|
||||
return () => {
|
||||
if (restored) return
|
||||
restored = true
|
||||
// A newer generation has taken the installation over; its values are the
|
||||
// live ones and this cleanup must not touch them.
|
||||
if (carrier.owner !== token) return
|
||||
for (const key of keys) {
|
||||
if (target[key] !== replacements[key]) continue
|
||||
const original = carrier.originals.get(key)
|
||||
if (original === undefined) delete target[key]
|
||||
else target[key] = original
|
||||
}
|
||||
carrier.owner = null
|
||||
if (target[HANDOVER] === carrier) {
|
||||
try {
|
||||
delete target[HANDOVER]
|
||||
} catch {
|
||||
/* Non-configurable on an exotic host; the carrier is inert anyway. */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The value a key held before this plugin first replaced it.
|
||||
*
|
||||
* Read from the carrier rather than captured in the caller, so a replacement
|
||||
* can delegate to the true original even when it was installed by a later
|
||||
* generation — and can do so lazily, at call time, when the carrier is known to
|
||||
* exist.
|
||||
*
|
||||
* @param target - the object the installation lives on.
|
||||
* @param key - the property name.
|
||||
* @returns the original value, or `undefined` when there is no carrier or the
|
||||
* property did not exist before this plugin was installed.
|
||||
*/
|
||||
export function originalOf(target, key) {
|
||||
return carrierFor(target, false)?.originals.get(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the request-identity interceptor ahead of every other listener.
|
||||
*
|
||||
* `prependListener`, not `on`: the webserver registers its own request handler
|
||||
* when it constructs the server, so appending would run this after every route
|
||||
* lookup and change nothing.
|
||||
*
|
||||
* Any predecessor bearing {@link INTERCEPTOR} is removed first. A reload can
|
||||
* add the successor before the predecessor's cleanup runs, and two identical
|
||||
* interceptors are not merely wasteful — the older one is a live closure over a
|
||||
* dead generation's config, which would keep spoofing the peer address after
|
||||
* `spoofSocketAddress` was turned off.
|
||||
*
|
||||
* @param server - the raw `node:http` server.
|
||||
* @param listener - the interceptor, called with the request or the socket.
|
||||
* @param events - the server events to cover, `request` and `upgrade`.
|
||||
* @returns a cleanup function, safe to call more than once.
|
||||
*/
|
||||
export function installInterceptor(server, listener, events = ['request', 'upgrade']) {
|
||||
listener[INTERCEPTOR] = true
|
||||
for (const event of events) {
|
||||
for (const existing of server.listeners(event)) {
|
||||
if (existing[INTERCEPTOR] === true) server.removeListener(event, existing)
|
||||
}
|
||||
server.prependListener(event, listener)
|
||||
}
|
||||
let removed = false
|
||||
return () => {
|
||||
if (removed) return
|
||||
removed = true
|
||||
for (const event of events) server.removeListener(event, listener)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a fiber is still the live installation.
|
||||
*
|
||||
* Cordis nulls a fiber's `uid` at the very start of disposal — before its
|
||||
* disposers run, and before the state machine moves to `UNLOADING` — so a
|
||||
* closure left behind by a generation whose cleanup never ran can recognise
|
||||
* that it no longer owns the process. The alternative signal, `fiber.state`, is
|
||||
* a numeric enum whose members are not part of the plugin-facing API; `uid` is
|
||||
* documented as "`null` once disposed".
|
||||
*
|
||||
* Conservative by construction: only a positive signal of disposal answers
|
||||
* false. A host that has reshaped the fiber, or a mock without one, keeps the
|
||||
* installation working rather than silently disarming it.
|
||||
*
|
||||
* @param fiber - the plugin fiber, usually `ctx.fiber`.
|
||||
* @returns false only when the fiber is known to have been disposed.
|
||||
*/
|
||||
export function fiberLive(fiber) {
|
||||
if (fiber === undefined || fiber === null) return true
|
||||
return fiber.uid !== null
|
||||
}
|
||||
+120
-70
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* dsh-disable-untrusted-origin — make every browser window behave like a
|
||||
* dsh-trustworthy — make every browser window behave like a
|
||||
* window running on the harness machine itself.
|
||||
*
|
||||
* DeepSeek Harness fences its HTTP surface by request identity. The `/api`
|
||||
@@ -25,6 +25,22 @@
|
||||
* `navigator.clipboard`, both of which exist only in a secure context, plus
|
||||
* the metadata that makes the page installable on a phone.
|
||||
*
|
||||
* What it deliberately does not do is write the trusted-host lists.
|
||||
* `webRuntime.trustedHosts` and the `connection` service's own `trustedHosts`
|
||||
* are separate arrays cloned at assembly time, and only the second is what the
|
||||
* `/api` fence reads — declaring an authority in the first, or in a config
|
||||
* patch that reaches neither, produces a name the gate never sees. Rewriting
|
||||
* the request needs no declaration at all, and reaches fences a declaration
|
||||
* cannot: a plugin comparing the header against a hardcoded loopback literal is
|
||||
* unmoved by any entry in a list it never consults.
|
||||
*
|
||||
* Reload safety: everything installed here is a mutation of an object the
|
||||
* harness owns, and a reload can run a successor's installation before its
|
||||
* predecessor's cleanup. `handover.js` records the originals once, on the
|
||||
* shared object, and restores them only while the generation that wrote them
|
||||
* still owns the installation — so a reload cannot leave the plugin inert while
|
||||
* it believes it is mounted, nor let a superseded interceptor keep acting.
|
||||
*
|
||||
* Security: this is a deliberate and total removal of the request-identity
|
||||
* fence across the whole HTTP surface. It defends against DNS rebinding and
|
||||
* against another page in the same browser driving this API, and both
|
||||
@@ -32,7 +48,7 @@
|
||||
* turning it on, and keep the listening port behind something that
|
||||
* authenticates on your behalf.
|
||||
*
|
||||
* @module dsh-disable-untrusted-origin
|
||||
* @module dsh-trustworthy
|
||||
*/
|
||||
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
@@ -42,6 +58,14 @@ import {
|
||||
loopbackise,
|
||||
spoofPeerAddress,
|
||||
} from './authority.js'
|
||||
import {
|
||||
HANDOVER,
|
||||
INTERCEPTOR,
|
||||
fiberLive,
|
||||
installInterceptor,
|
||||
installReplacements,
|
||||
originalOf,
|
||||
} from './handover.js'
|
||||
import {
|
||||
MANIFEST_PATH,
|
||||
manifest,
|
||||
@@ -51,10 +75,11 @@ import {
|
||||
} from './shims.js'
|
||||
|
||||
export { CLIENT_LOOPBACK_GRANT, loopbackAuthority, loopbackise, spoofPeerAddress }
|
||||
export { HANDOVER, INTERCEPTOR, fiberLive, installInterceptor, installReplacements, originalOf }
|
||||
export { MANIFEST_PATH, manifest, pwaMarkup, shimSource, withCoverViewport }
|
||||
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'disable-untrusted-origin'
|
||||
export const name = 'trustworthy'
|
||||
|
||||
/** The webserver owns the routes; the connection service owns the fence. */
|
||||
export const inject = ['webServer', 'connection']
|
||||
@@ -145,7 +170,7 @@ export const Config = z.object({
|
||||
*/
|
||||
export function apply(ctx, config) {
|
||||
if (!config.enabled) {
|
||||
ctx.logger?.info('disable-untrusted-origin: disabled by config; no fence was changed')
|
||||
ctx.logger?.info('trustworthy: disabled by config; no fence was changed')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -155,54 +180,63 @@ export function apply(ctx, config) {
|
||||
const authorityNow = () => (authority ??= loopbackAuthority(ctx.webServer.port))
|
||||
|
||||
const onRequest = (request) => {
|
||||
// A listener can outlive its own cleanup when a host tears a fiber down
|
||||
// without running disposers. Cordis nulls the fiber's `uid` at the start of
|
||||
// disposal, so a superseded interceptor recognises itself and stops acting
|
||||
// rather than going on spoofing an identity nobody is asking it to spoof.
|
||||
if (!fiberLive(ctx.fiber)) return
|
||||
loopbackise(request.headers, authorityNow())
|
||||
if (config.spoofSocketAddress) spoofPeerAddress(request)
|
||||
}
|
||||
|
||||
const server = ctx.webServer.server
|
||||
if (server === undefined) {
|
||||
throw new Error(
|
||||
'disable-untrusted-origin: the webserver exposes no raw server; the interceptor cannot be installed',
|
||||
// The host surface is probed rather than assumed. A harness release that
|
||||
// stops exposing the raw server should cost the user this plugin's effect and
|
||||
// a log line, not a failed fiber whose error takes the tree down with it.
|
||||
const server = ctx.webServer?.server
|
||||
if (server === undefined || typeof server.prependListener !== 'function') {
|
||||
ctx.logger?.warn(
|
||||
'trustworthy: the webserver exposes no raw server, so the request-identity '
|
||||
+ 'interceptor was not installed and a Host fence will still refuse a '
|
||||
+ 'remote window; the harness surface may have changed',
|
||||
)
|
||||
} else {
|
||||
// `prependListener`, not `on`: the webserver registers its own request
|
||||
// handler when it constructs the server, so appending would run this after
|
||||
// every route lookup and change nothing.
|
||||
const detach = installInterceptor(server, onRequest)
|
||||
ctx.effect(() => detach, 'trustworthy: request identity interceptor')
|
||||
}
|
||||
// `prependListener`, not `on`: the webserver registers its own request
|
||||
// handler when it constructs the server, so appending would run this after
|
||||
// every route lookup and change nothing.
|
||||
server.prependListener('request', onRequest)
|
||||
server.prependListener('upgrade', onRequest)
|
||||
ctx.effect(
|
||||
() => () => {
|
||||
server.removeListener('request', onRequest)
|
||||
server.removeListener('upgrade', onRequest)
|
||||
},
|
||||
'disable-untrusted-origin: request identity interceptor',
|
||||
)
|
||||
|
||||
if (config.disableAuth) {
|
||||
const connection = ctx.connection
|
||||
const originals = {
|
||||
requestRejection: connection.requestRejection,
|
||||
authorizeIndex: connection.authorizeIndex,
|
||||
authenticatedUrl: connection.authenticatedUrl,
|
||||
}
|
||||
// The `/api` gateway asks one method whether the request may proceed; it
|
||||
// applies the identity fence and then the session check. Answering
|
||||
// `undefined` admits both.
|
||||
connection.requestRejection = () => undefined
|
||||
// The shell is served by the frontend's fallback seat, which asks the
|
||||
// connection service before reading index.html. Affirming unconditionally
|
||||
// serves the application on a bare URL, with no process token and no
|
||||
// cookie.
|
||||
connection.authorizeIndex = () => true
|
||||
// The startup URL and the remote-access handoff both come from here;
|
||||
// returning the base URL keeps the token out of the printed link.
|
||||
connection.authenticatedUrl = (baseUrl) => baseUrl
|
||||
ctx.effect(
|
||||
() => () => {
|
||||
for (const [key, value] of Object.entries(originals)) connection[key] = value
|
||||
},
|
||||
'disable-untrusted-origin: authentication bypass',
|
||||
)
|
||||
//
|
||||
// `authenticatedUrl` is the startup URL and the remote-access handoff;
|
||||
// returning the base URL keeps the process token out of the printed link.
|
||||
//
|
||||
// Each replacement is live-aware rather than unconditional. A host that
|
||||
// takes a plugin out of service by editing its patch row does not always
|
||||
// run the plugin's disposers, and a service method that goes on answering
|
||||
// "admitted" after the plugin was switched off is the worst possible leak:
|
||||
// the fence would look restored while the gateway stayed open. When the
|
||||
// fiber is gone these delegate to the true original instead — read from the
|
||||
// handover carrier, so a later generation still finds the real one rather
|
||||
// than a predecessor's replacement.
|
||||
const live = () => fiberLive(ctx.fiber)
|
||||
const delegate = (key, admitted) =>
|
||||
function (...args) {
|
||||
if (live()) return admitted(...args)
|
||||
const original = originalOf(connection, key)
|
||||
return original === undefined ? undefined : original.apply(this, args)
|
||||
}
|
||||
const restore = installReplacements(connection, {
|
||||
requestRejection: delegate('requestRejection', () => undefined),
|
||||
authorizeIndex: delegate('authorizeIndex', () => true),
|
||||
authenticatedUrl: delegate('authenticatedUrl', (baseUrl) => baseUrl),
|
||||
})
|
||||
ctx.effect(() => restore, 'trustworthy: authentication bypass')
|
||||
}
|
||||
|
||||
if (config.grantPairing) {
|
||||
@@ -221,12 +255,15 @@ export function apply(ctx, config) {
|
||||
// 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.
|
||||
// Registered on this plugin's own fiber, so unload withdraws it. The answer
|
||||
// is gated on the fiber being live as well: if a host switches the plugin
|
||||
// off without running its disposers, the registration would otherwise go on
|
||||
// vouching for every device.
|
||||
if (ctx.get(PAIRING_SERVICE) === undefined) {
|
||||
ctx.provide(PAIRING_SERVICE, { isPairedDevice: () => true })
|
||||
ctx.provide(PAIRING_SERVICE, { isPairedDevice: () => fiberLive(ctx.fiber) })
|
||||
} else {
|
||||
ctx.logger?.info(
|
||||
`disable-untrusted-origin: ${PAIRING_SERVICE} is already provided; leaving that provider in place`,
|
||||
`trustworthy: ${PAIRING_SERVICE} is already provided; leaving that provider in place`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -262,47 +299,60 @@ export function apply(ctx, config) {
|
||||
}
|
||||
|
||||
if (config.shimPwa) {
|
||||
// Served from the webserver rather than injected inline, so the browser
|
||||
// can fetch it under the URL the manifest link advertises.
|
||||
dispose.push(ctx.webServer.register({
|
||||
kind: 'exact',
|
||||
path: MANIFEST_PATH,
|
||||
handler: (request, response) => {
|
||||
const body = JSON.stringify(manifest('DeepSeek Harness'))
|
||||
response.writeHead(200, {
|
||||
'content-type': 'application/manifest+json; charset=utf-8',
|
||||
'content-length': Buffer.byteLength(body),
|
||||
'cache-control': 'no-cache',
|
||||
})
|
||||
response.end(body)
|
||||
},
|
||||
}))
|
||||
// Both seats are probed, for the same reason the raw server is: the
|
||||
// harness reshapes this surface between releases, and a missing method
|
||||
// should cost the user a log line rather than a failed fiber.
|
||||
if (
|
||||
typeof ctx.webServer?.register !== 'function'
|
||||
|| typeof ctx.webServer?.tapIndex !== 'function'
|
||||
) {
|
||||
ctx.logger?.warn(
|
||||
'trustworthy: the webserver exposes no route registry or index '
|
||||
+ 'transform, so the installable-app metadata was not added',
|
||||
)
|
||||
} else {
|
||||
// Served from the webserver rather than injected inline, so the browser
|
||||
// can fetch it under the URL the manifest link advertises.
|
||||
dispose.push(ctx.webServer.register({
|
||||
kind: 'exact',
|
||||
path: MANIFEST_PATH,
|
||||
handler: (request, response) => {
|
||||
const body = JSON.stringify(manifest('DeepSeek Harness'))
|
||||
response.writeHead(200, {
|
||||
'content-type': 'application/manifest+json; charset=utf-8',
|
||||
'content-length': Buffer.byteLength(body),
|
||||
'cache-control': 'no-cache',
|
||||
})
|
||||
response.end(body)
|
||||
},
|
||||
}))
|
||||
|
||||
ctx.inject(['webServer'], (webCtx) => {
|
||||
webCtx.on('webserver/index-inject', (table) => {
|
||||
table.push({
|
||||
kind: 'html',
|
||||
placement: 'head',
|
||||
html: pwaMarkup({ manifestPath: MANIFEST_PATH, includeManifestLink: true }),
|
||||
ctx.inject(['webServer'], (webCtx) => {
|
||||
webCtx.on('webserver/index-inject', (table) => {
|
||||
table.push({
|
||||
kind: 'html',
|
||||
placement: 'head',
|
||||
html: pwaMarkup({ manifestPath: MANIFEST_PATH, includeManifestLink: true }),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// The viewport tag already exists, so it is rewritten in place rather
|
||||
// than joined by a second one whose precedence the browser decides.
|
||||
dispose.push(ctx.webServer.tapIndex(withCoverViewport))
|
||||
// The viewport tag already exists, so it is rewritten in place rather
|
||||
// than joined by a second one whose precedence the browser decides.
|
||||
dispose.push(ctx.webServer.tapIndex(withCoverViewport))
|
||||
}
|
||||
}
|
||||
|
||||
ctx.effect(
|
||||
() => () => {
|
||||
for (const undo of dispose) undo()
|
||||
},
|
||||
'disable-untrusted-origin: browser shims',
|
||||
'trustworthy: browser shims',
|
||||
)
|
||||
}
|
||||
|
||||
ctx.logger?.info(
|
||||
`disable-untrusted-origin: every window is treated as local; authentication is ${
|
||||
`trustworthy: every window is treated as local; authentication is ${
|
||||
config.disableAuth ? 'disabled' : 'kept'
|
||||
}`,
|
||||
)
|
||||
|
||||
+3
-3
@@ -13,13 +13,13 @@
|
||||
*/
|
||||
|
||||
/** Attribute marking the injected shim script, so a second injection is a no-op. */
|
||||
export const SHIM_MARKER = 'data-dsh-duo-shims'
|
||||
export const SHIM_MARKER = 'data-dsh-trustworthy-shims'
|
||||
|
||||
/** Attribute marking the injected PWA metadata block. */
|
||||
export const PWA_MARKER = 'data-dsh-duo-pwa'
|
||||
export const PWA_MARKER = 'data-dsh-trustworthy-pwa'
|
||||
|
||||
/** Where the plugin serves its PWA manifest from. */
|
||||
export const MANIFEST_PATH = '/dsh-disable-untrusted-origin/manifest.webmanifest'
|
||||
export const MANIFEST_PATH = '/dsh-trustworthy/manifest.webmanifest'
|
||||
|
||||
/**
|
||||
* The page-side shim, as a single IIFE.
|
||||
|
||||
+4
-3
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "dsh-disable-untrusted-origin",
|
||||
"version": "0.1.2",
|
||||
"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, optionally removes the browser-session check, and restores the browser APIs a non-secure page is denied.",
|
||||
"name": "dsh-trustworthy",
|
||||
"version": "0.2.0",
|
||||
"description": "Removes the untrusted-origin restriction from DeepSeek Harness. Every browser window is treated as if it were running on the harness machine itself: no process token, no declared trusted host, and no per-plugin origin fence, because one interceptor answers every request with the loopback authority.",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"exports": {
|
||||
".": "./lib/index.js",
|
||||
"./authority": "./lib/authority.js",
|
||||
"./handover": "./lib/handover.js",
|
||||
"./package.json": "./package.json",
|
||||
"./shims": "./lib/shims.js"
|
||||
},
|
||||
|
||||
+151
-23
@@ -17,9 +17,10 @@ import test from 'node:test'
|
||||
const PLUGIN = '../lib/index.js'
|
||||
|
||||
/** A host context carrying only what the plugin touches. */
|
||||
async function mockHost() {
|
||||
async function mockHost({ rawServer = true, registry = true } = {}) {
|
||||
const disposers = []
|
||||
const hooks = new Map()
|
||||
const warnings = []
|
||||
const originals = {
|
||||
requestRejection: function requestRejection() {
|
||||
return 403
|
||||
@@ -40,33 +41,47 @@ async function mockHost() {
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
|
||||
// The webserver is a route registry plus an index transform list, and both
|
||||
// refuse duplicates or release cleanly exactly as the real one does.
|
||||
// refuse duplicates or release cleanly exactly as the real one does. Either
|
||||
// seat can be withheld, so the tests can prove the plugin degrades to a
|
||||
// warning rather than to a failed fiber when a release reshapes the surface.
|
||||
const exact = new Map()
|
||||
const taps = []
|
||||
const webServer = {
|
||||
port: server.address().port,
|
||||
server,
|
||||
host: '0.0.0.0',
|
||||
register(route) {
|
||||
if (exact.has(route.path)) {
|
||||
throw new Error(`webserver: duplicate ${route.kind} route "${route.path}"`)
|
||||
}
|
||||
exact.set(route.path, route)
|
||||
return () => {
|
||||
exact.delete(route.path)
|
||||
}
|
||||
},
|
||||
tapIndex(transform) {
|
||||
taps.push(transform)
|
||||
return () => {
|
||||
const at = taps.indexOf(transform)
|
||||
if (at !== -1) taps.splice(at, 1)
|
||||
}
|
||||
},
|
||||
...(rawServer ? { server } : {}),
|
||||
...(registry
|
||||
? {
|
||||
register(route) {
|
||||
if (exact.has(route.path)) {
|
||||
throw new Error(`webserver: duplicate ${route.kind} route "${route.path}"`)
|
||||
}
|
||||
exact.set(route.path, route)
|
||||
return () => {
|
||||
exact.delete(route.path)
|
||||
}
|
||||
},
|
||||
tapIndex(transform) {
|
||||
taps.push(transform)
|
||||
return () => {
|
||||
const at = taps.indexOf(transform)
|
||||
if (at !== -1) taps.splice(at, 1)
|
||||
}
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
logger: { info() {} },
|
||||
// Cordis nulls a fiber's uid at the start of disposal, and leaves it
|
||||
// non-null for as long as the plugin is mounted.
|
||||
fiber: { uid: 1 },
|
||||
logger: {
|
||||
info() {},
|
||||
warn(message) {
|
||||
warnings.push(message)
|
||||
},
|
||||
},
|
||||
webServer,
|
||||
connection,
|
||||
effect(callback) {
|
||||
@@ -105,6 +120,7 @@ async function mockHost() {
|
||||
provided,
|
||||
exact,
|
||||
taps,
|
||||
warnings,
|
||||
/** Apply the registered index transforms, as `renderIndex` does. */
|
||||
renderIndex(html) {
|
||||
return taps.reduce((current, tap) => tap(current), html)
|
||||
@@ -299,7 +315,7 @@ test('the shims reach the index and the manifest answers', async () => {
|
||||
try {
|
||||
const rows = host.rows()
|
||||
|
||||
const script = rows.find((row) => row.kind === 'script' && row.text?.includes('data-dsh-duo-shims'))
|
||||
const script = rows.find((row) => row.kind === 'script' && row.text?.includes('data-dsh-trustworthy-shims'))
|
||||
assert.ok(script, 'the shim script row is present')
|
||||
assert.equal(script.placement, 'head', 'the shim runs before any bundle')
|
||||
assert.match(script.text, /getRandomValues/, 'the UUID polyfill mints from the unrestricted call')
|
||||
@@ -353,7 +369,7 @@ test('turning the shims off leaves the index alone', async () => {
|
||||
apply(host.ctx, { ...CONFIG, shimClipboard: false, shimRandomUuid: false, shimPwa: false })
|
||||
try {
|
||||
const rows = host.rows()
|
||||
assert.equal(rows.filter((row) => row.text?.includes('data-dsh-duo-shims')).length, 0, 'no shim script')
|
||||
assert.equal(rows.filter((row) => row.text?.includes('data-dsh-trustworthy-shims')).length, 0, 'no shim script')
|
||||
assert.equal(rows.filter((row) => row.kind === 'html').length, 0, 'no PWA metadata')
|
||||
assert.equal(rows.filter((row) => row.text?.includes('ownsHost')).length, 1, 'the identity grant is unaffected')
|
||||
assert.equal(host.exact.size, 0, 'no manifest route')
|
||||
@@ -370,7 +386,7 @@ test('the PWA shim alone does not drag the browser shims in', async () => {
|
||||
apply(host.ctx, { ...CONFIG, shimClipboard: false, shimRandomUuid: false })
|
||||
try {
|
||||
const rows = host.rows()
|
||||
assert.equal(rows.filter((row) => row.kind === 'script' && row.text?.includes('data-dsh-duo-shims')).length, 0)
|
||||
assert.equal(rows.filter((row) => row.kind === 'script' && row.text?.includes('data-dsh-trustworthy-shims')).length, 0)
|
||||
assert.equal(rows.filter((row) => row.kind === 'html').length, 1)
|
||||
assert.equal(host.exact.size, 1)
|
||||
} finally {
|
||||
@@ -388,3 +404,115 @@ test('unload withdraws the manifest route and the index transform', async () =>
|
||||
assert.equal(host.taps.length, 0, 'the viewport transform is released')
|
||||
await new Promise((resolve) => host.server.close(resolve))
|
||||
})
|
||||
|
||||
// The rest of the suite is about being switched off by a host that does not
|
||||
// run disposers, and about a reload that gives the installation two authors.
|
||||
|
||||
test('a disposed fiber stops admitting requests', async () => {
|
||||
// A host can take a plugin out of service by editing its patch row, and the
|
||||
// harness does not always run the plugin's disposers when it does. An
|
||||
// installed-but-switched-off plugin whose service methods still answer
|
||||
// "admitted" is the worst case: the interface reports the plugin as off while
|
||||
// the gateway stands open.
|
||||
const host = await mockHost()
|
||||
const { apply } = await import(PLUGIN)
|
||||
apply(host.ctx, CONFIG)
|
||||
|
||||
assert.equal(host.connection.requestRejection(), undefined, 'admitted while mounted')
|
||||
assert.equal(host.connection.authorizeIndex(), true)
|
||||
assert.equal(host.connection.authenticatedUrl('/base'), '/base')
|
||||
|
||||
host.ctx.fiber.uid = null
|
||||
assert.equal(host.connection.requestRejection(), 403, 'the real fence answers again')
|
||||
assert.equal(host.connection.authorizeIndex(), false)
|
||||
assert.equal(
|
||||
host.connection.authenticatedUrl('/base'),
|
||||
'/base?token=launch-secret',
|
||||
'the launch token comes back',
|
||||
)
|
||||
await new Promise((resolve) => host.server.close(resolve))
|
||||
})
|
||||
|
||||
test('a disposed fiber stops rewriting request identity', async () => {
|
||||
const host = await mockHost()
|
||||
const { apply } = await import(PLUGIN)
|
||||
apply(host.ctx, CONFIG)
|
||||
|
||||
const interceptor = host.server.listeners('request')[0]
|
||||
const mounted = { headers: { host: '192.168.1.49:3080' } }
|
||||
interceptor(mounted)
|
||||
assert.equal(mounted.headers.host, `127.0.0.1:${host.ctx.webServer.port}`)
|
||||
|
||||
host.ctx.fiber.uid = null
|
||||
const after = { headers: { host: '192.168.1.49:3080' } }
|
||||
interceptor(after)
|
||||
assert.equal(after.headers.host, '192.168.1.49:3080', 'a dead generation rewrites nothing')
|
||||
await new Promise((resolve) => host.server.close(resolve))
|
||||
})
|
||||
|
||||
test('a disposed fiber stops vouching for paired devices', async () => {
|
||||
const host = await mockHost()
|
||||
const { apply } = await import(PLUGIN)
|
||||
apply(host.ctx, CONFIG)
|
||||
|
||||
const pairing = host.provided.get('remoteWebUiPairing')
|
||||
assert.equal(pairing.isPairedDevice(), true)
|
||||
host.ctx.fiber.uid = null
|
||||
assert.equal(pairing.isPairedDevice(), false, 'an off plugin vouches for nobody')
|
||||
await new Promise((resolve) => host.server.close(resolve))
|
||||
})
|
||||
|
||||
test('a late cleanup from the previous generation leaves the reload in charge', async () => {
|
||||
// The ordering the harness does not promise: on reload the successor applies
|
||||
// before the predecessor's disposers run. The two generations share one
|
||||
// connection service, so the predecessor's cleanup is the dangerous one — an
|
||||
// unconditional restore would put the fence back while the successor still
|
||||
// believed it was mounted.
|
||||
const previous = await mockHost()
|
||||
const current = await mockHost()
|
||||
previous.ctx.connection = current.ctx.connection
|
||||
|
||||
const { apply } = await import(PLUGIN)
|
||||
apply(previous.ctx, CONFIG)
|
||||
apply(current.ctx, CONFIG)
|
||||
|
||||
previous.unload()
|
||||
assert.equal(
|
||||
current.connection.requestRejection(),
|
||||
undefined,
|
||||
'the successor is still admitting after the predecessor was cleaned up',
|
||||
)
|
||||
|
||||
current.unload()
|
||||
assert.equal(current.connection.requestRejection(), 403, 'the original survives both generations')
|
||||
assert.equal(current.connection.authenticatedUrl('/base'), '/base?token=launch-secret')
|
||||
await new Promise((resolve) => previous.server.close(resolve))
|
||||
await new Promise((resolve) => current.server.close(resolve))
|
||||
})
|
||||
|
||||
test('a webserver with no raw server is reported, not fatal', async () => {
|
||||
// A release may reshape the surface the plugin reaches through. Losing an
|
||||
// optional capability is a warning; throwing would fail the fiber and take
|
||||
// the parts that still work down with it.
|
||||
const host = await mockHost({ rawServer: false })
|
||||
const { apply } = await import(PLUGIN)
|
||||
|
||||
assert.doesNotThrow(() => apply(host.ctx, CONFIG))
|
||||
assert.equal(host.warnings.length, 1, 'the loss is reported')
|
||||
assert.match(host.warnings[0], /no raw server/)
|
||||
assert.equal(host.connection.requestRejection(), undefined, 'the rest still works')
|
||||
host.unload()
|
||||
await new Promise((resolve) => host.server.close(resolve))
|
||||
})
|
||||
|
||||
test('a webserver with no route registry is reported, not fatal', async () => {
|
||||
const host = await mockHost({ registry: false })
|
||||
const { apply } = await import(PLUGIN)
|
||||
|
||||
assert.doesNotThrow(() => apply(host.ctx, CONFIG))
|
||||
assert.equal(host.warnings.length, 1)
|
||||
assert.match(host.warnings[0], /no route registry/)
|
||||
assert.equal(host.connection.requestRejection(), undefined)
|
||||
host.unload()
|
||||
await new Promise((resolve) => host.server.close(resolve))
|
||||
})
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Behavioural check for the generation handover.
|
||||
*
|
||||
* The mechanism this plugin installs is a mutation of objects the harness
|
||||
* owns, and a reload gives those mutations two authors. These tests are about
|
||||
* the orderings — which the harness does not promise — and about the two ways
|
||||
* a reload goes wrong: a predecessor's cleanup tearing down its successor, and
|
||||
* a superseded installation going on acting after it was replaced.
|
||||
*
|
||||
* node test/handover.test.mjs
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
HANDOVER,
|
||||
INTERCEPTOR,
|
||||
fiberLive,
|
||||
installInterceptor,
|
||||
installReplacements,
|
||||
originalOf,
|
||||
} from '../lib/handover.js'
|
||||
|
||||
/** A stand-in for a service whose methods the plugin replaces. */
|
||||
function service() {
|
||||
return {
|
||||
requestRejection: () => 403,
|
||||
authorizeIndex: () => false,
|
||||
authenticatedUrl: (baseUrl) => `${baseUrl}?token=secret`,
|
||||
}
|
||||
}
|
||||
|
||||
/** The replacements one generation installs, distinguishable from another's. */
|
||||
function generation(tag, { live = () => true } = {}) {
|
||||
const admitted = () => `admitted-by-${tag}`
|
||||
return {
|
||||
requestRejection: () => (live() ? admitted() : 'delegated'),
|
||||
authorizeIndex: () => live(),
|
||||
authenticatedUrl: (baseUrl) => (live() ? baseUrl : `${baseUrl}?token=secret`),
|
||||
}
|
||||
}
|
||||
|
||||
test('the originals are recorded once and restored on cleanup', () => {
|
||||
const target = service()
|
||||
const first = generation('one')
|
||||
|
||||
const restore = installReplacements(target, first)
|
||||
assert.equal(target.requestRejection(), 'admitted-by-one')
|
||||
assert.equal(target.authenticatedUrl('/base'), '/base', 'the token is gone while installed')
|
||||
|
||||
restore()
|
||||
assert.equal(target.requestRejection(), 403, 'the fence is back')
|
||||
assert.equal(target.authorizeIndex(), false)
|
||||
assert.equal(target.authenticatedUrl('/base'), '/base?token=secret')
|
||||
})
|
||||
|
||||
test('a predecessor cleanup cannot tear down its successor', () => {
|
||||
// The ordering the harness does not promise: the successor installs first,
|
||||
// and only then does the predecessor's disposer run. Restoring
|
||||
// unconditionally here would put the true originals back while the successor
|
||||
// still believed it was mounted — the plugin inert, reporting itself live.
|
||||
const target = service()
|
||||
const first = installReplacements(target, generation('one'))
|
||||
const second = installReplacements(target, generation('two'))
|
||||
|
||||
first()
|
||||
assert.equal(target.requestRejection(), 'admitted-by-two', 'the successor still owns the service')
|
||||
assert.equal(target.authenticatedUrl('/base'), '/base')
|
||||
|
||||
second()
|
||||
assert.equal(target.requestRejection(), 403, 'the true original survives both generations')
|
||||
assert.equal(target.authenticatedUrl('/base'), '/base?token=secret')
|
||||
})
|
||||
|
||||
test('a cleanup before the next install leaves the original intact', () => {
|
||||
// The ordering the harness usually produces, asserted so that the guard is
|
||||
// not accidentally relying on the unordered case.
|
||||
const target = service()
|
||||
const first = installReplacements(target, generation('one'))
|
||||
first()
|
||||
|
||||
const second = installReplacements(target, generation('two'))
|
||||
assert.equal(target.requestRejection(), 'admitted-by-two')
|
||||
second()
|
||||
assert.equal(target.requestRejection(), 403, 'not a predecessor replacement')
|
||||
})
|
||||
|
||||
test('the original is readable from the carrier across generations', () => {
|
||||
const target = service()
|
||||
const first = installReplacements(target, generation('one'))
|
||||
assert.equal(originalOf(target, 'requestRejection')(), 403)
|
||||
|
||||
installReplacements(target, generation('two'))
|
||||
assert.equal(
|
||||
originalOf(target, 'requestRejection')(),
|
||||
403,
|
||||
'a later generation must still find the true original, not the predecessor',
|
||||
)
|
||||
first()
|
||||
assert.equal(target.requestRejection(), 'admitted-by-two')
|
||||
})
|
||||
|
||||
test('cleanup is idempotent and a third party keeps what it replaced', () => {
|
||||
const target = service()
|
||||
const restore = installReplacements(target, generation('one'))
|
||||
|
||||
target.authorizeIndex = () => 'someone-else'
|
||||
restore()
|
||||
restore()
|
||||
assert.equal(target.authorizeIndex(), 'someone-else', 'a foreign replacement is left alone')
|
||||
assert.equal(target.requestRejection(), 403, 'and the untouched keys are restored')
|
||||
})
|
||||
|
||||
test('a property that did not exist is removed rather than set to undefined', () => {
|
||||
const target = {}
|
||||
const restore = installReplacements(target, { requestRejection: () => 'admitted' })
|
||||
assert.equal(target.requestRejection(), 'admitted')
|
||||
restore()
|
||||
assert.equal('requestRejection' in target, false)
|
||||
})
|
||||
|
||||
test('the carrier is a shared, non-enumerable symbol property', () => {
|
||||
const target = service()
|
||||
const restore = installReplacements(target, generation('one'))
|
||||
assert.equal(HANDOVER, Symbol.for('dsh-trustworthy.handover'), 'shared across module instances')
|
||||
const descriptor = Object.getOwnPropertyDescriptor(target, HANDOVER)
|
||||
assert.equal(descriptor.enumerable, false, 'invisible to a serializer walking the service')
|
||||
restore()
|
||||
assert.equal(target[HANDOVER], undefined, 'withdrawn once nothing is installed')
|
||||
})
|
||||
|
||||
test('a target that refuses the carrier still coordinates', () => {
|
||||
// Some hosts freeze their service objects. Coordination then only covers this
|
||||
// module instance, which is still the common reload path, because a reload
|
||||
// that re-imports the module usually reuses the cached one.
|
||||
const target = Object.preventExtensions(service())
|
||||
const first = installReplacements(target, generation('one'))
|
||||
assert.equal(target.requestRejection(), 'admitted-by-one')
|
||||
first()
|
||||
assert.equal(target.requestRejection(), 403)
|
||||
})
|
||||
|
||||
test('the interceptor is prepended ahead of an existing handler', () => {
|
||||
const server = new EventEmitter()
|
||||
const order = []
|
||||
server.on('request', () => order.push('route'))
|
||||
|
||||
const detach = installInterceptor(server, () => order.push('interceptor'))
|
||||
server.emit('request')
|
||||
assert.deepEqual(order, ['interceptor', 'route'], 'the rewrite runs before any route lookup')
|
||||
|
||||
detach()
|
||||
order.length = 0
|
||||
server.emit('request')
|
||||
assert.deepEqual(order, ['route'])
|
||||
})
|
||||
|
||||
test('the interceptor covers both request and upgrade', () => {
|
||||
const server = new EventEmitter()
|
||||
const seen = []
|
||||
installInterceptor(server, (event) => seen.push(event))
|
||||
server.emit('request', 'request')
|
||||
server.emit('upgrade', 'upgrade')
|
||||
assert.deepEqual(seen, ['request', 'upgrade'], 'a websocket upgrade is a request too')
|
||||
})
|
||||
|
||||
test('a superseded interceptor is retired rather than joined', () => {
|
||||
// A reload can add the successor while the predecessor is still attached.
|
||||
// Two listeners would both run, and the older one is a closure over a dead
|
||||
// generation's configuration.
|
||||
const server = new EventEmitter()
|
||||
const seen = []
|
||||
const first = () => seen.push('old')
|
||||
const detachFirst = installInterceptor(server, first)
|
||||
|
||||
const second = () => seen.push('new')
|
||||
const detachSecond = installInterceptor(server, second)
|
||||
|
||||
assert.equal(server.listenerCount('request'), 1, 'only the successor remains')
|
||||
server.emit('request')
|
||||
assert.deepEqual(seen, ['new'])
|
||||
|
||||
detachFirst()
|
||||
server.emit('request')
|
||||
assert.deepEqual(seen, ['new', 'new'], "the predecessor's cleanup is a harmless no-op")
|
||||
|
||||
detachSecond()
|
||||
assert.equal(server.listenerCount('request'), 0)
|
||||
detachSecond()
|
||||
assert.equal(server.listenerCount('request'), 0, 'cleanup is idempotent')
|
||||
})
|
||||
|
||||
test('the interceptor is marked so a successor can find it', () => {
|
||||
const server = new EventEmitter()
|
||||
const listener = () => {}
|
||||
installInterceptor(server, listener)
|
||||
assert.equal(listener[INTERCEPTOR], true)
|
||||
assert.equal(INTERCEPTOR, Symbol.for('dsh-trustworthy.interceptor'))
|
||||
})
|
||||
|
||||
test('a foreign listener is left in place', () => {
|
||||
const server = new EventEmitter()
|
||||
const foreign = () => {}
|
||||
server.on('request', foreign)
|
||||
const detach = installInterceptor(server, () => {})
|
||||
detach()
|
||||
assert.equal(server.listenerCount('request'), 1)
|
||||
assert.equal(server.listeners('request')[0], foreign)
|
||||
})
|
||||
|
||||
test('liveness answers false only on a positive signal of disposal', () => {
|
||||
assert.equal(fiberLive(undefined), true, 'a host without a fiber is not a reason to disarm')
|
||||
assert.equal(fiberLive(null), true)
|
||||
assert.equal(fiberLive({}), true, 'an unrecognised shape is not a reason to disarm')
|
||||
assert.equal(fiberLive({ uid: 0 }), true, 'the root fiber is live')
|
||||
assert.equal(fiberLive({ uid: 12 }), true)
|
||||
assert.equal(fiberLive({ uid: null }), false, 'cordis nulls uid at the start of disposal')
|
||||
})
|
||||
|
||||
test('a dead generation delegates instead of admitting', () => {
|
||||
// The replacement a live plugin installs on the connection service. Once its
|
||||
// fiber is disposed it must behave as if the plugin were never mounted —
|
||||
// otherwise switching the plugin off would leave the gateway open.
|
||||
const target = service()
|
||||
const fiber = { uid: 1 }
|
||||
const live = () => fiberLive(fiber)
|
||||
const delegate = (key, admitted) =>
|
||||
function delegated(...args) {
|
||||
if (live()) return admitted(...args)
|
||||
const original = originalOf(target, key)
|
||||
return original === undefined ? undefined : original.apply(this, args)
|
||||
}
|
||||
|
||||
installReplacements(target, {
|
||||
requestRejection: delegate('requestRejection', () => undefined),
|
||||
authorizeIndex: delegate('authorizeIndex', () => true),
|
||||
})
|
||||
|
||||
assert.equal(target.requestRejection(), undefined, 'admitted while the fiber is live')
|
||||
assert.equal(target.authorizeIndex(), true)
|
||||
|
||||
fiber.uid = null
|
||||
assert.equal(target.requestRejection(), 403, 'the fence is answered by the original again')
|
||||
assert.equal(target.authorizeIndex(), false)
|
||||
})
|
||||
+3
-3
@@ -89,7 +89,7 @@ test('the UUID polyfill mints a v4 UUID from the unrestricted call', async () =>
|
||||
assert.equal(minted, '00010203-0405-4607-8809-0a0b0c0d0e0f')
|
||||
assert.notEqual(sandbox.crypto.randomUUID(), minted, 'each call reads fresh bytes')
|
||||
assert.equal(calls.draws, 2, 'one random draw per minted UUID')
|
||||
assert.deepEqual(calls.attributes, [['data-dsh-duo-shims', '1']], 'the script is stamped')
|
||||
assert.deepEqual(calls.attributes, [['data-dsh-trustworthy-shims', '1']], 'the script is stamped')
|
||||
})
|
||||
|
||||
test('the UUID polyfill leaves an existing implementation alone', async () => {
|
||||
@@ -148,12 +148,12 @@ test('nothing is emitted when every shim is off', async () => {
|
||||
test('the PWA block carries the manifest link and the app metadata', async () => {
|
||||
const { pwaMarkup, MANIFEST_PATH } = await import(SHIMS)
|
||||
const markup = pwaMarkup({ manifestPath: MANIFEST_PATH, includeManifestLink: true })
|
||||
assert.match(markup, /rel="manifest" href="\/dsh-disable-untrusted-origin\/manifest\.webmanifest"/)
|
||||
assert.match(markup, /rel="manifest" href="\/dsh-trustworthy\/manifest\.webmanifest"/)
|
||||
assert.match(markup, /apple-mobile-web-app-capable/)
|
||||
assert.match(markup, /apple-mobile-web-app-status-bar-style/)
|
||||
assert.match(markup, /name="theme-color"/)
|
||||
// Every tag is marked, so a probe can find the block in the served document.
|
||||
assert.equal((markup.match(/data-dsh-duo-pwa/g) ?? []).length, 4)
|
||||
assert.equal((markup.match(/data-dsh-trustworthy-pwa/g) ?? []).length, 4)
|
||||
assert.doesNotMatch(
|
||||
pwaMarkup({ manifestPath: MANIFEST_PATH, includeManifestLink: false }),
|
||||
/rel="manifest"/,
|
||||
|
||||
Reference in new issue
Block a user