@riceawa/dsh-lan-gateway 0.5.3 → 0.5.4

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@riceawa/dsh-lan-gateway",
3
3
  "description": "LAN/internet reverse-proxy gateway for the DeepSeek Harness web GUI: binds 0.0.0.0 and forwards to the loopback dsh web server. Default-deny: every source (loopback, LAN, internet) must sign in with an HMAC session cookie unless lanPasswordless is explicitly enabled; against dsh >= 0.1.2-rc.1 the gateway relays one shared upstream browser session, so the harness's own authorization still gates every request. Fail-closed start guard (password required, plaintext needs an explicit opt-in), session revocation by epoch (password changes and secret rotation kill cookies and live WebSockets), same-site/Origin fence on HTTP and WebSocket upgrades, optional TLS (auto self-signed or user-supplied certs), and a Settings → Plugins card for live adjustment of port, CIDRs, auth, and TLS. Includes an insecure-origin UUID shim client bundle: on gateway-served plain-HTTP origins browsers lack crypto.randomUUID, so the client half patches a getRandomValues-backed randomUUID onto the Crypto prototype, fixing workspace open over LAN without touching DSH source.",
4
- "version": "0.5.3",
4
+ "version": "0.5.4",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -9,8 +9,9 @@ user-invocable: true
9
9
 
10
10
  The `dsh-lan-gateway` plugin lets the DeepSeek Harness web GUI be reached from the
11
11
  LAN and the wider internet. dsh itself binds only to loopback (the web CLI
12
- hard-refuses `0.0.0.0`), so this plugin runs its own reverse-proxy gateway on
13
- `0.0.0.0` that forwards to the loopback web server while rewriting Host/Origin.
12
+ hard-refuses `0.0.0.0`), so this plugin runs its own reverse-proxy gateway on the
13
+ unspecified address — both families, so IPv6 clients reach it too — forwarding to
14
+ the loopback web server while rewriting Host/Origin.
14
15
 
15
16
  Since v0.5.0 the model is **default-deny** (post-QVD-2026-57410 hardening):
16
17
 
@@ -31,8 +32,8 @@ Since v0.5.0 the model is **default-deny** (post-QVD-2026-57410 hardening):
31
32
  Do not edit state files by hand — use the `lan_gateway` tool.
32
33
 
33
34
  - `lan_gateway` with `command: "status"` — is it listening, on which port, toward
34
- which dsh port, password set?, session epoch, upstream-session relay state,
35
- ingress encryption, last error.
35
+ which dsh port, password set?, session epoch, how many signed-out sessions are
36
+ still held, upstream-session relay state, ingress encryption, last error.
36
37
  - `lan_gateway` with `command: "enable"` — start listening. If it refuses (no
37
38
  password, legacy `authRequired: false`, plaintext without opt-in, `lanPasswordless`
38
39
  without a session-capable base), the message tells you what to change.
@@ -72,3 +73,6 @@ once (passwords and sessions travel in clear).
72
73
  without an Origin) does not.
73
74
  - Sessions don't survive a password change / `rotate-secret`: that is by design —
74
75
  the revocation epoch advanced and all cookies (and live WebSockets) were revoked.
76
+ Signing out is narrower: it revokes only the session that signed out, so that
77
+ session's cookie is dead even if a copy of it was kept elsewhere, while the
78
+ user's other devices stay signed in.
package/src/auth.ts CHANGED
@@ -100,16 +100,40 @@ export function classifySource(
100
100
  }
101
101
 
102
102
  if (address === '::1') return 'loopback'
103
- // Link-local IPv6 fe80::/10.
104
- if (address.toLowerCase().startsWith('fe80:')) return 'lan'
103
+ if (inIpv6LinkLocal(address)) return 'lan'
105
104
  return 'internet'
106
105
  }
107
106
 
107
+ /**
108
+ * Whether a textual IPv6 address falls inside fe80::/10. The first ten bits are
109
+ * `1111111010`, so the leading hextet spans fe80–febf; a `startsWith('fe80:')`
110
+ * test covers only fe80::/16 and misclassifies fe90::–febf:: as internet.
111
+ */
112
+ function inIpv6LinkLocal(address: string): boolean {
113
+ const match = /^([0-9a-fA-F]{1,4}):/.exec(address)
114
+ if (match === null) return false
115
+ return (Number.parseInt(match[1]!, 16) & 0xffc0) === 0xfe80
116
+ }
117
+
108
118
  /** Encode a byte buffer as URL-safe base64 without padding. */
