@riceawa/dsh-lan-gateway 0.5.3 → 0.5.5

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
@@ -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
@@ -14,31 +15,32 @@
14
15
  * or its login/logout paths; those are handled locally or refused.
15
16
  * - Because this gateway rewrites Origin to loopback, dsh's own CSRF fence is
16
17
  * blinded — so the gateway runs its own origin check on every relayed
17
- * request (HTTP and WebSocket upgrade) BEFORE rewriting: reject
18
- * `sec-fetch-site: cross-site`, reject any Origin that does not name the
19
- * gateway authority the browser actually used, and require an Origin on
20
- * state-changing methods and on every WebSocket upgrade.
18
+ * request (HTTP and WebSocket upgrade) BEFORE rewriting. See
19
+ * `request-policy.ts`, which owns that decision along with every other
20
+ * header/path/server decision; this module owns the transport.
21
21
  * - Against a session-capable dsh base the Host/Origin rewrite alone would
22
22
  * still earn a 401 (dsh no longer trusts a loopback Host; it demands its own
23
23
  * authority-bound session cookie). The gateway therefore relays one shared
24
24
  * upstream session acquired through the launch-token exchange and replays it
25
25
  * 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.
26
+ * - Sessions are revocable two ways. Each carries a random id, so signing out
27
+ * retires exactly that session and the WebSockets it opened; and each
28
+ * carries a revocation epoch, so a password change or secret rotation kills
29
+ * every session at once — cookie, socket, and all.
29
30
  *
30
31
  * @module @riceawa/dsh-lan-gateway/gateway
31
32
  */
32
33
 
33
34
  import http from 'node:http'
34
35
  import https from 'node:https'
36
+ import { randomBytes } from 'node:crypto'
35
37
  import type { Duplex } from 'node:stream'
36
38
  import {
37
39
  classifySource,
38
- originMatchesHost,
39
40
  RateLimiter,
40
41
  signCookie,
41
- verifyCookie,
42
+ verifySession,
43
+ type SessionClaims,
42
44
  type SourceClass,
43
45
  } from './auth.ts'
