@riceawa/dsh-lan-gateway 0.3.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 ADDED
@@ -0,0 +1,343 @@
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.
5
+ *
6
+ * Security model:
7
+ * - 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.
10
+ * - 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).
14
+ *
15
+ * @module @riceawa/dsh-lan-gateway/gateway
16
+ */
17
+
18
+ import http from 'node:http'
19
+ import https from 'node:https'
20
+ import type { Duplex } from 'node:stream'
21
+ import {
22
+ classifySource,
23
+ RateLimiter,
24
+ signCookie,
25
+ verifyCookie,
26
+ type SourceClass,
27
+ } from './auth.ts'
28
+ import {
29
+ COOKIE_NAME,
30
+ LOGIN_PATH,
31
+ readBody,
32
+ renderLoginPage,
33
+ serveLoginGet,
34
+ type LoginPageOptions,
35
+ } from './login.ts'
36
+ import { verifyPassword, type GatewayState } from './state.ts'
37
+
38
+ /** Configuration the gateway needs at listen time. */
39
+ export interface GatewayConfig {
40
+ /** Port to bind on 0.0.0.0. */
41
+ gatewayPort: number
42
+ /** The loopback dsh web server port to forward to. */
43
+ dshPort: number
44
+ /** LAN CIDRs treated as password-free. */
45
+ lanCidrs: readonly string[]
46
+ /** Whether non-LAN sources require a password. */
47
+ authRequired: boolean
48
+ /** Cookie lifetime in days. */
49
+ cookieMaxAgeDays: number
50
+ /** Cookie name. */
51
+ cookieName: string
52
+ /** PEM cert/key material; when present the listener speaks HTTPS. */
53
+ tls?: { cert: string; key: string }
54
+ }
55
+
56
+ const DEFAULT_BODY_LIMIT_BYTES = 64 * 1024
57
+ const LOGIN_ATTEMPTS_LIMIT = 5
58
+ const LOGIN_ATTEMPTS_WINDOW_MS = 60_000
59
+
60
+ /**
61
+ * The running gateway: owns the HTTP server and the auth state needed per
62
+ * request. Created by the plugin on enable; torn down by the plugin on
63
+ * disable or tree disposal.
64
+ */
65
+ export class LanGateway {
66
+ readonly server: http.Server
67
+ private readonly loginLimiter = new RateLimiter(LOGIN_ATTEMPTS_LIMIT, LOGIN_ATTEMPTS_WINDOW_MS)
68
+ private state: GatewayState
69
+ private disposed = false
70
+
71
+ constructor(private readonly config: GatewayConfig, state: GatewayState) {
72
+ this.state = state
73
+ const handle = (req: http.IncomingMessage, res: http.ServerResponse): void => {
74
+ void this.handleHttp(req, res)
75
+ }
76
+ this.server = this.config.tls !== undefined
77
+ ? https.createServer({ cert: this.config.tls.cert, key: this.config.tls.key }, handle)
78
+ : http.createServer(handle)
79
+ this.server.on('upgrade', (req, socket, head) => {
80
+ void this.handleUpgrade(req, socket, head)
81
+ })
82
+ }
83
+
84
+ /** Replace the in-memory state (e.g. after a password change). */
85
+ setState(state: GatewayState): void {
86
+ this.state = state
87
+ }
88
+
89
+ /** Start listening; rejects if the port is already in use. */
90
+ async listen(): Promise<void> {
91
+ return new Promise((resolve, reject) => {
92
+ const onError = (err: Error): void => {
93
+ this.server.off('listening', onListening)
94
+ reject(err)
95
+ }
96
+ const onListening = (): void => {
97
+ this.server.off('error', onError)
98
+ resolve()
99
+ }
100
+ this.server.once('error', onError)
101
+ this.server.once('listening', onListening)
102
+ this.server.listen(this.config.gatewayPort, '0.0.0.0')
103
+ })
104
+ }
105
+
106
+ /** Close the server and stop accepting connections. */
107
+ async close(): Promise<void> {
108
+ if (this.disposed) return
109
+ this.disposed = true
110
+ return new Promise((resolve) => {
111
+ this.server.close(() => resolve())
112
+ this.server.closeAllConnections()
113
+ })
114
+ }
115
+
116
+ private sourceClass(req: http.IncomingMessage): SourceClass {
117
+ return classifySource(req.socket.remoteAddress, this.config.lanCidrs)
118
+ }
119
+
120
+ /** Parse the session cookie out of a Cookie header. */
121
+ private sessionCookie(req: http.IncomingMessage): string | undefined {
122
+ const header = req.headers.cookie
123
+ if (typeof header !== 'string') return undefined
124
+ for (const part of header.split(';')) {
125
+ const trimmed = part.trim()
126
+ if (trimmed.startsWith(`${this.config.cookieName}=`)) {
127
+ return trimmed.slice(this.config.cookieName.length + 1)
128
+ }
129
+ }
130
+ return undefined
131
+ }
132
+
133
+ /** Whether a request carries a valid session for its source. */
134
+ private authorized(req: http.IncomingMessage): boolean {
135
+ const cookie = this.sessionCookie(req)
136
+ return cookie !== undefined && verifyCookie(this.state.cookieSecret, cookie, Date.now())
137
+ }
138
+
139
+ private serveUnauthorized(res: http.ServerResponse, limited: boolean): void {
140
+ res.writeHead(302, {
141
+ location: `${LOGIN_PATH}${limited ? '?limited=1' : ''}`,
142
+ ...this.securityHeaders(),
143
+ })
144
+ res.end()
145
+ }
146
+
147
+ private serveLoginError(res: http.ServerResponse, message: string): void {
148
+ const opts: LoginPageOptions = { error: message }
149
+ res.writeHead(401, {
150
+ 'content-type': 'text/html; charset=utf-8',
151
+ 'cache-control': 'no-store',
152
+ ...this.securityHeaders(),
153
+ })
154
+ res.end(renderLoginPage(opts))
155
+ }
156
+
157
+ /** HSTS when the listener is HTTPS (never sent on plain HTTP). */
158
+ private securityHeaders(): http.OutgoingHttpHeaders {
159
+ return this.config.tls === undefined
160
+ ? {}
161
+ : { 'strict-transport-security': 'max-age=15552000' }
162
+ }
163
+
164
+ /** Handle one HTTP request: auth gate → CSRF fence → forward. */
165
+ private async handleHttp(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
166
+ const source = this.sourceClass(req)
167
+ const url = req.url ?? '/'
168
+ const pathname = url.split('?')[0] ?? '/'
169
+
170
+ if (pathname === LOGIN_PATH) {
171
+ this.handleLogin(req, res)
172
+ return
173
+ }
174
+
175
+ if (source === 'internet' && this.config.authRequired) {
176
+ if (!this.authorized(req)) {
177
+ this.serveUnauthorized(res, false)
178
+ return
179
+ }
180
+ }
181
+
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
+ }
189
+ }
190
+
191
+ this.forward(req, res, url)
192
+ }
193
+
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
208
+ }
209
+ }
210
+
211
+ /** Handle the login GET form / POST submission. */
212
+ private handleLogin(req: http.IncomingMessage, res: http.ServerResponse): void {
213
+ const limited = req.url?.includes('limited=1') ?? false
214
+ if (req.method === 'GET' || req.method === 'HEAD') {
215
+ serveLoginGet(res, this.securityHeaders())
216
+ return
217
+ }
218
+ if (req.method !== 'POST') {
219
+ res.writeHead(405, { allow: 'GET, POST' })
220
+ res.end()
221
+ return
222
+ }
223
+
224
+ const key = req.socket.remoteAddress ?? 'unknown'
225
+ if (!this.loginLimiter.allow(key)) {
226
+ this.serveLoginError(res, 'Too many attempts — please wait a minute.')
227
+ return
228
+ }
229
+
230
+ void readBody(req, DEFAULT_BODY_LIMIT_BYTES, res).then((body) => {
231
+ if (body === undefined) return // response already sent (413/400)
232
+ let password: string | undefined
233
+ try {
234
+ const fields = new URLSearchParams(body)
235
+ password = fields.get('password') ?? undefined
236
+ } catch {
237
+ password = undefined
238
+ }
239
+ if (password === undefined || !verifyPassword(this.state, password)) {
240
+ this.serveLoginError(res, 'Incorrect password.')
241
+ return
242
+ }
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' : ''
246
+ res.writeHead(302, {
247
+ location: '/',
248
+ ...this.securityHeaders(),
249
+ 'set-cookie': [
250
+ `${this.config.cookieName}=${cookie}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${this.config.cookieMaxAgeDays * 86_400}${secure}`,
251
+ ],
252
+ })
253
+ res.end()
254
+ })
255
+ }
256
+
257
+ /** Forward an HTTP request to dsh, rewriting Host/Origin to loopback. */
258
+ private forward(req: http.IncomingMessage, res: http.ServerResponse, url: string): void {
259
+ const headers: http.OutgoingHttpHeaders = { ...req.headers }
260
+ headers.host = `127.0.0.1:${this.config.dshPort}`
261
+ if (typeof headers.origin === 'string') {
262
+ headers.origin = `http://127.0.0.1:${this.config.dshPort}`
263
+ }
264
+ // Hop-by-hop headers the gateway must not forward.
265
+ delete headers['proxy-connection']
266
+ delete headers.connection
267
+
268
+ const proxyReq = http.request({
269
+ host: '127.0.0.1',
270
+ port: this.config.dshPort,
271
+ method: req.method,
272
+ path: url,
273
+ headers,
274
+ }, (proxyRes) => {
275
+ res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers)
276
+ proxyRes.pipe(res)
277
+ })
278
+ proxyReq.on('error', () => {
279
+ if (!res.headersSent) {
280
+ res.writeHead(502)
281
+ }
282
+ res.destroy()
283
+ })
284
+ req.pipe(proxyReq)
285
+ }
286
+
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
+ )
294
+ socket.destroy()
295
+ return
296
+ }
297
+
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}`
302
+ }
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']
306
+
307
+ const proxyReq = http.request({
308
+ host: '127.0.0.1',
309
+ port: this.config.dshPort,
310
+ method: 'GET',
311
+ path: req.url ?? '/',
312
+ headers,
313
+ })
314
+ proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
315
+ // node's http client has already consumed the 101 response headers, so
316
+ // reconstruct them on the client socket before splicing.
317
+ const statusLine = `HTTP/1.1 ${proxyRes.statusCode ?? 101} ${proxyRes.statusMessage ?? 'Switching Protocols'}\r\n`
318
+ const headerLines = Object.entries(proxyRes.headers)
319
+ .map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(', ') : value}\r\n`)
320
+ .join('')
321
+ socket.write(`${statusLine}${headerLines}\r\n`)
322
+ // Forward the client's own head bytes (initial WebSocket frames) to dsh.
323
+ if (head !== undefined && head.length > 0) {
324
+ proxySocket.write(head)
325
+ }
326
+ proxySocket.pipe(socket).pipe(proxySocket)
327
+ if (proxyHead !== undefined && proxyHead.length > 0) {
328
+ proxySocket.unshift(proxyHead)
329
+ }
330
+ socket.on('error', () => proxySocket.destroy())
331
+ proxySocket.on('error', () => socket.destroy())
332
+ })
333
+ proxyReq.on('error', () => socket.destroy())
334
+ proxyReq.end()
335
+ }
336
+ }
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
+ }