@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/README.md +37 -21
- package/lib/client.js +190 -146
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +129 -8
- package/lib/index.js +1424 -968
- package/package.json +26 -14
- package/src/auth.ts +7 -14
- package/src/client/index.ts +39 -29
- package/src/client/lan-gateway-card.tsx +93 -145
- package/src/config-fields.ts +137 -0
- package/src/gateway.ts +203 -240
- package/src/index.ts +401 -157
- package/src/login.ts +1 -17
- package/src/request-policy.ts +315 -0
- package/src/state.ts +16 -5
- package/src/tls.ts +23 -0
- package/src/upstream-session.ts +24 -8
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
|
|
19
|
-
* `
|
|
20
|
-
*
|
|
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
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
286
|
-
for (const socket of this.
|
|
231
|
+
private destroyAllSockets(): void {
|
|
232
|
+
for (const socket of this.sockets.keys()) {
|
|
287
233
|
socket.destroy()
|
|
288
234
|
}
|
|
289
|
-
this.
|
|
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
|
|
294
|
-
for (const [socket,
|
|
295
|
-
if (
|
|
296
|
-
this.
|
|
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
|
-
|
|
302
|
-
|
|
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.
|
|
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 =
|
|
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
|
-
/**
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
private serveUnauthorized(res: http.ServerResponse
|
|
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:
|
|
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:
|
|
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 (
|
|
427
|
-
this.serveUnauthorized(res
|
|
350
|
+
if (requiresLogin(source, this.config.lanPasswordless) && !this.authorized(req)) {
|
|
351
|
+
this.serveUnauthorized(res)
|
|
428
352
|
return
|
|
429
353
|
}
|
|
430
354
|
|
|
431
|
-
if (!
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
/**
|
|
536
|
-
private
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
601
|
-
const headers =
|
|
602
|
-
|
|
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,
|
|
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 (
|
|
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 (!
|
|
554
|
+
if (!sameSiteAllowed(req, true)) {
|
|
658
555
|
refuse(403)
|
|
659
556
|
return
|
|
660
557
|
}
|
|
661
558
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
this.
|
|
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
|
-
|
|
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
|
|
679
|
-
const
|
|
680
|
-
|
|
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
|
-
|
|
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
|
}
|