@riceawa/dsh-lan-gateway 0.6.2 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -15
- package/lib/client.js +83 -143
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +18 -2
- package/lib/index.js +70 -6
- package/package.json +21 -23
- package/src/client/index.ts +37 -26
- package/src/client/lan-gateway-card.tsx +173 -197
- package/src/gateway.ts +31 -5
- package/src/index.ts +19 -8
- package/src/request-policy.ts +36 -0
package/src/gateway.ts
CHANGED
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
} from './login.ts'
|
|
54
54
|
import {
|
|
55
55
|
downstreamResponseHeaders,
|
|
56
|
+
isLoopbackAuthority,
|
|
56
57
|
isOwnedPath,
|
|
57
58
|
loginOriginAllowed,
|
|
58
59
|
pathOf,
|
|
@@ -265,6 +266,24 @@ export class LanGateway {
|
|
|
265
266
|
: classifySource(req.socket.remoteAddress, this.config.lanCidrs)
|
|
266
267
|
}
|
|
267
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Whether a request for the gateway-owned management prefix comes from the
|
|
271
|
+
* host itself, and may therefore be relayed to the loopback-only config and
|
|
272
|
+
* password routes.
|
|
273
|
+
*
|
|
274
|
+
* Both tests are required and neither is sufficient alone. The socket source
|
|
275
|
+
* is what a remote client cannot forge; the authority the client named is what
|
|
276
|
+
* a trusted TLS terminator deployment cannot blur, because there every socket
|
|
277
|
+
* source is the terminator's own loopback address while the browser still
|
|
278
|
+
* names the public host it dialed. A local browser that used `127.0.0.1` (or
|
|
279
|
+
* `localhost`) is the same operator the native route already trusts, and it
|
|
280
|
+
* still has to clear the session gate and the same-site fence below before
|
|
281
|
+
* anything is forwarded.
|
|
282
|
+
*/
|
|
283
|
+
private localManagementRequest(req: http.IncomingMessage, source: SourceClass): boolean {
|
|
284
|
+
return source === 'loopback' && isLoopbackAuthority(req.headers.host)
|
|
285
|
+
}
|
|
286
|
+
|
|
268
287
|
/**
|
|
269
288
|
* The session a request carries, or undefined when it presents none, presents
|
|
270
289
|
* one that no longer verifies under the current epoch, or presents one whose
|
|
@@ -323,7 +342,10 @@ export class LanGateway {
|
|
|
323
342
|
return `${this.config.cookieName}=${value}; ${attributes}${this.config.secureCookies ? '; Secure' : ''}`
|
|
324
343
|
}
|
|
325
344
|
|
|
326
|
-
/**
|
|
345
|
+
/**
|
|
346
|
+
* Handle one HTTP request: login surface → owned-path gate → session gate →
|
|
347
|
+
* same-site gate → relay.
|
|
348
|
+
*/
|
|
327
349
|
private async handleHttp(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
328
350
|
const url = req.url ?? '/'
|
|
329
351
|
const pathname = pathOf(url)
|
|
@@ -338,10 +360,14 @@ export class LanGateway {
|
|
|
338
360
|
return
|
|
339
361
|
}
|
|
340
362
|
|
|
341
|
-
// The gateway's own management surface never reaches dsh
|
|
342
|
-
// remote request must not be able to
|
|
343
|
-
// having the gateway rewrite Host to
|
|
344
|
-
|
|
363
|
+
// The gateway's own management surface never reaches dsh from anywhere but
|
|
364
|
+
// the host itself: an unauthenticated remote request must not be able to
|
|
365
|
+
// touch the loopback-only config route by having the gateway rewrite Host to
|
|
366
|
+
// loopback for it. A host-local browser (`localManagementRequest`) is
|
|
367
|
+
// exempt, so the settings card on the gateway origin behaves like the native
|
|
368
|
+
// card instead of being refused exactly where the user goes looking for it;
|
|
369
|
+
// it still has to clear the session and same-site gates below.
|
|
370
|
+
if (isOwnedPath(pathname) && !this.localManagementRequest(req, source)) {
|
|
345
371
|
res.writeHead(403, this.securityHeaders())
|
|
346
372
|
res.end('forbidden')
|
|
347
373
|
return
|
package/src/index.ts
CHANGED
|
@@ -61,10 +61,7 @@ import {
|
|
|
61
61
|
} from './config-fields.ts'
|
|
62
62
|
import { LanGateway } from './gateway.ts'
|
|
63
63
|
import { readBody } from './login.ts'
|
|
64
|
-
import {
|
|
65
|
-
isLoopbackHost,
|
|
66
|
-
READ_ONLY_METHODS,
|
|
67
|
-
} from './request-policy.ts'
|
|
64
|
+
import { isLoopbackHost } from './request-policy.ts'
|
|
68
65
|
import {
|
|
69
66
|
loadState,
|
|
70
67
|
saveState,
|
|
@@ -451,8 +448,24 @@ function tlsStatusLine(cfg: Config): string {
|
|
|
451
448
|
* gateway refuses to relay this prefix, so the only way in is the native
|
|
452
449
|
* loopback listener itself (a genuine local user, or a local process that could
|
|
453
450
|
* already read `~/.dsh`). Host must be loopback (also blocks DNS rebinding),
|
|
454
|
-
* cross-site fetches are refused, an Origin
|
|
455
|
-
*
|
|
451
|
+
* cross-site fetches are refused, and an Origin that *is* attached must match
|
|
452
|
+
* the Host this request named. These are exactly dsh's own rules for a request
|
|
453
|
+
* that may reach its API (`isTrustedApiRequest`), Origin-optional included.
|
|
454
|
+
*
|
|
455
|
+
* **Why an absent Origin is accepted, on a write too.** The Desktop app answers
|
|
456
|
+
* its UI from the `dsh-app://app` origin and forwards every non-asset path to
|
|
457
|
+
* this loopback server through a bridge that strips `host`/`origin`/`cookie`/
|
|
458
|
+
* `sec-fetch-site` and re-attaches dsh's own host session cookie
|
|
459
|
+
* (`forwardWebRequest` in `@deepseek-ai/dsh-desktop-host`). A read from that
|
|
460
|
+
* bridge was always fine; demanding an Origin on a state change made every save
|
|
461
|
+
* from the Desktop card a 403 — the one place a local operator goes looking for
|
|
462
|
+
* this setting — while dsh's own routes accept the shape. Nothing is given up:
|
|
463
|
+
* a non-browser client on this host sets Host *and* Origin freely, so that rule
|
|
464
|
+
* never fenced it, and a browser cannot suppress the markers that do the work —
|
|
465
|
+
* a cross-site request carries `sec-fetch-site: cross-site`, or an Origin that
|
|
466
|
+
* does not name this Host (a cross-origin redirect or a sandboxed frame makes
|
|
467
|
+
* it the literal `null`, which fails the match just as well), and both stay
|
|
468
|
+
* refused. Exported for tests.
|
|
456
469
|
*/
|
|
457
470
|
export function isTrustedConfigRequest(req: IncomingMessage): boolean {
|
|
458
471
|
const host = req.headers?.host
|
|
@@ -467,8 +480,6 @@ export function isTrustedConfigRequest(req: IncomingMessage): boolean {
|
|
|
467
480
|
if (req.headers?.['sec-fetch-site'] === 'cross-site') return false
|
|
468
481
|
const origin = req.headers?.origin
|
|
469
482
|
if (origin !== undefined && !originMatchesHost(origin, host)) return false
|
|
470
|
-
const method = req.method ?? 'GET'
|
|
471
|
-
if (!READ_ONLY_METHODS.has(method) && origin === undefined) return false
|
|
472
483
|
return true
|
|
473
484
|
}
|
|
474
485
|
|
package/src/request-policy.ts
CHANGED
|
@@ -122,6 +122,42 @@ export function isLoopbackHost(hostname: string): boolean {
|
|
|
122
122
|
)
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Whether a `Host` header names a loopback authority (127/8, `localhost`, ::1).
|
|
127
|
+
*
|
|
128
|
+
* This is the second half of the gateway's local-management exemption, and it
|
|
129
|
+
* answers a different question from {@link isLoopbackHost}'s own callers: not
|
|
130
|
+
* "is this authority loopback" but "did the browser itself use a loopback
|
|
131
|
+
* address". The two tests are combined on purpose. The socket source is what a
|
|
132
|
+
* remote client cannot forge; the Host is what a deployment cannot blur — behind
|
|
133
|
+
* a trusted TLS terminator every socket source is the terminator's loopback
|
|
134
|
+
* address, so the source test alone would readmit every remote browser, while a
|
|
135
|
+
* remote browser names the public host it dialed and stays refused.
|
|
136
|
+
*
|
|
137
|
+
* A client that can set an arbitrary Host (curl, not a browser) must still pass
|
|
138
|
+
* the socket-source test and hold a gateway session to reach anything, and the
|
|
139
|
+
* route behind the prefix is the one the native loopback listener already
|
|
140
|
+
* answers with no credential at all.
|
|
141
|
+
* @param host - the `Host` header value, or undefined.
|
|
142
|
+
* @returns true only when it parses and names a loopback authority.
|
|
143
|
+
*/
|
|
144
|
+
export function isLoopbackAuthority(host: string | undefined): boolean {
|
|
145
|
+
if (host === undefined || host === '') return false
|
|
146
|
+
let url: URL
|
|
147
|
+
try {
|
|
148
|
+
url = new URL(`http://${host}`)
|
|
149
|
+
} catch {
|
|
150
|
+
return false
|
|
151
|
+
}
|
|
152
|
+
// A Host header is nothing but an authority. Anything URL parsing had to read
|
|
153
|
+
// beyond `host[:port]` — userinfo, a path, a query — means the value is not
|
|
154
|
+
// one, and `http://evil.com@127.0.0.1` must not read as loopback.
|
|
155
|
+
if (url.username !== '' || url.password !== '' || url.pathname !== '/' || url.search !== '' || url.hash !== '') {
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
return isLoopbackHost(url.hostname)
|
|
159
|
+
}
|
|
160
|
+
|
|
125
161
|
/** Whether this source must present a gateway session (default: everyone). */
|
|
126
162
|
export function requiresLogin(source: SourceClass, lanPasswordless: boolean): boolean {
|
|
127
163
|
return !(lanPasswordless && source !== 'internet')
|