@riceawa/dsh-lan-gateway 0.3.0 → 0.5.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
@@ -1,16 +1,31 @@
1
1
  /**
2
- * The reverse-proxy gateway: a `node:http` server bound to `0.0.0.0` that
3
- * forwards every request to the loopback dsh web server, rewriting Host and
4
- * Origin so the dsh `/api` trust fence (which only trusts loopback) passes.
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.
5
4
  *
6
- * Security model:
5
+ * Security model (post-QVD / session-base):
7
6
  * - Source is classified from `socket.remoteAddress` only (never
8
- * `X-Forwarded-For`). LAN/loopback sources are proxied without a password;
9
- * anything else must present a valid signed cookie or complete the login.
7
+ * `X-Forwarded-For`). Classification alone grants nothing: by default every
8
+ * source — loopback, LAN, internet — must present a valid gateway session.
9
+ * `lanPasswordless` (an explicit opt-in, false by default) is the one way a
10
+ * LAN/loopback source skips the gateway login, and it is only ever allowed
11
+ * against a session-capable dsh base (enforced by the plugin, which owns the
12
+ * fail-closed guard).
13
+ * - The gateway never forwards its own management surface (`/lan-gateway/*`)
14
+ * or its login/logout paths; those are handled locally or refused.
10
15
  * - Because this gateway rewrites Origin to loopback, dsh's own CSRF fence is
11
- * blinded — so the gateway runs its own origin check on `/api*` requests
12
- * BEFORE rewriting (reject `sec-fetch-site: cross-site` and any Origin that
13
- * does not match the gateway authority the browser actually used).
16
+ * 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.
21
+ * - Against a session-capable dsh base the Host/Origin rewrite alone would
22
+ * still earn a 401 (dsh no longer trusts a loopback Host; it demands its own
23
+ * authority-bound session cookie). The gateway therefore relays one shared
24
+ * upstream session acquired through the launch-token exchange and replays it
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.
14
29
  *
15
30
  * @module @riceawa/dsh-lan-gateway/gateway
16
31
  */
@@ -20,20 +35,22 @@ import https from 'node:https'
20
35
  import type { Duplex } from 'node:stream'
21
36
  import {
22
37
  classifySource,
38
+ originMatchesHost,
23
39
  RateLimiter,
24
40
  signCookie,
25
41
  verifyCookie,
26
42
  type SourceClass,
27
43
  } from './auth.ts'
28
44
  import {
29
- COOKIE_NAME,
30
45
  LOGIN_PATH,
46
+ LOGOUT_PATH,
31
47
  readBody,
32
48
  renderLoginPage,
33
49
  serveLoginGet,
34
50
  type LoginPageOptions,
35
51
  } from './login.ts'
36
52
  import { verifyPassword, type GatewayState } from './state.ts'
53
+ import type { UpstreamSession } from './upstream-session.ts'
37
54
 
38
55
  /** Configuration the gateway needs at listen time. */
