@riceawa/dsh-lan-gateway 0.5.4 → 0.6.0

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
@@ -15,10 +15,9 @@
15
15
  * or its login/logout paths; those are handled locally or refused.
16
16
  * - Because this gateway rewrites Origin to loopback, dsh's own CSRF fence is
17
17
  * blinded — so the gateway runs its own origin check on every relayed
18
- * request (HTTP and WebSocket upgrade) BEFORE rewriting: reject
19
- * `sec-fetch-site: cross-site`, reject any Origin that does not name the
20
- * gateway authority the browser actually used, and require an Origin on
21
- * 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.
22
21
  * - Against a session-capable dsh base the Host/Origin rewrite alone would
23
22
  * still earn a 401 (dsh no longer trusts a loopback Host; it demands its own
24
23
  * authority-bound session cookie). The gateway therefore relays one shared
@@ -38,7 +37,6 @@ import { randomBytes } from 'node:crypto'
38
37
  import type { Duplex } from 'node:stream'
39
38
  import {
40
39
  classifySource,
41
- originMatchesHost,
42
40
  RateLimiter,
43
41
  signCookie,
44
42
  verifySession,
@@ -53,6 +51,17 @@ import {
53
51
  serveLoginGet,
54
52
  type LoginPageOptions,
55
53
  } from './login.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'
56
65
  import {
57
66
  isSessionRevoked,
58
67
  revokeSession,
@@ -95,100 +104,20 @@ const DEFAULT_BODY_LIMIT_BYTES = 64 * 1024
95
104
  const LOGIN_ATTEMPTS_LIMIT = 5
96
105
  const LOGIN_ATTEMPTS_WINDOW_MS = 60_000
97
106
 
98
- /** Methods a browser never attaches a CSRF-meaningful body to; safe without an Origin. */
99
- const READ_ONLY_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
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
107
  /**
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
-
160
- /** Prefixes the gateway owns and must never relay to dsh. */
161
- function isOwnedPath(pathname: string): boolean {
162
- return pathname === '/lan-gateway' || pathname.startsWith('/lan-gateway/')
163
- }
164
-
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.
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.
183
112
  */
184
- function pathOf(url: string): string {
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
- }
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
192
121
  }
193
122
 
194
123
  /** A fresh per-session id: 128 random bits, URL-safe. */
@@ -207,13 +136,25 @@ export class LanGateway {
207
136
  private state: GatewayState
208
137
  private disposed = false
209
138
  /**
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.
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.
215
156
  */
216
- private readonly activeDuplexes = new Map<Duplex, string | undefined>()
157
+ private gate = 0
217
158
 
218
159
  constructor(
219
160
  private readonly config: GatewayConfig,
@@ -234,7 +175,11 @@ export class LanGateway {
234
175
  /** Replace the in-memory state; bumps of `sessionEpoch` revoke live sessions and sockets. */
235
176
  setState(state: GatewayState): void {
236
177
  if (state.sessionEpoch !== this.state.sessionEpoch) {
237
- 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()
238
183
  }
239
184
  this.state = state
240
185
  }
@@ -271,66 +216,62 @@ export class LanGateway {
271
216
  return `${host}:${address.port}`
272
217
  }
273
218
 
274
- /** Close the server, drop upgraded sockets, and stop accepting connections. */
219
+ /** Close the server, drop every socket, and stop accepting connections. */
275
220
  async close(): Promise<void> {
276
221
  if (this.disposed) return
277
222
  this.disposed = true
278
- this.destroyActiveDuplexes()
223
+ this.gate += 1
224
+ this.destroyAllSockets()
279
225
  return new Promise((resolve) => {
280
226
  this.server.close(() => resolve())
281
227
  this.server.closeAllConnections()
282
228
  })
283
229
  }
284
230
 
285
- private destroyActiveDuplexes(): void {
286
- for (const socket of this.activeDuplexes.keys()) {
231
+ private destroyAllSockets(): void {
232
+ for (const socket of this.sockets.keys()) {
287
233
  socket.destroy()
288
234
  }
289
- this.activeDuplexes.clear()
235
+ this.sockets.clear()
290
236
  }
291
237
 
292
238
  /** 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)
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)
297
243
  socket.destroy()
298
244
  }
299
245
  }
300
246
 
301
- private trackDuplex(socket: Duplex, sid: string | undefined): void {
302
- this.activeDuplexes.set(socket, sid)
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 })
303
250
  socket.on('close', () => {
304
- this.activeDuplexes.delete(socket)
251
+ this.sockets.delete(socket)
305
252
  })
306
253
  }
307
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
+
308
262
  private sourceOf(req: http.IncomingMessage): SourceClass {
309
263
  return this.config.classifySource !== undefined
310
264
  ? this.config.classifySource(req)
311
265
  : classifySource(req.socket.remoteAddress, this.config.lanCidrs)
312
266
  }
313
267
 
314
- /** Parse the session cookie out of a Cookie header. */
315
- private sessionCookie(req: http.IncomingMessage): string | undefined {
316
- const header = req.headers.cookie
317
- if (typeof header !== 'string') return undefined
318
- for (const part of header.split(';')) {
319
- const trimmed = part.trim()
320
- if (trimmed.startsWith(`${this.config.cookieName}=`)) {
321
- return trimmed.slice(this.config.cookieName.length + 1)
322
- }
323
- }
324
- return undefined
325
- }
326
-
327
268
  /**
328
269
  * The session a request carries, or undefined when it presents none, presents
329
270
  * one that no longer verifies under the current epoch, or presents one whose
330
271
  * id has been signed out.
331
272
  */
332
273
  private session(req: http.IncomingMessage): SessionClaims | undefined {
333
- const cookie = this.sessionCookie(req)
274
+ const cookie = sessionCookie(req.headers, this.config.cookieName)
334
275
  if (cookie === undefined) return undefined
335
276
  const claims = verifySession(
336
277
  this.state.cookieSecret,
@@ -347,21 +288,21 @@ export class LanGateway {
347
288
  return this.session(req) !== undefined
348
289
  }
349
290
 
350
- /** Whether this source must present a gateway session (default: everyone). */
351
- private requiresLogin(source: SourceClass): boolean {
352
- return !(this.config.lanPasswordless && source !== 'internet')
353
- }
354
-
355
- 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 {
356
297
  res.writeHead(302, {
357
- location: `${LOGIN_PATH}${limited ? '?limited=1' : ''}`,
298
+ location: LOGIN_PATH,
358
299
  ...this.securityHeaders(),
359
300
  })
360
301
  res.end()
361
302
  }
362
303
 
363
- private serveLoginError(res: http.ServerResponse, message: string): void {
364
- 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 }
365
306
  res.writeHead(401, {
366
307
  'content-type': 'text/html; charset=utf-8',
367
308
  'cache-control': 'no-store',
@@ -377,29 +318,12 @@ export class LanGateway {
377
318
  : { 'strict-transport-security': 'max-age=15552000' }
378
319
  }
379
320
 
380
- /**
381
- * The gateway's own cross-site gate, shared by HTTP and WebSocket upgrades
382
- * and applied before any Host/Origin rewriting. Browsers attach Origin to
383
- * state-changing requests and to every WebSocket handshake; reads without an
384
- * Origin (navigations, non-browser clients holding a session) stay allowed.
385
- */
386
- private sameSiteAllowed(req: http.IncomingMessage, upgrade: boolean): boolean {
387
- const headers = req.headers
388
- if (headers['sec-fetch-site'] === 'cross-site') return false
389
- const origin = headers.origin
390
- const host = headers.host
391
- if (origin !== undefined && !originMatchesHost(origin, host)) return false
392
- if (upgrade) return origin !== undefined
393
- if (!READ_ONLY_METHODS.has(req.method ?? 'GET')) return origin !== undefined
394
- return true
395
- }
396
-
397
321
  private sessionSetCookie(value: string, maxAgeSeconds: number): string {
398
322
  const attributes = `Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeSeconds}`
399
323
  return `${this.config.cookieName}=${value}; ${attributes}${this.config.secureCookies ? '; Secure' : ''}`
400
324
  }
401
325
 
402
- /** 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. */
403
327
  private async handleHttp(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
404
328
  const url = req.url ?? '/'
405
329
  const pathname = pathOf(url)
@@ -423,12 +347,12 @@ export class LanGateway {
423
347
  return
424
348
  }
425
349
 
426
- if (this.requiresLogin(source) && !this.authorized(req)) {
427
- this.serveUnauthorized(res, false)
350
+ if (requiresLogin(source, this.config.lanPasswordless) && !this.authorized(req)) {
351
+ this.serveUnauthorized(res)
428
352
  return
429
353
  }
430
354
 
431
- if (!this.sameSiteAllowed(req, false)) {
355
+ if (!sameSiteAllowed(req, false)) {
432
356
  res.writeHead(403, this.securityHeaders())
433
357
  res.end('forbidden')
434
358
  return
@@ -439,7 +363,6 @@ export class LanGateway {
439
363
 
440
364
  /** Handle the login GET form / POST submission. */
441
365
  private handleLogin(req: http.IncomingMessage, res: http.ServerResponse): void {
442
- const limited = req.url?.includes('limited=1') ?? false
443
366
  if (req.method === 'GET' || req.method === 'HEAD') {
444
367
  serveLoginGet(res, this.securityHeaders())
445
368
  return
@@ -450,9 +373,19 @@ export class LanGateway {
450
373
  return
451
374
  }
452
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
+
453
386
  const key = req.socket.remoteAddress ?? 'unknown'
454
387
  if (!this.loginLimiter.allow(key)) {
455
- this.serveLoginError(res, 'Too many attempts — please wait a minute.')
388
+ this.serveLoginError(res, 'Too many attempts — please wait a minute.', true)
456
389
  return
457
390
  }
458
391
 
@@ -465,7 +398,18 @@ export class LanGateway {
465
398
  } catch {
466
399
  password = undefined
467
400
  }
468
- const accepted = password !== undefined && await 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
+ }
469
413
  if (!accepted) {
470
414
  this.serveLoginError(res, 'Incorrect password.')
471
415
  return
@@ -513,7 +457,7 @@ export class LanGateway {
513
457
  return
514
458
  }
515
459
  // A logout is a state change: refuse cross-site triggers.
516
- if (!this.sameSiteAllowed(req, false)) {
460
+ if (!sameSiteAllowed(req, false)) {
517
461
  res.writeHead(403, this.securityHeaders())
518
462
  res.end('forbidden')
519
463
  return
@@ -522,7 +466,11 @@ export class LanGateway {
522
466
  if (claims?.sid !== undefined) {
523
467
  this.state = revokeSession(this.state, claims.sid, claims.exp)
524
468
  this.config.onStateChange?.(this.state)
525
- this.destroyDuplexesFor(claims.sid)
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)
526
474
  }
527
475
  res.writeHead(302, {
528
476
  location: '/',
@@ -532,74 +480,23 @@ export class LanGateway {
532
480
  res.end()
533
481
  }
534
482
 
535
- /** Build the outbound headers: rewrite Host/Origin to the loopback upstream. */
536
- private upstreamHeaders(req: http.IncomingMessage, keepUpgrade: boolean): http.OutgoingHttpHeaders {
537
- const headers: http.OutgoingHttpHeaders = { ...req.headers }
538
- headers.host = `127.0.0.1:${this.config.dshPort}`
539
- if (typeof headers.origin === 'string') {
540
- headers.origin = `http://127.0.0.1:${this.config.dshPort}`
541
- }
542
- // Hop-by-hop headers the gateway must not forward.
543
- delete headers['proxy-connection']
544
- if (!keepUpgrade) {
545
- delete headers.connection
546
- delete headers.upgrade
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
- }
556
- return headers
557
- }
558
-
559
- /** Attach the shared upstream session cookie to the outbound headers, if any. */
560
- private attachUpstreamSession(headers: http.OutgoingHttpHeaders): boolean {
561
- const session = this.config.upstreamSession
562
- if (session === undefined) return false
563
- const cookie = session.peek()
564
- if (cookie === undefined) return false
565
- const existing = headers.cookie
566
- headers.cookie = typeof existing === 'string' && existing !== ''
567
- ? `${existing}; ${cookie}`
568
- : cookie
569
- return true
570
- }
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
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()
595
488
  }
596
489
 
597
490
  /** Forward an HTTP request to dsh, replaying the shared upstream session. */
598
491
  private async relayHttp(req: http.IncomingMessage, res: http.ServerResponse, url: string): Promise<void> {
599
492
  const session = this.config.upstreamSession
600
- if (session !== undefined) await session.cookie()
601
- const headers = this.upstreamHeaders(req, false)
602
- 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
603
500
 
604
501
  const proxyReq = http.request({
605
502
  host: '127.0.0.1',
@@ -614,7 +511,7 @@ export class LanGateway {
614
511
  if (attached && session !== undefined && proxyRes.statusCode === 401) {
615
512
  session.invalidate()
616
513
  }
617
- res.writeHead(proxyRes.statusCode ?? 502, this.downstreamHeaders(proxyRes.headers))
514
+ res.writeHead(proxyRes.statusCode ?? 502, downstreamResponseHeaders(proxyRes.headers))
618
515
  proxyRes.pipe(res)
619
516
  })
620
517
  proxyReq.on('error', () => {
@@ -646,7 +543,7 @@ export class LanGateway {
646
543
  // The session is read once: the socket this upgrade ends up holding stays
647
544
  // attributable to it, so signing that session out can close the socket.
648
545
  const claims = this.session(req)
649
- if (this.requiresLogin(source) && claims === undefined) {
546
+ if (requiresLogin(source, this.config.lanPasswordless) && claims === undefined) {
650
547
  refuse(401)
651
548
  return
652
549
  }
@@ -654,15 +551,30 @@ export class LanGateway {
654
551
  // Upgrades are state changes that only browsers meaningfully make: require
655
552
  // a same-origin Origin so a cross-site page cannot open a socket that rides
656
553
  // the requester's ambient session.
657
- if (!this.sameSiteAllowed(req, true)) {
554
+ if (!sameSiteAllowed(req, true)) {
658
555
  refuse(403)
659
556
  return
660
557
  }
661
558
 
662
- const session = this.config.upstreamSession
663
- if (session !== undefined) await session.cookie()
664
- const headers = this.upstreamHeaders(req, true)
665
- 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
+ })
666
578
 
667
579
  const proxyReq = http.request({
668
580
  host: '127.0.0.1',
@@ -671,13 +583,34 @@ export class LanGateway {
671
583
  path: url,
672
584
  headers,
673
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
+
674
596
  proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
675
- this.trackDuplex(socket, claims?.sid)
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
+ }
676
607
  // node's http client has already consumed the 101 response headers, so
677
608
  // reconstruct them on the client socket before splicing.
678
- const statusLine = `HTTP/1.1 ${proxyRes.statusCode ?? 101} ${proxyRes.statusMessage ?? 'Switching Protocols'}\r\n`
679
- const headerLines = Object.entries(proxyRes.headers)
680
- .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`))
681
614
  .join('')
682
615
  socket.write(`${statusLine}${headerLines}\r\n`)
683
616
  // Forward the client's own head bytes (initial WebSocket frames) to dsh.
@@ -691,7 +624,37 @@ export class LanGateway {
691
624
  socket.on('error', () => proxySocket.destroy())
692
625
  proxySocket.on('error', () => socket.destroy())
693
626
  })
694
- 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
+ })
695
658
  proxyReq.end()
696
659
  }
697
660
  }