109
119
  function base64url(input: Buffer): string {
110
120
  return input.toString('base64url')
111
121
  }
112
122
 
123
+ /** The claims a verified session cookie carries. */
124
+ export interface SessionClaims {
125
+ /** Epoch millis at which the session expires. */
126
+ exp: number
127
+ /** The revocation epoch the cookie was minted under. */
128
+ epoch: number
129
+ /**
130
+ * Per-session id. Present on cookies minted from 0.5.4 on, which is what
131
+ * lets one session be retired on its own (sign-out) instead of retiring
132
+ * every session the password authorized. Absent on older cookies.
133
+ */
134
+ sid?: string
135
+ }
136
+
113
137
  /**
114
138
  * Issue a signed session cookie value.
115
139
  * @param secret - the HMAC signing secret (base64 string).
@@ -118,28 +142,29 @@ function base64url(input: Buffer): string {
118
142
  * cookie whose epoch no longer matches the live state is rejected by
119
143
  * {@link verifyCookie}. Defaults to 0 (epoch-less, legacy) for callers that
120
144
  * do not participate in revocation.
145
+ * @param sid - optional per-session id (see {@link SessionClaims.sid}).
121
146
  * @returns a `payload.signature` string suitable for the cookie value.
122
147
  */
123
- export function signCookie(secret: string, expiresMs: number, epoch: number = 0): string {
124
- const payload = base64url(Buffer.from(JSON.stringify({ exp: expiresMs, epoch })))
148
+ export function signCookie(secret: string, expiresMs: number, epoch: number = 0, sid?: string): string {
149
+ const claims = sid === undefined ? { exp: expiresMs, epoch } : { exp: expiresMs, epoch, sid }
150
+ const payload = base64url(Buffer.from(JSON.stringify(claims)))
125
151
  const sig = createHmac('sha256', secret).update(payload).digest('base64url')
126
152
  return `${payload}.${sig}`
127
153
  }
128
154
 
129
155
  /**
130
- * Whether a cookie value is a valid, unexpired session signed with `secret`
131
- * and minted under `epoch`. Epoch-less cookies (legacy payloads) count as
132
- * epoch 0, so an upgrade from a pre-0.5.0 state does not log everyone out.
156
+ * Verify a cookie's signature, expiry and epoch.
157
+ * @returns the claims it carries, or undefined when it is not a valid session.
133
158
  */
134
- export function verifyCookie(
159
+ export function verifySession(
135
160
  secret: string,
136
161
  value: string | undefined,
137
162
  now: number,
138
163
  epoch: number = 0,
139
- ): boolean {
140
- if (value === undefined) return false
164
+ ): SessionClaims | undefined {
165
+ if (value === undefined) return undefined
141
166
  const dot = value.indexOf('.')
142
- if (dot === -1) return false
167
+ if (dot === -1) return undefined
143
168
  const payload = value.slice(0, dot)
144
169
  const sig = value.slice(dot + 1)
145
170
  const expected = createHmac('sha256', secret).update(payload).digest()
@@ -147,20 +172,39 @@ export function verifyCookie(
147
172
  try {
148
173
  actual = Buffer.from(sig, 'base64url')
149
174
  } catch {
150
- return false
175
+ return undefined
151
176
  }
152
- if (expected.length !== actual.length) return false
153
- if (!timingSafeEqual(expected, actual)) return false
177
+ if (expected.length !== actual.length) return undefined
178
+ if (!timingSafeEqual(expected, actual)) return undefined
154
179
  try {
155
- const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { exp?: unknown; epoch?: unknown }
156
- if (typeof decoded.exp !== 'number' || decoded.exp <= now) return false
180
+ const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Partial<SessionClaims>
181
+ if (typeof decoded.exp !== 'number' || decoded.exp <= now) return undefined
157
182
  const cookieEpoch = typeof decoded.epoch === 'number' ? decoded.epoch : 0
158
- return cookieEpoch === epoch
183
+ if (cookieEpoch !== epoch) return undefined
184
+ return {
185
+ exp: decoded.exp,
186
+ epoch: cookieEpoch,
187
+ ...(typeof decoded.sid === 'string' ? { sid: decoded.sid } : {}),
188
+ }
159
189
  } catch {
160
- return false
190
+ return undefined
161
191
  }
162
192
  }
163
193
 
194
+ /**
195
+ * Whether a cookie value is a valid, unexpired session signed with `secret`
196
+ * and minted under `epoch`. Epoch-less cookies (legacy payloads) count as
197
+ * epoch 0, so an upgrade from a pre-0.5.0 state does not log everyone out.
198
+ */
199
+ export function verifyCookie(
200
+ secret: string,
201
+ value: string | undefined,
202
+ now: number,
203
+ epoch: number = 0,
204
+ ): boolean {
205
+ return verifySession(secret, value, now, epoch) !== undefined
206
+ }
207
+
164
208
  /**
165
209
  * Whether a browser Origin header names the same authority (hostname:port) as
166
210
  * a request Host header. Both sides run through WHATWG URL parsing so case and
@@ -181,7 +225,15 @@ export function originMatchesHost(origin: string | undefined, host: string | und
181
225
 
182
226
  /** A token bucket limiter keyed by source address. */
183
227
  export class RateLimiter {
228
+ /**
229
+ * Hard ceiling on tracked sources. Expiry alone only reclaims a bucket when
230
+ * `prune` runs, and a spray from many distinct addresses inside one window
231
+ * outruns it, so the map also sheds its soonest-expiring entries past this.
232
+ */
233
+ private static readonly MAX_BUCKETS = 10_000
184
234
  private readonly buckets = new Map<string, { tokens: number; resetAt: number }>()
235
+ /** Epoch millis at which the next opportunistic sweep is due. */
236
+ private nextPruneAt = 0
185
237
  constructor(
186
238
  private readonly maxTokens: number,
187
239
  private readonly windowMs: number,
@@ -194,22 +246,42 @@ export class RateLimiter {
194
246
  */
195
247
  allow(key: string): boolean {
196
248
  const now = Date.now()
197
- const bucket = this.buckets.get(key)
198
- if (bucket === undefined || bucket.resetAt <= now) {
199
- this.buckets.set(key, { tokens: this.maxTokens - 1, resetAt: now + this.windowMs })
200
- return true
249
+ // Sweep on a rolling window. Without this, expired buckets are only
250
+ // replaced when their own key returns, so every address that ever posted
251
+ // to the login route keeps an entry for the life of the process.
252
+ if (now >= this.nextPruneAt) {
253
+ this.prune(now)
254
+ this.nextPruneAt = now + this.windowMs
201
255
  }
202
- if (bucket.tokens > 0) {
203
- bucket.tokens -= 1
256
+ const existing = this.buckets.get(key)
257
+ if (existing !== undefined && existing.resetAt > now) {
258
+ if (existing.tokens <= 0) return false
259
+ existing.tokens -= 1
204
260
  return true
205
261
  }
206
- return false
262
+ // A new bucket. A spray of distinct addresses inside one window outruns
263
+ // expiry, so shed the closest-to-expiring entries first — they are the
264
+ // ones about to lapse anyway, so the eviction costs the least fidelity.
265
+ if (existing === undefined && this.buckets.size >= RateLimiter.MAX_BUCKETS) {
266
+ this.evictSoonestToExpire()
267
+ }
268
+ this.buckets.set(key, { tokens: this.maxTokens - 1, resetAt: now + this.windowMs })
269
+ return true
207
270
  }
208
271
 
209
- /** Drop expired buckets to bound memory. */
272
+ /** Drop expired buckets to bound memory. Called from {@link allow}. */
210
273
  prune(now: number = Date.now()): void {
211
274
  for (const [key, bucket] of this.buckets) {
212
275
  if (bucket.resetAt <= now) this.buckets.delete(key)
213
276
  }
214
277
  }
278
+
279
+ /** Trim back to 90% of the ceiling, oldest expiry first. */
280
+ private evictSoonestToExpire(): void {
281
+ const target = Math.floor(RateLimiter.MAX_BUCKETS * 0.9)
282
+ const byExpiry = [...this.buckets.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt)
283
+ for (const [key] of byExpiry.slice(0, Math.max(0, this.buckets.size - target))) {
284
+ this.buckets.delete(key)
285
+ }
286
+ }
215
287
  }
package/src/gateway.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
- * The reverse-proxy gateway: a `node:http(s)` server bound to `0.0.0.0` that
3
- * forwards every request to the loopback dsh web server.
2
+ * The reverse-proxy gateway: a `node:http(s)` server bound to the unspecified
3
+ * address (dual-stack, so IPv6 clients reach it too) that forwards every
4
+ * request to the loopback dsh web server.
4
5
  *
5
6
  * Security model (post-QVD / session-base):
6
7
  * - Source is classified from `socket.remoteAddress` only (never
@@ -23,22 +24,25 @@
23
24
  * authority-bound session cookie). The gateway therefore relays one shared
24
25
  * upstream session acquired through the launch-token exchange and replays it
25
26
  * on every forwarded request. See `upstream-session.ts`.
26
- * - Sessions carry a revocation epoch: a password change or secret rotation
27
- * bumps the epoch, every previously issued cookie dies, and established
28
- * WebSockets are torn down so the client re-authenticates.
27
+ * - Sessions are revocable two ways. Each carries a random id, so signing out
28
+ * retires exactly that session and the WebSockets it opened; and each
29
+ * carries a revocation epoch, so a password change or secret rotation kills
30
+ * every session at once — cookie, socket, and all.
29
31
  *
30
32
  * @module @riceawa/dsh-lan-gateway/gateway
31
33
  */
32
34
 
33
35
  import http from 'node:http'
34
36
  import https from 'node:https'
37
+ import { randomBytes } from 'node:crypto'
35
38
  import type { Duplex } from 'node:stream'
36
39
  import {
37
40
  classifySource,
38
41
  originMatchesHost,
39
42
  RateLimiter,
40
43
  signCookie,
41
- verifyCookie,
44
+ verifySession,
45
+ type SessionClaims,
42
46
  type SourceClass,
43
47
  } from './auth.ts'
44
48
  import {
@@ -49,12 +53,17 @@ import {
49
53
  serveLoginGet,
50
54
  type LoginPageOptions,
51
55
  } from './login.ts'
52
- import { verifyPassword, type GatewayState } from './state.ts'
56
+ import {
57
+ isSessionRevoked,
58
+ revokeSession,
59
+ verifyPassword,
60
+ type GatewayState,
61
+ } from './state.ts'
53
62
  import type { UpstreamSession } from './upstream-session.ts'
54
63
 
55
64
  /** Configuration the gateway needs at listen time. */
56
65
  export interface GatewayConfig {
57
- /** Port to bind on 0.0.0.0. */
66
+ /** Port to bind on the unspecified address (dual-stack; see {@link LanGateway.listen}). */
58
67
  gatewayPort: number
59
68
  /** The loopback dsh web server port to forward to. */
60
69
  dshPort: number
@@ -74,6 +83,12 @@ export interface GatewayConfig {
74
83
  classifySource?: (req: http.IncomingMessage) => SourceClass
75
84
  /** Optional shared upstream session relayed onto every forwarded request. */
76
85
  upstreamSession?: UpstreamSession
86
+ /**
87
+ * Called after the gateway revokes a session itself (sign-out), so the plugin
88
+ * can persist a state the gateway changed on its own. The gateway has already
89
+ * installed it locally by then.
90
+ */
91
+ onStateChange?: (state: GatewayState) => void
77
92
  }
78
93
 
79
94
  const DEFAULT_BODY_LIMIT_BYTES = 64 * 1024
@@ -83,15 +98,102 @@ const LOGIN_ATTEMPTS_WINDOW_MS = 60_000
83
98
  /** Methods a browser never attaches a CSRF-meaningful body to; safe without an Origin. */
84
99
  const READ_ONLY_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
85
100
 
101
+ /** The upstream browser-session cookie name prefix; the relay owns this namespace. */
102
+ const UPSTREAM_COOKIE_PREFIX = 'dsh-auth-'
103
+
104
+ /**
105
+ * Headers a proxy must not forward in either direction (RFC 9110 §7.6.1), plus
106
+ * the non-standard proxy-connection.
107
+ */
108
+ const HOP_BY_HOP_HEADERS = new Set([
109
+ 'connection',
110
+ 'keep-alive',
111
+ 'proxy-authenticate',
112
+ 'proxy-authorization',
113
+ 'proxy-connection',
114
+ 'te',
115
+ 'trailer',
116
+ 'transfer-encoding',
117
+ 'upgrade',
118
+ ])
119
+
120
+ /**
121
+ * Headers by which a client asserts where a request came from. The gateway
122
+ * classifies on `socket.remoteAddress` and never reads these, so relaying a
123
+ * caller's own values only hands the next hop a forgeable claim.
124
+ */
125
+ const FORWARDING_HEADERS = [
126
+ 'forwarded',
127
+ 'x-forwarded-for',
128
+ 'x-forwarded-host',
129
+ 'x-forwarded-port',
130
+ 'x-forwarded-proto',
131
+ 'x-real-ip',
132
+ ]
133
+
134
+ /** Whether a Cookie fragment names the upstream session cookie. */
135
+ function isUpstreamSessionPair(pair: string): boolean {
136
+ return pair.startsWith(UPSTREAM_COOKIE_PREFIX)
137
+ }
138
+
139
+ /**
140
+ * Drop every `dsh-auth-*` pair from a Cookie header, returning the remainder
141
+ * (possibly '').
142
+ *
143
+ * `attachUpstreamSession` appends the relay's session to the client's own
144
+ * cookie, and upstream reads the FIRST name match. A client that holds any
145
+ * `dsh-auth-<hash>` — typically one minted before dsh's signing secret was
146
+ * reset, so still present but no longer verifying — would therefore shadow the
147
+ * relay's session on every request. That draws a 401, the gateway reads the
148
+ * 401 as "upstream revoked our session" and discards it, the next request
149
+ * re-acquires, and the client's stale cookie shadows that one too: a loop that
150
+ * never converges. Stripping the namespace makes the relay's copy the only one.
151
+ */
152
+ function withoutUpstreamSessionPairs(cookie: string): string {
153
+ return cookie
154
+ .split(';')
155
+ .map((pair) => pair.trim())
156
+ .filter((pair) => pair !== '' && !isUpstreamSessionPair(pair))
157
+ .join('; ')
158
+ }
159
+
86
160
  /** Prefixes the gateway owns and must never relay to dsh. */
87
161
  function isOwnedPath(pathname: string): boolean {
88
162
  return pathname === '/lan-gateway' || pathname.startsWith('/lan-gateway/')
89
163
  }
90
164
 
91
- /** The pathname of a request URL (query string stripped, not decoded). */
165
+ /**
166
+ * The pathname a request is routed by: the one dsh's router resolves it to
167
+ * (WHATWG URL parsing, which strips the query and collapses dot segments),
168
+ * with trailing slashes then removed for the gateway's own surface tests.
169
+ *
170
+ * The decision paths below (owned prefix, login, logout) must use this rather
171
+ * than the raw request target. dsh normalizes before matching, so a raw-string
172
+ * test disagrees with it on `/foo/../lan-gateway/config` — that is not an owned
173
+ * path by string prefix, stays in the relay, and lands on the plugin's own
174
+ * config route once Host has been rewritten to loopback. Forwarding still
175
+ * relays the raw target: dsh applies the same normalization itself.
176
+ *
177
+ * WHATWG parsing does not drop a trailing slash, and neither does dsh's
178
+ * router, so `/__login/` is not the login page to either of them. The gateway
179
+ * recognizes its own surfaces there anyway: `/__logout/` must still sign out,
180
+ * and `/lan-gateway/config/` must be refused rather than relayed into dsh's
181
+ * single-page fallback. Blocking a trailing-slash spelling of an owned prefix
182
+ * errs toward refusing, which costs nothing — no upstream route lives under it.
183
+ */
92
184
  function pathOf(url: string): string {
93
- const query = url.indexOf('?')
94
- return query === -1 ? url : url.slice(0, query)
185
+ try {
186
+ return new URL(url, 'http://gateway.invalid').pathname.replace(/\/+$/, '') || '/'
187
+ } catch {
188
+ // Unparseable here means unparseable for dsh too; the raw target routes
189
+ // nowhere and is relayed as-is.
190
+ return url
191
+ }
192
+ }
193
+
194
+ /** A fresh per-session id: 128 random bits, URL-safe. */
195
+ function newSessionId(): string {
196
+ return randomBytes(16).toString('base64url')
95
197
  }
96
198
 
97
199
  /**
@@ -104,8 +206,14 @@ export class LanGateway {
104
206
  private readonly loginLimiter = new RateLimiter(LOGIN_ATTEMPTS_LIMIT, LOGIN_ATTEMPTS_WINDOW_MS)
105
207
  private state: GatewayState
106
208
  private disposed = false
107
- /** Established WebSockets (upgraded client sockets), torn down on session-epoch change. */
108
- private readonly activeDuplexes = new Set<Duplex>()
209
+ /**
210
+ * Established WebSockets (upgraded client sockets), each keyed by the session
211
+ * that opened it. A socket outlives the request that authenticated it, so it
212
+ * has to be closable by session: on an epoch bump every socket dies, and on
213
+ * sign-out only that session's. The value is undefined for a cookie minted
214
+ * before per-session ids existed, which only a wholesale revocation reaches.
215
+ */
216
+ private readonly activeDuplexes = new Map<Duplex, string | undefined>()
109
217
 
110
218
  constructor(
111
219
  private readonly config: GatewayConfig,
@@ -131,7 +239,14 @@ export class LanGateway {
131
239
  this.state = state
132
240
  }
133
241
 
134
- /** Start listening; rejects if the port is already in use. */
242
+ /**
243
+ * Start listening on the configured port. The listener is dual-stack: with
244
+ * no host given, node binds the unspecified IPv6 address `::` — which also
245
+ * accepts IPv4 clients, arriving as `::ffff:a.b.c.d` for the classifier to
246
+ * unwrap — when the host has IPv6, and falls back to `0.0.0.0` when it does
247
+ * not. Binding IPv4 only used to leave every IPv6 client (including `::1`)
248
+ * unable to reach a gateway that classifies them.
249
+ */
135
250
  async listen(): Promise<void> {
136
251
  return new Promise((resolve, reject) => {
137
252
  const onError = (err: Error): void => {
@@ -144,10 +259,18 @@ export class LanGateway {
144
259
  }
145
260
  this.server.once('error', onError)
146
261
  this.server.once('listening', onListening)
147
- this.server.listen(this.config.gatewayPort, '0.0.0.0')
262
+ this.server.listen(this.config.gatewayPort)
148
263
  })
149
264
  }
150
265
 
266
+ /** The address actually bound, for logs and status (never a claim about it). */
267
+ boundAddress(): string {
268
+ const address = this.server.address()
269
+ if (address === null || typeof address === 'string') return `port ${this.config.gatewayPort}`
270
+ const host = address.family === 'IPv6' ? `[${address.address}]` : address.address
271
+ return `${host}:${address.port}`
272
+ }
273
+
151
274
  /** Close the server, drop upgraded sockets, and stop accepting connections. */
152
275
  async close(): Promise<void> {
153
276
  if (this.disposed) return
@@ -160,14 +283,23 @@ export class LanGateway {
160
283
  }
161
284
 
162
285
  private destroyActiveDuplexes(): void {
163
- for (const socket of this.activeDuplexes) {
286
+ for (const socket of this.activeDuplexes.keys()) {
164
287
  socket.destroy()
165
288
  }
166
289
  this.activeDuplexes.clear()
167
290
  }
168
291
 
169
- private trackDuplex(socket: Duplex): void {
170
- this.activeDuplexes.add(socket)
292
+ /** Close the sockets one session opened, so signing out ends its live streams too. */
293
+ private destroyDuplexesFor(sid: string): void {
294
+ for (const [socket, owner] of this.activeDuplexes) {
295
+ if (owner !== sid) continue
296
+ this.activeDuplexes.delete(socket)
297
+ socket.destroy()
298
+ }
299
+ }
300
+
301
+ private trackDuplex(socket: Duplex, sid: string | undefined): void {
302
+ this.activeDuplexes.set(socket, sid)
171
303
  socket.on('close', () => {
172
304
  this.activeDuplexes.delete(socket)
173
305
  })
@@ -192,15 +324,27 @@ export class LanGateway {
192
324
  return undefined
193
325
  }
194
326
 
195
- /** Whether a request carries a session valid under the current epoch. */
196
- private authorized(req: http.IncomingMessage): boolean {
327
+ /**
328
+ * The session a request carries, or undefined when it presents none, presents
329
+ * one that no longer verifies under the current epoch, or presents one whose
330
+ * id has been signed out.
331
+ */
332
+ private session(req: http.IncomingMessage): SessionClaims | undefined {
197
333
  const cookie = this.sessionCookie(req)
198
- return cookie !== undefined && verifyCookie(
334
+ if (cookie === undefined) return undefined
335
+ const claims = verifySession(
199
336
  this.state.cookieSecret,
200
337
  cookie,
201
338
  Date.now(),
202
339
  this.state.sessionEpoch,
203
340
  )
341
+ if (claims === undefined) return undefined
342
+ return isSessionRevoked(this.state, claims.sid) ? undefined : claims
343
+ }
344
+
345
+ /** Whether a request carries a session valid under the current epoch. */
346
+ private authorized(req: http.IncomingMessage): boolean {
347
+ return this.session(req) !== undefined
204
348
  }
205
349
 
206
350
  /** Whether this source must present a gateway session (default: everyone). */
@@ -312,7 +456,7 @@ export class LanGateway {
312
456
  return
313
457
  }
314
458
 
315
- void readBody(req, DEFAULT_BODY_LIMIT_BYTES, res).then((body) => {
459
+ void readBody(req, DEFAULT_BODY_LIMIT_BYTES, res).then(async (body) => {
316
460
  if (body === undefined) return // response already sent (413/400)
317
461
  let password: string | undefined
318
462
  try {
@@ -321,23 +465,47 @@ export class LanGateway {
321
465
  } catch {
322
466
  password = undefined
323
467
  }
324
- if (password === undefined || !verifyPassword(this.state, password)) {
468
+ const accepted = password !== undefined && await verifyPassword(this.state, password)
469
+ if (!accepted) {
325
470
  this.serveLoginError(res, 'Incorrect password.')
326
471
  return
327
472
  }
328
473
  const maxAgeSeconds = this.config.cookieMaxAgeDays * 86_400
329
474
  const expiresMs = Date.now() + maxAgeSeconds * 1000
330
- const cookie = signCookie(this.state.cookieSecret, expiresMs, this.state.sessionEpoch)
475
+ // Every session gets its own id so signing out can retire this one alone.
476
+ const cookie = signCookie(
477
+ this.state.cookieSecret,
478
+ expiresMs,
479
+ this.state.sessionEpoch,
480
+ newSessionId(),
481
+ )
331
482
  res.writeHead(302, {
332
483
  location: '/',
333
484
  ...this.securityHeaders(),
334
485
  'set-cookie': [this.sessionSetCookie(cookie, maxAgeSeconds)],
335
486
  })
336
487
  res.end()
488
+ }).catch(() => {
489
+ // The body reader reports its own failures through the response; this
490
+ // catches the async verification path so it cannot become an unhandled
491
+ // rejection.
492
+ if (!res.headersSent) {
493
+ res.writeHead(500, this.securityHeaders())
494
+ res.end('login failed')
495
+ }
337
496
  })
338
497
  }
339
498
 
340
- /** POST /__logout: sign an immediately-expired cookie and bounce to / . */
499
+ /**
500
+ * POST /__logout: revoke this session and clear the cookie.
501
+ *
502
+ * The session is stateless, so clearing the cookie only stops the browser
503
+ * that ran the sign-out; a copy of the same value held anywhere else would
504
+ * keep working until it expired. Revoking the id in the cookie retires that
505
+ * one session for good, and leaves the account's other sessions — other
506
+ * devices, other browsers — alone. Bumping the session epoch here would be
507
+ * the blunter instrument: it signs out every session there is.
508
+ */
341
509
  private handleLogout(req: http.IncomingMessage, res: http.ServerResponse): void {
342
510
  if (req.method !== 'POST') {
343
511
  res.writeHead(405, { allow: 'POST' })
@@ -350,6 +518,12 @@ export class LanGateway {
350
518
  res.end('forbidden')
351
519
  return
352
520
  }
521
+ const claims = this.session(req)
522
+ if (claims?.sid !== undefined) {
523
+ this.state = revokeSession(this.state, claims.sid, claims.exp)
524
+ this.config.onStateChange?.(this.state)
525
+ this.destroyDuplexesFor(claims.sid)
526
+ }
353
527
  res.writeHead(302, {
354
528
  location: '/',
355
529
  ...this.securityHeaders(),
@@ -371,6 +545,14 @@ export class LanGateway {
371
545
  delete headers.connection
372
546
  delete headers.upgrade
373
547
  }
548
+ // A caller's own forwarding claims are not ours to relay.
549
+ for (const name of FORWARDING_HEADERS) delete headers[name]
550
+ // The relay is the only authority on the upstream session cookie.
551
+ if (typeof headers.cookie === 'string') {
552
+ const kept = withoutUpstreamSessionPairs(headers.cookie)
553
+ if (kept === '') delete headers.cookie
554
+ else headers.cookie = kept
555
+ }
374
556
  return headers
375
557
  }
376
558
 
@@ -387,6 +569,31 @@ export class LanGateway {
387
569
  return true
388
570
  }
389
571
 
572
+ /**
573
+ * The headers to send back to the client: hop-by-hop headers dropped, and
574
+ * the upstream session cookie withheld. Upstream's one cookie-minting route
575
+ * is the launch-token exchange at `/`, so a client that already holds a
576
+ * gateway session could otherwise post the token through the gateway and
577
+ * walk away with a durable upstream credential the relay exists to keep on
578
+ * this side. Cookies from other routes (plugins) still pass through.
579
+ */
580
+ private downstreamHeaders(upstream: http.IncomingHttpHeaders): http.OutgoingHttpHeaders {
581
+ const headers: http.OutgoingHttpHeaders = {}
582
+ for (const [key, value] of Object.entries(upstream)) {
583
+ if (value === undefined) continue
584
+ const lower = key.toLowerCase()
585
+ if (HOP_BY_HOP_HEADERS.has(lower)) continue
586
+ if (lower === 'set-cookie') {
587
+ const list = (Array.isArray(value) ? value : [value])
588
+ .filter((entry) => !isUpstreamSessionPair(entry.trim()))
589
+ if (list.length > 0) headers[key] = list
590
+ continue
591
+ }
592
+ headers[key] = value
593
+ }
594
+ return headers
595
+ }
596
+
390
597
  /** Forward an HTTP request to dsh, replaying the shared upstream session. */
391
598
  private async relayHttp(req: http.IncomingMessage, res: http.ServerResponse, url: string): Promise<void> {
392
599
  const session = this.config.upstreamSession
@@ -407,7 +614,7 @@ export class LanGateway {
407
614
  if (attached && session !== undefined && proxyRes.statusCode === 401) {
408
615
  session.invalidate()
409
616
  }
410
- res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers)
617
+ res.writeHead(proxyRes.statusCode ?? 502, this.downstreamHeaders(proxyRes.headers))
411
618
  proxyRes.pipe(res)
412
619
  })
413
620
  proxyReq.on('error', () => {
@@ -436,7 +643,10 @@ export class LanGateway {
436
643
  return
437
644
  }
438
645
 
439
- if (this.requiresLogin(source) && !this.authorized(req)) {
646
+ // The session is read once: the socket this upgrade ends up holding stays
647
+ // attributable to it, so signing that session out can close the socket.
648
+ const claims = this.session(req)
649
+ if (this.requiresLogin(source) && claims === undefined) {
440
650
  refuse(401)
441
651
  return
442
652
  }
@@ -462,7 +672,7 @@ export class LanGateway {
462
672
  headers,
463
673
  })
464
674
  proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
465
- this.trackDuplex(socket)
675
+ this.trackDuplex(socket, claims?.sid)
466
676
  // node's http client has already consumed the 101 response headers, so
467
677
  // reconstruct them on the client socket before splicing.
468
678
  const statusLine = `HTTP/1.1 ${proxyRes.statusCode ?? 101} ${proxyRes.statusMessage ?? 'Switching Protocols'}\r\n`