39
56
  export interface GatewayConfig {
@@ -41,22 +58,42 @@ export interface GatewayConfig {
41
58
  gatewayPort: number
42
59
  /** The loopback dsh web server port to forward to. */
43
60
  dshPort: number
44
- /** LAN CIDRs treated as password-free. */
61
+ /** LAN CIDRs that may be treated as trusted (descriptive; see `lanPasswordless`). */
45
62
  lanCidrs: readonly string[]
46
- /** Whether non-LAN sources require a password. */
47
- authRequired: boolean
63
+ /** Whether LAN/loopback sources may skip the gateway login (explicit opt-in). */
64
+ lanPasswordless: boolean
48
65
  /** Cookie lifetime in days. */
49
66
  cookieMaxAgeDays: number
50
67
  /** Cookie name. */
51
68
  cookieName: string
52
- /** PEM cert/key material; when present the listener speaks HTTPS. */
69
+ /** Whether the ingress is encrypted (self TLS or a declared trusted terminator); adds `Secure` to cookies. */
70
+ secureCookies: boolean
71
+ /** PEM cert/key material; when present the listener speaks HTTPS (and sends HSTS). */
53
72
  tls?: { cert: string; key: string }
73
+ /** Optional injectable source classifier (integration tests emulate LAN/internet). */
74
+ classifySource?: (req: http.IncomingMessage) => SourceClass
75
+ /** Optional shared upstream session relayed onto every forwarded request. */
76
+ upstreamSession?: UpstreamSession
54
77
  }
55
78
 
56
79
  const DEFAULT_BODY_LIMIT_BYTES = 64 * 1024
57
80
  const LOGIN_ATTEMPTS_LIMIT = 5
58
81
  const LOGIN_ATTEMPTS_WINDOW_MS = 60_000
59
82
 
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/')
89
+ }
90
+
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)
95
+ }
96
+
60
97
  /**
61
98
  * The running gateway: owns the HTTP server and the auth state needed per
62
99
  * request. Created by the plugin on enable; torn down by the plugin on
@@ -67,8 +104,13 @@ export class LanGateway {
67
104
  private readonly loginLimiter = new RateLimiter(LOGIN_ATTEMPTS_LIMIT, LOGIN_ATTEMPTS_WINDOW_MS)
68
105
  private state: GatewayState
69
106
  private disposed = false
107
+ /** Established WebSockets (upgraded client sockets), torn down on session-epoch change. */
108
+ private readonly activeDuplexes = new Set<Duplex>()
70
109
 
71
- constructor(private readonly config: GatewayConfig, state: GatewayState) {
110
+ constructor(
111
+ private readonly config: GatewayConfig,
112
+ state: GatewayState,
113
+ ) {
72
114
  this.state = state
73
115
  const handle = (req: http.IncomingMessage, res: http.ServerResponse): void => {
74
116
  void this.handleHttp(req, res)
@@ -81,8 +123,11 @@ export class LanGateway {
81
123
  })
82
124
  }
83
125
 
84
- /** Replace the in-memory state (e.g. after a password change). */
126
+ /** Replace the in-memory state; bumps of `sessionEpoch` revoke live sessions and sockets. */
85
127
  setState(state: GatewayState): void {
128
+ if (state.sessionEpoch !== this.state.sessionEpoch) {
129
+ this.destroyActiveDuplexes()
130
+ }
86
131
  this.state = state
87
132
  }
88
133
 
@@ -103,18 +148,35 @@ export class LanGateway {
103
148
  })
104
149
  }
105
150
 
106
- /** Close the server and stop accepting connections. */
151
+ /** Close the server, drop upgraded sockets, and stop accepting connections. */
107
152
  async close(): Promise<void> {
108
153
  if (this.disposed) return
109
154
  this.disposed = true
155
+ this.destroyActiveDuplexes()
110
156
  return new Promise((resolve) => {
111
157
  this.server.close(() => resolve())
112
158
  this.server.closeAllConnections()
113
159
  })
114
160
  }
115
161
 