44
46
  import {
@@ -49,12 +51,28 @@ import {
49
51
  serveLoginGet,
50
52
  type LoginPageOptions,
51
53
  } from './login.ts'
52
- import { verifyPassword, type GatewayState } from './state.ts'
54
+ import {
55
+ downstreamResponseHeaders,
56
+ isOwnedPath,
57
+ loginOriginAllowed,
58
+ pathOf,
59
+ requiresLogin,
60
+ sameSiteAllowed,
61
+ sessionCookie,
62
+ upgradeResponseHeaders,
63
+ upstreamRequestHeaders,
64
+ } from './request-policy.ts'
65
+ import {
66
+ isSessionRevoked,
67
+ revokeSession,
68
+ verifyPassword,
69
+ type GatewayState,
70
+ } from './state.ts'
53
71
  import type { UpstreamSession } from './upstream-session.ts'
54
72
 
55
73
  /** Configuration the gateway needs at listen time. */
56
74
  export interface GatewayConfig {
57
- /** Port to bind on 0.0.0.0. */
75
+ /** Port to bind on the unspecified address (dual-stack; see {@link LanGateway.listen}). */
58
76
  gatewayPort: number
59
77
  /** The loopback dsh web server port to forward to. */
60
78
  dshPort: number
@@ -74,24 +92,37 @@ export interface GatewayConfig {
74
92
  classifySource?: (req: http.IncomingMessage) => SourceClass
75
93
  /** Optional shared upstream session relayed onto every forwarded request. */
76
94
  upstreamSession?: UpstreamSession
95
+ /**
96
+ * Called after the gateway revokes a session itself (sign-out), so the plugin
97
+ * can persist a state the gateway changed on its own. The gateway has already
98
+ * installed it locally by then.
99
+ */
100
+ onStateChange?: (state: GatewayState) => void
77
101
  }
78
102
 
79
103
  const DEFAULT_BODY_LIMIT_BYTES = 64 * 1024
80
104
  const LOGIN_ATTEMPTS_LIMIT = 5
81
105
  const LOGIN_ATTEMPTS_WINDOW_MS = 60_000
82
106
 
83
- /** Methods a browser never attaches a CSRF-meaningful body to; safe without an Origin. */
84
- const READ_ONLY_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
85
-
86
- /** Prefixes the gateway owns and must never relay to dsh. */
87
- function isOwnedPath(pathname: string): boolean {
88
- return pathname === '/lan-gateway' || pathname.startsWith('/lan-gateway/')
107
+ /**
108
+ * How long a half-open upstream WebSocket handshake may hang before the
109
+ * gateway gives up on it. Without a deadline the client socket sits in the
110
+ * pending table forever and never learns the upgrade failed — node's http
111
+ * client would wait out its own socket timeout, which is measured in minutes.
112
+ */
113
+ const UPGRADE_HANDSHAKE_TIMEOUT_MS = 15_000
114
+
115
+ /** A pending or established WebSocket, and the gate generation it was admitted under. */
116
+ interface TrackedSocket {
117
+ /** The session that opened it; undefined for a cookie predating per-session ids. */
118
+ sid: string | undefined
119
+ /** The gate generation at admission; a bump retires every socket below it. */
120
+ gate: number
89
121
  }
90
122
 
91
- /** The pathname of a request URL (query string stripped, not decoded). */
92
- function pathOf(url: string): string {
93
- const query = url.indexOf('?')
94
- return query === -1 ? url : url.slice(0, query)
123
+ /** A fresh per-session id: 128 random bits, URL-safe. */
124
+ function newSessionId(): string {
125
+ return randomBytes(16).toString('base64url')
95
126
  }
96
127
 
97
128
  /**
@@ -104,8 +135,26 @@ export class LanGateway {
104
135
  private readonly loginLimiter = new RateLimiter(LOGIN_ATTEMPTS_LIMIT, LOGIN_ATTEMPTS_WINDOW_MS)
105
136
  private state: GatewayState
106
137
  private disposed = false
107
- /** Established WebSockets (upgraded client sockets), torn down on session-epoch change. */
108
- private readonly activeDuplexes = new Set<Duplex>()
138
+ /**
139
+ * Every WebSocket this gateway is responsible for, keyed by the client
140
+ * socket: pending handshakes as well as established ones.
141
+ *
142
+ * A socket outlives the request that authenticated it, so it has to be
143
+ * closable by session: on an epoch bump every socket dies, and on sign-out
144
+ * only that session's. A handshake that is still waiting on the relay or on
145
+ * upstream's 101 is tracked from the moment it passes the gates, not from the
146
+ * moment it is spliced — otherwise a revocation that lands mid-handshake
147
+ * closes the map's contents and then watches the abandoned handshake finish
148
+ * and register itself as live.
149
+ */
150
+ private readonly sockets = new Map<Duplex, TrackedSocket>()
151
+ /**
152
+ * Bumped by every revocation (epoch change, per-session sign-out) and by
153
+ * disposal. A socket is retired when the generation moves past the one it was
154
+ * admitted under, which is what lets a pending handshake be judged by the
155
+ * rules in force when it *completes* rather than when it started.
156
+ */
157
+ private gate = 0
109
158
 
110
159
  constructor(
111
160
  private readonly config: GatewayConfig,
@@ -126,12 +175,23 @@ export class LanGateway {
126
175
  /** Replace the in-memory state; bumps of `sessionEpoch` revoke live sessions and sockets. */
127
176
  setState(state: GatewayState): void {
128
177
  if (state.sessionEpoch !== this.state.sessionEpoch) {
129
- this.destroyActiveDuplexes()
178
+ // An epoch bump retires every session, so every socket goes — including a
179
+ // handshake still waiting upstream, which the gate bump leaves unable to
180
+ // re-admit itself when its 101 arrives.
181
+ this.gate += 1
182
+ this.destroyAllSockets()
130
183
  }
131
184
  this.state = state
132
185
  }
133
186
 
134
- /** Start listening; rejects if the port is already in use. */
187
+ /**
188
+ * Start listening on the configured port. The listener is dual-stack: with
189
+ * no host given, node binds the unspecified IPv6 address `::` — which also
190
+ * accepts IPv4 clients, arriving as `::ffff:a.b.c.d` for the classifier to
191
+ * unwrap — when the host has IPv6, and falls back to `0.0.0.0` when it does
192
+ * not. Binding IPv4 only used to leave every IPv6 client (including `::1`)
193
+ * unable to reach a gateway that classifies them.
194
+ */
135
195
  async listen(): Promise<void> {
136
196
  return new Promise((resolve, reject) => {
137
197
  const onError = (err: Error): void => {
@@ -144,80 +204,105 @@ export class LanGateway {
144
204
  }
145
205
  this.server.once('error', onError)
146
206
  this.server.once('listening', onListening)
147
- this.server.listen(this.config.gatewayPort, '0.0.0.0')
207
+ this.server.listen(this.config.gatewayPort)
148
208
  })
149
209
  }
150
210
 
151
- /** Close the server, drop upgraded sockets, and stop accepting connections. */
211
+ /** The address actually bound, for logs and status (never a claim about it). */
212
+ boundAddress(): string {
213
+ const address = this.server.address()
214
+ if (address === null || typeof address === 'string') return `port ${this.config.gatewayPort}`
215
+ const host = address.family === 'IPv6' ? `[${address.address}]` : address.address
216
+ return `${host}:${address.port}`
217
+ }
218
+
219
+ /** Close the server, drop every socket, and stop accepting connections. */
152
220
  async close(): Promise<void> {
153
221
  if (this.disposed) return
154
222
  this.disposed = true
155
- this.destroyActiveDuplexes()
223
+ this.gate += 1
224
+ this.destroyAllSockets()
156
225
  return new Promise((resolve) => {
157
226
  this.server.close(() => resolve())
158
227
  this.server.closeAllConnections()
159
228
  })
160
229
  }
161
230
 
162
- private destroyActiveDuplexes(): void {
163
- for (const socket of this.activeDuplexes) {
231
+ private destroyAllSockets(): void {
232
+ for (const socket of this.sockets.keys()) {
233
+ socket.destroy()
234
+ }
235
+ this.sockets.clear()
236
+ }
237
+
238
+ /** Close the sockets one session opened, so signing out ends its live streams too. */
239
+ private destroySocketsFor(sid: string): void {
240
+ for (const [socket, tracked] of this.sockets) {
241
+ if (tracked.sid !== sid) continue
242
+ this.sockets.delete(socket)
164
243
  socket.destroy()
165
244
  }
166
- this.activeDuplexes.clear()
167
245
  }
168
246
 
169
- private trackDuplex(socket: Duplex): void {
170
- this.activeDuplexes.add(socket)
247
+ /** Track a socket from admission to close. */
248
+ private trackSocket(socket: Duplex, sid: string | undefined): void {
249
+ this.sockets.set(socket, { sid, gate: this.gate })
171
250
  socket.on('close', () => {
172
- this.activeDuplexes.delete(socket)
251
+ this.sockets.delete(socket)
173
252
  })
174
253
  }
175
254
 
255
+ /** Whether a socket is still tracked, undisposed, and admitted under the current gate. */
256
+ private stillAdmitted(socket: Duplex): boolean {
257
+ if (this.disposed) return false
258
+ const tracked = this.sockets.get(socket)
259
+ return tracked !== undefined && tracked.gate === this.gate
260
+ }
261
+
176
262
  private sourceOf(req: http.IncomingMessage): SourceClass {
177
263
  return this.config.classifySource !== undefined
178
264
  ? this.config.classifySource(req)
179
265
  : classifySource(req.socket.remoteAddress, this.config.lanCidrs)
180
266
  }
181
267
 
182
- /** Parse the session cookie out of a Cookie header. */
183
- private sessionCookie(req: http.IncomingMessage): string | undefined {
184
- const header = req.headers.cookie
185
- if (typeof header !== 'string') return undefined
186
- for (const part of header.split(';')) {
187
- const trimmed = part.trim()
188
- if (trimmed.startsWith(`${this.config.cookieName}=`)) {
189
- return trimmed.slice(this.config.cookieName.length + 1)
190
- }
191
- }
192
- return undefined
193
- }
194
-
195
- /** Whether a request carries a session valid under the current epoch. */
196
- private authorized(req: http.IncomingMessage): boolean {
197
- const cookie = this.sessionCookie(req)
198
- return cookie !== undefined && verifyCookie(
268
+ /**
269
+ * The session a request carries, or undefined when it presents none, presents
270
+ * one that no longer verifies under the current epoch, or presents one whose
271
+ * id has been signed out.
272
+ */
273
+ private session(req: http.IncomingMessage): SessionClaims | undefined {
274
+ const cookie = sessionCookie(req.headers, this.config.cookieName)
275
+ if (cookie === undefined) return undefined
276
+ const claims = verifySession(
199
277
  this.state.cookieSecret,
200
278
  cookie,
201
279
  Date.now(),
202
280
  this.state.sessionEpoch,
203
281
  )
282
+ if (claims === undefined) return undefined
283
+ return isSessionRevoked(this.state, claims.sid) ? undefined : claims
204
284
  }
205
285
 
206
- /** Whether this source must present a gateway session (default: everyone). */
207
- private requiresLogin(source: SourceClass): boolean {
208
- return !(this.config.lanPasswordless && source !== 'internet')
286
+ /** Whether a request carries a session valid under the current epoch. */
287
+ private authorized(req: http.IncomingMessage): boolean {
288
+ return this.session(req) !== undefined
209
289
  }
210
290
 
211
- private serveUnauthorized(res: http.ServerResponse, limited: boolean): void {
291
+ /**
292
+ * Send an unauthorized caller to the login form. The rate limiter's refusal
293
+ * does not come through here: it is answered on the POST itself, where the
294
+ * banner can be rendered without a round trip.
295
+ */
296
+ private serveUnauthorized(res: http.ServerResponse): void {
212
297
  res.writeHead(302, {
213
- location: `${LOGIN_PATH}${limited ? '?limited=1' : ''}`,
298
+ location: LOGIN_PATH,
214
299
  ...this.securityHeaders(),
215
300
  })
216
301
  res.end()
217
302
  }
218
303
 
219
- private serveLoginError(res: http.ServerResponse, message: string): void {
220
- const opts: LoginPageOptions = { error: message }
304
+ private serveLoginError(res: http.ServerResponse, message: string, limited = false): void {
305
+ const opts: LoginPageOptions = limited ? { limited: true } : { error: message }
221
306
  res.writeHead(401, {
222
307
  'content-type': 'text/html; charset=utf-8',
223
308
  'cache-control': 'no-store',
@@ -233,29 +318,12 @@ export class LanGateway {
233
318
  : { 'strict-transport-security': 'max-age=15552000' }
234
319
  }
235
320
 
236
- /**
237
- * The gateway's own cross-site gate, shared by HTTP and WebSocket upgrades
238
- * and applied before any Host/Origin rewriting. Browsers attach Origin to
239
- * state-changing requests and to every WebSocket handshake; reads without an
240
- * Origin (navigations, non-browser clients holding a session) stay allowed.
241
- */
242
- private sameSiteAllowed(req: http.IncomingMessage, upgrade: boolean): boolean {
243
- const headers = req.headers
244
- if (headers['sec-fetch-site'] === 'cross-site') return false
245
- const origin = headers.origin
246
- const host = headers.host
247
- if (origin !== undefined && !originMatchesHost(origin, host)) return false
248
- if (upgrade) return origin !== undefined
249
- if (!READ_ONLY_METHODS.has(req.method ?? 'GET')) return origin !== undefined
250
- return true
251
- }
252
-
253
321
  private sessionSetCookie(value: string, maxAgeSeconds: number): string {
254
322
  const attributes = `Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeSeconds}`
255
323
  return `${this.config.cookieName}=${value}; ${attributes}${this.config.secureCookies ? '; Secure' : ''}`
256
324
  }
257
325
 
258
- /** Handle one HTTP request: anonymous allowlist → owned-path refuse → session gate → same-site gate → relay. */
326
+ /** Handle one HTTP request: login surface → owned-path refuse → session gate → same-site gate → relay. */
259
327
  private async handleHttp(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
260
328
  const url = req.url ?? '/'
261
329
  const pathname = pathOf(url)
@@ -279,12 +347,12 @@ export class LanGateway {
279
347
  return
280
348
  }
281
349
 
282
- if (this.requiresLogin(source) && !this.authorized(req)) {
283
- this.serveUnauthorized(res, false)
350
+ if (requiresLogin(source, this.config.lanPasswordless) && !this.authorized(req)) {
351
+ this.serveUnauthorized(res)
284
352
  return
285
353
  }
286
354
 
287
- if (!this.sameSiteAllowed(req, false)) {
355
+ if (!sameSiteAllowed(req, false)) {
288
356
  res.writeHead(403, this.securityHeaders())
289
357
  res.end('forbidden')
290
358
  return
@@ -295,7 +363,6 @@ export class LanGateway {
295
363
 
296
364
  /** Handle the login GET form / POST submission. */
297
365
  private handleLogin(req: http.IncomingMessage, res: http.ServerResponse): void {
298
- const limited = req.url?.includes('limited=1') ?? false
299
366
  if (req.method === 'GET' || req.method === 'HEAD') {
300
367
  serveLoginGet(res, this.securityHeaders())
301
368
  return
@@ -306,13 +373,23 @@ export class LanGateway {
306
373
  return
307
374
  }
308
375
 
376
+ // Issuing a session is a state change on the same footing as retiring one
377
+ // (see handleLogout), and a cross-site form post spends the victim's source
378
+ // address in the login limiter. Refuse before the limiter so a hostile page
379
+ // cannot drain someone else's budget either.
380
+ if (!loginOriginAllowed(req.headers)) {
381
+ res.writeHead(403, this.securityHeaders())
382
+ res.end('forbidden')
383
+ return
384
+ }
385
+
309
386
  const key = req.socket.remoteAddress ?? 'unknown'
310
387
  if (!this.loginLimiter.allow(key)) {
311
- this.serveLoginError(res, 'Too many attempts — please wait a minute.')
388
+ this.serveLoginError(res, 'Too many attempts — please wait a minute.', true)
312
389
  return
313
390
  }
314
391
 
315
- void readBody(req, DEFAULT_BODY_LIMIT_BYTES, res).then((body) => {
392
+ void readBody(req, DEFAULT_BODY_LIMIT_BYTES, res).then(async (body) => {
316
393
  if (body === undefined) return // response already sent (413/400)
317
394
  let password: string | undefined
318
395
  try {
@@ -321,23 +398,58 @@ export class LanGateway {
321
398
  } catch {
322
399
  password = undefined
323
400
  }
324
- if (password === undefined || !verifyPassword(this.state, password)) {
401
+ // Verify against the state this request started under, and refuse to sign
402
+ // anything if it moved. scrypt takes tens of milliseconds; a password
403
+ // change, a clear, or a secret rotation landing inside that window
404
+ // advances the epoch, and signing the new epoch on the strength of the
405
+ // old password would hand back exactly the session the epoch bump was
406
+ // meant to kill.
407
+ const checked = this.state
408
+ const accepted = password !== undefined && await verifyPassword(checked, password)
409
+ if (this.state !== checked || this.disposed) {
410
+ this.serveLoginError(res, 'Sign-in was interrupted — please try again.')
411
+ return
412
+ }
413
+ if (!accepted) {
325
414
  this.serveLoginError(res, 'Incorrect password.')
326
415
  return
327
416
  }
328
417
  const maxAgeSeconds = this.config.cookieMaxAgeDays * 86_400
329
418
  const expiresMs = Date.now() + maxAgeSeconds * 1000
330
- const cookie = signCookie(this.state.cookieSecret, expiresMs, this.state.sessionEpoch)
419
+ // Every session gets its own id so signing out can retire this one alone.
420
+ const cookie = signCookie(
421
+ this.state.cookieSecret,
422
+ expiresMs,
423
+ this.state.sessionEpoch,
424
+ newSessionId(),
425
+ )
331
426
  res.writeHead(302, {
332
427
  location: '/',
333
428
  ...this.securityHeaders(),
334
429
  'set-cookie': [this.sessionSetCookie(cookie, maxAgeSeconds)],
335
430
  })
336
431
  res.end()
432
+ }).catch(() => {
433
+ // The body reader reports its own failures through the response; this
434
+ // catches the async verification path so it cannot become an unhandled
435
+ // rejection.
436
+ if (!res.headersSent) {
437
+ res.writeHead(500, this.securityHeaders())
438
+ res.end('login failed')
439
+ }
337
440
  })
338
441
  }
339
442
 
340
- /** POST /__logout: sign an immediately-expired cookie and bounce to / . */
443
+ /**
444
+ * POST /__logout: revoke this session and clear the cookie.
445
+ *
446
+ * The session is stateless, so clearing the cookie only stops the browser
447
+ * that ran the sign-out; a copy of the same value held anywhere else would
448
+ * keep working until it expired. Revoking the id in the cookie retires that
449
+ * one session for good, and leaves the account's other sessions — other
450
+ * devices, other browsers — alone. Bumping the session epoch here would be
451
+ * the blunter instrument: it signs out every session there is.
452
+ */
341
453
  private handleLogout(req: http.IncomingMessage, res: http.ServerResponse): void {
342
454
  if (req.method !== 'POST') {
343
455
  res.writeHead(405, { allow: 'POST' })
@@ -345,11 +457,21 @@ export class LanGateway {
345
457
  return
346
458
  }
347
459
  // A logout is a state change: refuse cross-site triggers.
348
- if (!this.sameSiteAllowed(req, false)) {
460
+ if (!sameSiteAllowed(req, false)) {
349
461
  res.writeHead(403, this.securityHeaders())
350
462
  res.end('forbidden')
351
463
  return
352
464
  }
465
+ const claims = this.session(req)
466
+ if (claims?.sid !== undefined) {
467
+ this.state = revokeSession(this.state, claims.sid, claims.exp)
468
+ this.config.onStateChange?.(this.state)
469
+ // Dropping the session's sockets from the map is what retires a handshake
470
+ // it opened mid-flight too: the 101 callback re-checks membership, finds
471
+ // it gone, and closes instead of splicing. The epoch is untouched, so the
472
+ // account's other sessions keep working.
473
+ this.destroySocketsFor(claims.sid)
474
+ }
353
475
  res.writeHead(302, {
354
476
  location: '/',
355
477
  ...this.securityHeaders(),
@@ -358,41 +480,23 @@ export class LanGateway {
358
480
  res.end()
359
481
  }
360
482
 
361
- /** Build the outbound headers: rewrite Host/Origin to the loopback upstream. */
362
- private upstreamHeaders(req: http.IncomingMessage, keepUpgrade: boolean): http.OutgoingHttpHeaders {
363
- const headers: http.OutgoingHttpHeaders = { ...req.headers }
364
- headers.host = `127.0.0.1:${this.config.dshPort}`
365
- if (typeof headers.origin === 'string') {
366
- headers.origin = `http://127.0.0.1:${this.config.dshPort}`
367
- }
368
- // Hop-by-hop headers the gateway must not forward.
369
- delete headers['proxy-connection']
370
- if (!keepUpgrade) {
371
- delete headers.connection
372
- delete headers.upgrade
373
- }
374
- return headers
375
- }
376
-
377
- /** Attach the shared upstream session cookie to the outbound headers, if any. */
378
- private attachUpstreamSession(headers: http.OutgoingHttpHeaders): boolean {
379
- const session = this.config.upstreamSession
380
- if (session === undefined) return false
381
- const cookie = session.peek()
382
- if (cookie === undefined) return false
383
- const existing = headers.cookie
384
- headers.cookie = typeof existing === 'string' && existing !== ''
385
- ? `${existing}; ${cookie}`
386
- : cookie
387
- return true
483
+ /** The shared upstream session's cookie value, if the relay holds one. */
484
+ private async upstreamCookie(): Promise<string | undefined> {
485
+ return this.config.upstreamSession === undefined
486
+ ? undefined
487
+ : this.config.upstreamSession.cookie()
388
488
  }
389
489
 
390
490
  /** Forward an HTTP request to dsh, replaying the shared upstream session. */
391
491
  private async relayHttp(req: http.IncomingMessage, res: http.ServerResponse, url: string): Promise<void> {
392
492
  const session = this.config.upstreamSession
393
- if (session !== undefined) await session.cookie()
394
- const headers = this.upstreamHeaders(req, false)
395
- const attached = this.attachUpstreamSession(headers)
493
+ const relayed = await this.upstreamCookie()
494
+ const headers = upstreamRequestHeaders(req.headers, {
495
+ dshPort: this.config.dshPort,
496
+ keepUpgrade: false,
497
+ upstreamCookie: relayed,
498
+ })
499
+ const attached = relayed !== undefined
396
500
 
397
501
  const proxyReq = http.request({
398
502
  host: '127.0.0.1',
@@ -407,7 +511,7 @@ export class LanGateway {
407
511
  if (attached && session !== undefined && proxyRes.statusCode === 401) {
408
512
  session.invalidate()
409
513
  }
410
- res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers)
514
+ res.writeHead(proxyRes.statusCode ?? 502, downstreamResponseHeaders(proxyRes.headers))
411
515
  proxyRes.pipe(res)
412
516
  })
413
517
  proxyReq.on('error', () => {
@@ -436,7 +540,10 @@ export class LanGateway {
436
540
  return
437
541
  }
438
542
 
439
- if (this.requiresLogin(source) && !this.authorized(req)) {
543
+ // The session is read once: the socket this upgrade ends up holding stays
544
+ // attributable to it, so signing that session out can close the socket.
545
+ const claims = this.session(req)
546
+ if (requiresLogin(source, this.config.lanPasswordless) && claims === undefined) {
440
547
  refuse(401)
441
548
  return
442
549
  }
@@ -444,15 +551,30 @@ export class LanGateway {
444
551
  // Upgrades are state changes that only browsers meaningfully make: require
445
552
  // a same-origin Origin so a cross-site page cannot open a socket that rides
446
553
  // the requester's ambient session.
447
- if (!this.sameSiteAllowed(req, true)) {
554
+ if (!sameSiteAllowed(req, true)) {
448
555
  refuse(403)
449
556
  return
450
557
  }
451
558
 
452
- const session = this.config.upstreamSession
453
- if (session !== undefined) await session.cookie()
454
- const headers = this.upstreamHeaders(req, true)
455
- this.attachUpstreamSession(headers)
559
+ // Own the socket from here, not from the 101: everything below awaits, and
560
+ // an epoch bump, a sign-out or a dispose landing in that window has to be
561
+ // able to reach this handshake.
562
+ this.trackSocket(socket, claims?.sid)
563
+ const retire = (): void => {
564
+ if (!this.sockets.delete(socket)) return
565
+ socket.destroy()
566
+ }
567
+
568
+ const relayed = await this.upstreamCookie()
569
+ if (!this.stillAdmitted(socket)) {
570
+ retire()
571
+ return
572
+ }
573
+ const headers = upstreamRequestHeaders(req.headers, {
574
+ dshPort: this.config.dshPort,
575
+ keepUpgrade: true,
576
+ upstreamCookie: relayed,
577
+ })
456
578
 
457
579
  const proxyReq = http.request({
458
580
  host: '127.0.0.1',
@@ -461,13 +583,34 @@ export class LanGateway {
461
583
  path: url,
462
584
  headers,
463
585
  })
586
+ const timer = setTimeout(() => {
587
+ // Nothing came back in time. Drop both ends rather than leave the client
588
+ // socket parked in the map with no way to learn the handshake failed.
589
+ proxyReq.destroy()
590
+ retire()
591
+ }, UPGRADE_HANDSHAKE_TIMEOUT_MS)
592
+ const settle = (): void => {
593
+ clearTimeout(timer)
594
+ }
595
+
464
596
  proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
465
- this.trackDuplex(socket)
597
+ settle()
598
+ // The gated decision was made before the relay exchange and before
599
+ // upstream answered; re-check it now, because a revocation in between is
600
+ // exactly the case that used to slip through and register a live socket
601
+ // after the sweep had already run.
602
+ if (!this.stillAdmitted(socket)) {
603
+ proxySocket.destroy()
604
+ retire()
605
+ return
606
+ }
466
607
  // node's http client has already consumed the 101 response headers, so
467
608
  // reconstruct them on the client socket before splicing.
468
- const statusLine = `HTTP/1.1 ${proxyRes.statusCode ?? 101} ${proxyRes.statusMessage ?? 'Switching Protocols'}\r\n`
469
- const headerLines = Object.entries(proxyRes.headers)
470
- .map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(', ') : value}\r\n`)
609
+ const status = proxyRes.statusCode ?? 101
610
+ const statusLine = `HTTP/1.1 ${status} ${proxyRes.statusMessage ?? 'Switching Protocols'}\r\n`
611
+ const headerLines = Object.entries(upgradeResponseHeaders(proxyRes.headers))
612
+ .flatMap(([key, value]) => (Array.isArray(value) ? value : [value])
613
+ .map(entry => `${key}: ${entry}\r\n`))
471
614
  .join('')
472
615
  socket.write(`${statusLine}${headerLines}\r\n`)
473
616
  // Forward the client's own head bytes (initial WebSocket frames) to dsh.
@@ -481,7 +624,37 @@ export class LanGateway {
481
624
  socket.on('error', () => proxySocket.destroy())
482
625
  proxySocket.on('error', () => socket.destroy())
483
626
  })
484
- proxyReq.on('error', () => socket.destroy())
627
+
628
+ // A non-101 response is an ordinary HTTP answer to the handshake: upstream
629
+ // refused it (401 after a revocation, 404 for an unknown path, 502 from a
630
+ // proxy). Without this branch node emits neither 'upgrade' nor 'error' and
631
+ // the client socket would sit open forever holding no connection at all.
632
+ proxyReq.on('response', (proxyRes) => {
633
+ settle()
634
+ proxyRes.resume() // drain so the socket can be released
635
+ if (!this.stillAdmitted(socket)) {
636
+ retire()
637
+ return
638
+ }
639
+ if (proxyRes.statusCode === 401 && relayed !== undefined) {
640
+ // Same reading as the HTTP branch: upstream rejected the session we
641
+ // relayed, so drop it. Otherwise a base that only ever sees WebSocket
642
+ // reconnects would keep replaying a dead session until it lapses.
643
+ this.config.upstreamSession?.invalidate()
644
+ }
645
+ const body = `upstream refused the WebSocket upgrade (HTTP ${proxyRes.statusCode ?? 502})`
646
+ socket.write(
647
+ `HTTP/1.1 ${proxyRes.statusCode ?? 502} ${proxyRes.statusMessage ?? 'Upstream Refused'}\r\n`
648
+ + `Connection: close\r\nContent-Type: text/plain; charset=utf-8\r\n`
649
+ + `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`,
650
+ )
651
+ retire()
652
+ })
653
+
654
+ proxyReq.on('error', () => {
655
+ settle()
656
+ retire()
657
+ })
485
658
  proxyReq.end()
486
659
  }
487
660
  }