@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/README.md +145 -55
- package/cordis.patch.yml +5 -2
- package/lib/client.js +78 -44
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +52 -5
- package/lib/index.js +470 -108
- package/package.json +2 -2
- package/skills/lan-gateway.md +47 -29
- package/src/auth.ts +39 -6
- package/src/client/index.ts +16 -2
- package/src/client/lan-gateway-card.tsx +56 -21
- package/src/gateway.ts +230 -86
- package/src/index.ts +196 -48
- package/src/login.ts +3 -0
- package/src/state.ts +27 -6
- package/src/tool.ts +12 -8
- package/src/upstream-session.ts +187 -0
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
|
|
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`).
|
|
9
|
-
*
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
61
|
+
/** LAN CIDRs that may be treated as trusted (descriptive; see `lanPasswordless`). */
|
|
45
62
|
lanCidrs: readonly string[]
|
|
46
|
-
/** Whether
|
|
47
|
-
|
|
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
|
-
/**
|
|
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(
|
|
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
|
|
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
|
|
117
|
-
|
|
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
|
|
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(
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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.
|
|
192
|
-
|
|
282
|
+
if (this.requiresLogin(source) && !this.authorized(req)) {
|
|
283
|
+
this.serveUnauthorized(res, false)
|
|
284
|
+
return
|
|
285
|
+
}
|
|
193
286
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
244
|
-
const
|
|
245
|
-
const
|
|
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
|
-
/**
|
|
258
|
-
private
|
|
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
|
-
|
|
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
|
|
288
|
-
private handleUpgrade(req: http.IncomingMessage, socket: Duplex, head: Buffer): void {
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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:
|
|
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
|
-
}
|