116
- private sourceClass(req: http.IncomingMessage): SourceClass {
117
- return classifySource(req.socket.remoteAddress, this.config.lanCidrs)
162
+ private destroyActiveDuplexes(): void {
163
+ for (const socket of this.activeDuplexes) {
164
+ socket.destroy()
165
+ }
166
+ this.activeDuplexes.clear()
167
+ }
168
+
169
+ private trackDuplex(socket: Duplex): void {
170
+ this.activeDuplexes.add(socket)
171
+ socket.on('close', () => {
172
+ this.activeDuplexes.delete(socket)
173
+ })
174
+ }
175
+
176
+ private sourceOf(req: http.IncomingMessage): SourceClass {
177
+ return this.config.classifySource !== undefined
178
+ ? this.config.classifySource(req)
179
+ : classifySource(req.socket.remoteAddress, this.config.lanCidrs)
118
180
  }
119
181
 
120
182
  /** Parse the session cookie out of a Cookie header. */
@@ -130,10 +192,20 @@ export class LanGateway {
130
192
  return undefined
131
193
  }
132
194
 
133
- /** Whether a request carries a valid session for its source. */
195
+ /** Whether a request carries a session valid under the current epoch. */
134
196
  private authorized(req: http.IncomingMessage): boolean {
135
197
  const cookie = this.sessionCookie(req)
136
- return cookie !== undefined && verifyCookie(this.state.cookieSecret, cookie, Date.now())
198
+ return cookie !== undefined && verifyCookie(
199
+ this.state.cookieSecret,
200
+ cookie,
201
+ Date.now(),
202
+ this.state.sessionEpoch,
203
+ )
204
+ }
205
+
206
+ /** Whether this source must present a gateway session (default: everyone). */
207
+ private requiresLogin(source: SourceClass): boolean {
208
+ return !(this.config.lanPasswordless && source !== 'internet')
137
209
  }
138
210
 
139
211
  private serveUnauthorized(res: http.ServerResponse, limited: boolean): void {
@@ -154,58 +226,71 @@ export class LanGateway {
154
226
  res.end(renderLoginPage(opts))
155
227
  }
156
228
 
157
- /** HSTS when the listener is HTTPS (never sent on plain HTTP). */
229
+ /** HSTS when the listener itself is HTTPS (never sent on plain HTTP). */
158
230
  private securityHeaders(): http.OutgoingHttpHeaders {
159
231
  return this.config.tls === undefined
160
232
  ? {}
161
233
  : { 'strict-transport-security': 'max-age=15552000' }
162
234
  }
163
235
 
164
- /** Handle one HTTP request: auth gate → CSRF fence → forward. */
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
+ private sessionSetCookie(value: string, maxAgeSeconds: number): string {
254
+ const attributes = `Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeSeconds}`
255
+ return `${this.config.cookieName}=${value}; ${attributes}${this.config.secureCookies ? '; Secure' : ''}`
256
+ }
257
+
258
+ /** Handle one HTTP request: anonymous allowlist → owned-path refuse → session gate → same-site gate → relay. */
165
259
  private async handleHttp(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
166
- const source = this.sourceClass(req)
167
260
  const url = req.url ?? '/'
168
- const pathname = url.split('?')[0] ?? '/'
261
+ const pathname = pathOf(url)
262
+ const source = this.sourceOf(req)
169
263
 
170
264
  if (pathname === LOGIN_PATH) {
171
265
  this.handleLogin(req, res)
172
266
  return
173
267
  }
174
-
175
- if (source === 'internet' && this.config.authRequired) {
176
- if (!this.authorized(req)) {
177
- this.serveUnauthorized(res, false)
178
- return
179
- }
268
+ if (pathname === LOGOUT_PATH) {
269
+ this.handleLogout(req, res)
270
+ return
180
271
  }
181
272
 
182
- // CSRF fence for /api before any rewriting (see module docs).
183
- if (pathname === '/api' || pathname.startsWith('/api/')) {
184
- if (!this.passesCsrfFence(req)) {
185
- res.writeHead(403, this.securityHeaders())
186
- res.end('forbidden')
187
- return
188
- }
273
+ // The gateway's own management surface never reaches dsh: an unauthenticated
274
+ // remote request must not be able to touch the loopback-only config route by
275
+ // having the gateway rewrite Host to loopback for it.
276
+ if (isOwnedPath(pathname)) {
277
+ res.writeHead(403, this.securityHeaders())
278
+ res.end('forbidden')
279
+ return
189
280
  }
190
281
 
191
- this.forward(req, res, url)
192
- }
282
+ if (this.requiresLogin(source) && !this.authorized(req)) {
283
+ this.serveUnauthorized(res, false)
284
+ return
285
+ }
193
286
 
194
- /** Reject cross-site API traffic: the gateway's own origin check. */
195
- private passesCsrfFence(req: http.IncomingMessage): boolean {
196
- const headers = req.headers
197
- if (headers['sec-fetch-site'] === 'cross-site') return false
198
- const origin = headers.origin
199
- if (origin === undefined) return true
200
- try {
201
- const originHost = new URL(origin).host
202
- const requestHost = typeof headers.host === 'string' ? headers.host : ''
203
- // Compare with the gateway authority the browser actually used; a
204
- // browser always fills Host from the URL it loaded.
205
- return originHost === requestHost || originHost === stripDefaultPort(requestHost)
206
- } catch {
207
- return false
287
+ if (!this.sameSiteAllowed(req, false)) {
288
+ res.writeHead(403, this.securityHeaders())
289
+ res.end('forbidden')
290
+ return
208
291
  }
292
+
293
+ await this.relayHttp(req, res, url)
209
294
  }
210
295
 
211
296
  /** Handle the login GET form / POST submission. */
@@ -216,7 +301,7 @@ export class LanGateway {
216
301
  return
217
302
  }
218
303
  if (req.method !== 'POST') {
219
- res.writeHead(405, { allow: 'GET, POST' })
304
+ res.writeHead(405, { allow: 'GET, HEAD, POST' })
220
305
  res.end()
221
306
  return
222
307
  }
@@ -240,22 +325,41 @@ export class LanGateway {
240
325
  this.serveLoginError(res, 'Incorrect password.')
241
326
  return
242
327
  }
243
- const expiresMs = Date.now() + this.config.cookieMaxAgeDays * 86_400_000
244
- const cookie = signCookie(this.state.cookieSecret, expiresMs)
245
- const secure = this.config.tls !== undefined ? '; Secure' : ''
328
+ const maxAgeSeconds = this.config.cookieMaxAgeDays * 86_400
329
+ const expiresMs = Date.now() + maxAgeSeconds * 1000
330
+ const cookie = signCookie(this.state.cookieSecret, expiresMs, this.state.sessionEpoch)
246
331
  res.writeHead(302, {
247
332
  location: '/',
248
333
  ...this.securityHeaders(),
249
- 'set-cookie': [
250
- `${this.config.cookieName}=${cookie}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${this.config.cookieMaxAgeDays * 86_400}${secure}`,
251
- ],
334
+ 'set-cookie': [this.sessionSetCookie(cookie, maxAgeSeconds)],
252
335
  })
253
336
  res.end()
254
337
  })
255
338
  }
256
339
 
257
- /** Forward an HTTP request to dsh, rewriting Host/Origin to loopback. */
258
- private forward(req: http.IncomingMessage, res: http.ServerResponse, url: string): void {
340
+ /** POST /__logout: sign an immediately-expired cookie and bounce to / . */
341
+ private handleLogout(req: http.IncomingMessage, res: http.ServerResponse): void {
342
+ if (req.method !== 'POST') {
343
+ res.writeHead(405, { allow: 'POST' })
344
+ res.end()
345
+ return
346
+ }
347
+ // A logout is a state change: refuse cross-site triggers.
348
+ if (!this.sameSiteAllowed(req, false)) {
349
+ res.writeHead(403, this.securityHeaders())
350
+ res.end('forbidden')
351
+ return
352
+ }
353
+ res.writeHead(302, {
354
+ location: '/',
355
+ ...this.securityHeaders(),
356
+ 'set-cookie': [this.sessionSetCookie('', 0)],
357
+ })
358
+ res.end()
359
+ }
360
+
361
+ /** Build the outbound headers: rewrite Host/Origin to the loopback upstream. */
362
+ private upstreamHeaders(req: http.IncomingMessage, keepUpgrade: boolean): http.OutgoingHttpHeaders {
259
363
  const headers: http.OutgoingHttpHeaders = { ...req.headers }
260
364
  headers.host = `127.0.0.1:${this.config.dshPort}`
261
365
  if (typeof headers.origin === 'string') {
@@ -263,7 +367,32 @@ export class LanGateway {
263
367
  }
264
368
  // Hop-by-hop headers the gateway must not forward.
265
369
  delete headers['proxy-connection']
266
- delete headers.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
388
+ }
389
+
390
+ /** Forward an HTTP request to dsh, replaying the shared upstream session. */
391
+ private async relayHttp(req: http.IncomingMessage, res: http.ServerResponse, url: string): Promise<void> {
392
+ 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)
267
396
 
268
397
  const proxyReq = http.request({
269
398
  host: '127.0.0.1',
@@ -272,6 +401,12 @@ export class LanGateway {
272
401
  path: url,
273
402
  headers,
274
403
  }, (proxyRes) => {
404
+ // A 401 while we relayed an upstream session means upstream revoked it
405
+ // (secret/epoch change on its side): drop our copy so the next request
406
+ // re-acquires through the launch-token exchange.
407
+ if (attached && session !== undefined && proxyRes.statusCode === 401) {
408
+ session.invalidate()
409
+ }
275
410
  res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers)
276
411
  proxyRes.pipe(res)
277
412
  })
@@ -284,34 +419,50 @@ export class LanGateway {
284
419
  req.pipe(proxyReq)
285
420
  }
286
421
 
287
- /** Forward a WebSocket upgrade, splicing the raw duplex through to dsh. */
288
- private handleUpgrade(req: http.IncomingMessage, socket: Duplex, head: Buffer): void {
289
- const source = this.sourceClass(req)
290
- if (source === 'internet' && this.config.authRequired && !this.authorized(req)) {
291
- socket.write(
292
- 'HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n',
293
- )
422
+ /** Forward a WebSocket upgrade through the same gates, splicing the duplex to dsh. */
423
+ private async handleUpgrade(req: http.IncomingMessage, socket: Duplex, head: Buffer): Promise<void> {
424
+ const url = req.url ?? '/'
425
+ const pathname = pathOf(url)
426
+ const source = this.sourceOf(req)
427
+
428
+ const refuse = (status: number): void => {
429
+ socket.write(`HTTP/1.1 ${status} ${status === 401 ? 'Unauthorized' : 'Forbidden'}\r\nConnection: close\r\n\r\n`)
294
430
  socket.destroy()
431
+ }
432
+
433
+ // Login/logout and the gateway's own surface are not upgrade targets.
434
+ if (pathname === LOGIN_PATH || pathname === LOGOUT_PATH || isOwnedPath(pathname)) {
435
+ refuse(403)
295
436
  return
296
437
  }
297
438
 
298
- const headers: http.OutgoingHttpHeaders = { ...req.headers }
299
- headers.host = `127.0.0.1:${this.config.dshPort}`
300
- if (typeof headers.origin === 'string') {
301
- headers.origin = `http://127.0.0.1:${this.config.dshPort}`
439
+ if (this.requiresLogin(source) && !this.authorized(req)) {
440
+ refuse(401)
441
+ return
442
+ }
443
+
444
+ // Upgrades are state changes that only browsers meaningfully make: require
445
+ // a same-origin Origin so a cross-site page cannot open a socket that rides
446
+ // the requester's ambient session.
447
+ if (!this.sameSiteAllowed(req, true)) {
448
+ refuse(403)
449
+ return
302
450
  }
303
- // Unlike the plain-HTTP path, keep Connection: Upgrade / Upgrade: websocket
304
- // so dsh answers with 101 and node's client emits 'upgrade'.
305
- delete headers['proxy-connection']
451
+
452
+ const session = this.config.upstreamSession
453
+ if (session !== undefined) await session.cookie()
454
+ const headers = this.upstreamHeaders(req, true)
455
+ this.attachUpstreamSession(headers)
306
456
 
307
457
  const proxyReq = http.request({
308
458
  host: '127.0.0.1',
309
459
  port: this.config.dshPort,
310
460
  method: 'GET',
311
- path: req.url ?? '/',
461
+ path: url,
312
462
  headers,
313
463
  })
314
464
  proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
465
+ this.trackDuplex(socket)
315
466
  // node's http client has already consumed the 101 response headers, so
316
467
  // reconstruct them on the client socket before splicing.
317
468
  const statusLine = `HTTP/1.1 ${proxyRes.statusCode ?? 101} ${proxyRes.statusMessage ?? 'Switching Protocols'}\r\n`
@@ -334,10 +485,3 @@ export class LanGateway {
334
485
  proxyReq.end()
335
486
  }
336
487
  }
337
-
338
- /** Strip an explicit default port from a Host authority, if present. */
339
- function stripDefaultPort(host: string): string {
340
- const parsed = /^(.+?)(?::(\d+))?$/.exec(host)
341
- if (parsed?.[2] === '80' || parsed?.[2] === '443') return parsed[1]!
342
- return host
343
- }