@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/README.md +55 -42
- package/lib/client.js +157 -125
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +57 -7
- package/lib/index.js +1249 -554
- package/package.json +1 -1
- package/skills/lan-gateway.md +8 -4
- package/src/auth.ts +91 -26
- package/src/client/lan-gateway-card.tsx +61 -127
- package/src/config-fields.ts +137 -0
- package/src/gateway.ts +303 -130
- package/src/index.ts +313 -130
- package/src/login.ts +1 -17
- package/src/request-policy.ts +315 -0
- package/src/state.ts +90 -9
- package/src/tls.ts +56 -0
- package/src/upstream-session.ts +41 -10
package/src/gateway.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The reverse-proxy gateway: a `node:http(s)` server bound to
|
|
3
|
-
*
|
|
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
|
|
18
|
-
* `
|
|
19
|
-
*
|
|
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
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
/**
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
/**
|
|
92
|
-
function
|
|
93
|
-
|
|
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
|
-
/**
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
207
|
+
this.server.listen(this.config.gatewayPort)
|
|
148
208
|
})
|
|
149
209
|
}
|
|
150
210
|
|
|
151
|
-
/**
|
|
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.
|
|
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
|
|
163
|
-
for (const socket of this.
|
|
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
|
-
|
|
170
|
-
|
|
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.
|
|
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
|
-
/**
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
207
|
-
private
|
|
208
|
-
return
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 (
|
|
283
|
-
this.serveUnauthorized(res
|
|
350
|
+
if (requiresLogin(source, this.config.lanPasswordless) && !this.authorized(req)) {
|
|
351
|
+
this.serveUnauthorized(res)
|
|
284
352
|
return
|
|
285
353
|
}
|
|
286
354
|
|
|
287
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 (!
|
|
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
|
-
/**
|
|
362
|
-
private
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
394
|
-
const headers =
|
|
395
|
-
|
|
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
|
-
|
|
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 (!
|
|
554
|
+
if (!sameSiteAllowed(req, true)) {
|
|
448
555
|
refuse(403)
|
|
449
556
|
return
|
|
450
557
|
}
|
|
451
558
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
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
|
+
})
|
|
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
|
-
|
|
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
|
|
469
|
-
const
|
|
470
|
-
|
|
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
|
-
|
|
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
|
}
|