@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/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
- /** Handle one HTTP request: login surface → owned-path refuse → session gate → same-site gate → relay. */
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: an unauthenticated
342
- // remote request must not be able to touch the loopback-only config route by
343
- // having the gateway rewrite Host to loopback for it.
344
- if (isOwnedPath(pathname)) {
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 must match the Host the browser
455
- * used, and a state-changing method must carry that Origin. Exported for tests.
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
 
@@ -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')