@riceawa/dsh-lan-gateway 0.5.4 → 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.
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Every request decision the gateway makes, as pure functions over headers,
3
+ * paths and config — the same treatment `auth.ts` already gives
4
+ * `classifySource` / `signCookie` / `originMatchesHost`. `LanGateway` keeps the
5
+ * `http.Server`, the socket bookkeeping and the relay; who may pass, which path
6
+ * the gateway owns, and what the forwarded headers look like are decided here,
7
+ * where a test can reach them with a literal object instead of a live socket.
8
+ *
9
+ * Nothing here reads a clock, a socket or a config file.
10
+ *
11
+ * @module @riceawa/dsh-lan-gateway/request-policy
12
+ */
13
+
14
+ import type { IncomingHttpHeaders, OutgoingHttpHeaders } from 'node:http'
15
+ import { originMatchesHost, type SourceClass } from './auth.ts'
16
+ import { isUpstreamCookiePair, isUpstreamSessionCookie } from './upstream-session.ts'
17
+
18
+ /** The request facts a policy decision reads. */
19
+ export interface RequestHead {
20
+ /**
21
+ * Optional *and* explicitly undefined-able, matching how Node declares
22
+ * `IncomingMessage.method`. Under `exactOptionalPropertyTypes` those are two
23
+ * different types, and the shorter `method?: string` would reject a plain
24
+ * `IncomingMessage` at every call site.
25
+ */
26
+ method?: string | undefined
27
+ headers: IncomingHttpHeaders
28
+ }
29
+
30
+ /** Methods a browser never attaches a CSRF-meaningful body to; safe without an Origin. */
31
+ export const READ_ONLY_METHODS: ReadonlySet<string> = new Set(['GET', 'HEAD', 'OPTIONS'])
32
+
33
+ /**
34
+ * Headers a proxy must not forward in either direction (RFC 9110 §7.6.1), plus
35
+ * the non-standard proxy-connection.
36
+ */
37
+ const HOP_BY_HOP_HEADERS = new Set([
38
+ 'connection',
39
+ 'keep-alive',
40
+ 'proxy-authenticate',
41
+ 'proxy-authorization',
42
+ 'proxy-connection',
43
+ 'te',
44
+ 'trailer',
45
+ 'transfer-encoding',
46
+ 'upgrade',
47
+ ])
48
+
49
+ /**
50
+ * Hop-by-hop headers a successful upgrade must still carry: 101 is exactly the
51
+ * exchange that negotiates Connection/Upgrade, so they survive there and
52
+ * nowhere else.
53
+ */
54
+ const UPGRADE_HANDSHAKE_HEADERS = new Set(['connection', 'upgrade'])
55
+
56
+ /**
57
+ * Headers by which a client asserts where a request came from. The gateway
58
+ * classifies on `socket.remoteAddress` and never reads these, so relaying a
59
+ * caller's own values only hands the next hop a forgeable claim.
60
+ */
61
+ const FORWARDING_HEADERS = [
62
+ 'forwarded',
63
+ 'x-forwarded-for',
64
+ 'x-forwarded-host',
65
+ 'x-forwarded-port',
66
+ 'x-forwarded-proto',
67
+ 'x-real-ip',
68
+ ]
69
+
70
+ /** Prefixes the gateway owns and must never relay to dsh. */
71
+ export function isOwnedPath(pathname: string): boolean {
72
+ return pathname === '/lan-gateway' || pathname.startsWith('/lan-gateway/')
73
+ }
74
+
75
+ /**
76
+ * The pathname a request is routed by: the one dsh's router resolves it to
77
+ * (WHATWG URL parsing, which strips the query and collapses dot segments),
78
+ * with trailing slashes then removed for the gateway's own surface tests.
79
+ *
80
+ * The decision paths below (owned prefix, login, logout) must use this rather
81
+ * than the raw request target. dsh normalizes before matching, so a raw-string
82
+ * test disagrees with it on `/foo/../lan-gateway/config` — that is not an owned
83
+ * path by string prefix, stays in the relay, and lands on the plugin's own
84
+ * config route once Host has been rewritten to loopback. Forwarding still
85
+ * relays the raw target: dsh applies the same normalization itself.
86
+ *
87
+ * WHATWG parsing does not drop a trailing slash, and neither does dsh's
88
+ * router, so `/__login/` is not the login page to either of them. The gateway
89
+ * recognizes its own surfaces there anyway: `/__logout/` must still sign out,
90
+ * and `/lan-gateway/config/` must be refused rather than relayed into dsh's
91
+ * single-page fallback. Blocking a trailing-slash spelling of an owned prefix
92
+ * errs toward refusing, which costs nothing — no upstream route lives under it.
93
+ */
94
+ export function pathOf(url: string): string {
95
+ try {
96
+ return new URL(url, 'http://gateway.invalid').pathname.replace(/\/+$/, '') || '/'
97
+ } catch {
98
+ // Unparseable here means unparseable for dsh too; the raw target routes
99
+ // nowhere and is relayed as-is.
100
+ return url
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Whether `hostname` is loopback (127/8, localhost, ::1).
106
+ *
107
+ * This validates a URL *hostname* — the loopback fence on the gateway's own
108
+ * config route, where the input is the browser's Host header — so it accepts
109
+ * the spellings a URL parser produces, `[::1]` included. `classifySource` in
110
+ * `auth.ts` answers a different question about a different input (a socket
111
+ * address, unwrapped from its `::ffff:` mapping, and including LAN space); the
112
+ * two are related but not interchangeable, and neither should be rewritten in
113
+ * terms of the other without moving its input domain too.
114
+ */
115
+ export function isLoopbackHost(hostname: string): boolean {
116
+ if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
117
+ const parts = hostname.split('.')
118
+ return (
119
+ parts.length === 4
120
+ && parts[0] === '127'
121
+ && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
122
+ )
123
+ }
124
+
125
+ /** Whether this source must present a gateway session (default: everyone). */
126
+ export function requiresLogin(source: SourceClass, lanPasswordless: boolean): boolean {
127
+ return !(lanPasswordless && source !== 'internet')
128
+ }
129
+
130
+ /** Parse the session cookie out of a Cookie header. */
131
+ export function sessionCookie(headers: IncomingHttpHeaders, cookieName: string): string | undefined {
132
+ const header = headers.cookie
133
+ if (typeof header !== 'string') return undefined
134
+ for (const part of header.split(';')) {
135
+ const trimmed = part.trim()
136
+ if (trimmed.startsWith(`${cookieName}=`)) {
137
+ return trimmed.slice(cookieName.length + 1)
138
+ }
139
+ }
140
+ return undefined
141
+ }
142
+
143
+ /**
144
+ * The cross-site test shared by every gateway-owned entry point, applied before
145
+ * any Host/Origin rewriting: an explicit cross-site fetch, or an Origin that
146
+ * does not name the authority the browser actually used.
147
+ *
148
+ * Only claims a cross-site page cannot suppress are read, which is what makes
149
+ * this usable on the login POST too (see {@link loginOriginAllowed}).
150
+ */
151
+ export function isCrossSiteRequest(headers: IncomingHttpHeaders): boolean {
152
+ if (headers['sec-fetch-site'] === 'cross-site') return true
153
+ const origin = headers.origin
154
+ if (origin !== undefined && !originMatchesHost(origin, headers.host)) return true
155
+ return false
156
+ }
157
+
158
+ /**
159
+ * The gateway's own cross-site gate, shared by HTTP and WebSocket upgrades and
160
+ * applied before any Host/Origin rewriting. Browsers attach Origin to
161
+ * state-changing requests and to every WebSocket handshake; reads without an
162
+ * Origin (navigations, non-browser clients holding a session) stay allowed.
163
+ */
164
+ export function sameSiteAllowed(req: RequestHead, upgrade: boolean): boolean {
165
+ if (isCrossSiteRequest(req.headers)) return false
166
+ const origin = req.headers.origin
167
+ if (upgrade) return origin !== undefined
168
+ if (!READ_ONLY_METHODS.has(req.method ?? 'GET')) return origin !== undefined
169
+ return true
170
+ }
171
+
172
+ /**
173
+ * The fence on the login POST. Issuing a session is as much a state change as
174
+ * retiring one — and a cross-site form post burns the victim's source address
175
+ * through the login rate limiter — so the login route runs the same cross-site
176
+ * test as everything else.
177
+ *
178
+ * It deliberately stops short of {@link sameSiteAllowed}'s "a state-changing
179
+ * request must carry an Origin" rule: a browser always sends an Origin on a
180
+ * form POST, but curl, the dsh CLI and other non-browser clients legitimately
181
+ * do not, and requiring one would lock them out of signing in. What remains is
182
+ * what a cross-site page cannot forge or strip: `sec-fetch-site`, and an Origin
183
+ * that disagrees with the Host the request names.
184
+ */
185
+ export function loginOriginAllowed(headers: IncomingHttpHeaders): boolean {
186
+ return !isCrossSiteRequest(headers)
187
+ }
188
+
189
+ /**
190
+ * Drop every `dsh-auth-*` pair from a Cookie header, returning the remainder
191
+ * (possibly '').
192
+ *
193
+ * The relay's session is appended to the client's own cookie, and upstream
194
+ * reads the FIRST name match. A client that holds any `dsh-auth-<hash>` —
195
+ * typically one minted before dsh's signing secret was reset, so still present
196
+ * but no longer verifying — would therefore shadow the relay's session on every
197
+ * request. That draws a 401, the gateway reads the 401 as "upstream revoked our
198
+ * session" and discards it, the next request re-acquires, and the client's
199
+ * stale cookie shadows that one too: a loop that never converges. Stripping the
200
+ * namespace makes the relay's copy the only one.
201
+ *
202
+ * This filters the namespace; `isUpstreamSessionCookie` decides which cookie may
203
+ * be *accepted* from upstream. The two are deliberately different rules.
204
+ */
205
+ export function withoutUpstreamSessionPairs(cookie: string): string {
206
+ return cookie
207
+ .split(';')
208
+ .map((pair) => pair.trim())
209
+ .filter((pair) => pair !== '' && !isUpstreamCookiePair(pair))
210
+ .join('; ')
211
+ }
212
+
213
+ /** How the outbound request headers are built. */
214
+ export interface UpstreamRequestOptions {
215
+ /** The loopback dsh port the Host/Origin rewrite names. */
216
+ dshPort: number
217
+ /** Keep the WebSocket handshake's Connection/Upgrade headers. */
218
+ keepUpgrade: boolean
219
+ /** The shared upstream session to ride, when the relay holds one. */
220
+ upstreamCookie?: string | undefined
221
+ }
222
+
223
+ /**
224
+ * Build the outbound headers for one relayed request: rewrite Host/Origin to
225
+ * the loopback upstream, drop hop-by-hop and caller-supplied forwarding
226
+ * headers, clear the upstream cookie namespace the relay owns, and attach the
227
+ * relayed session.
228
+ */
229
+ export function upstreamRequestHeaders(
230
+ headers: IncomingHttpHeaders,
231
+ options: UpstreamRequestOptions,
232
+ ): OutgoingHttpHeaders {
233
+ const out: OutgoingHttpHeaders = { ...headers }
234
+ out.host = `127.0.0.1:${options.dshPort}`
235
+ if (typeof out.origin === 'string') {
236
+ out.origin = `http://127.0.0.1:${options.dshPort}`
237
+ }
238
+ delete out['proxy-connection']
239
+ if (!options.keepUpgrade) {
240
+ delete out.connection
241
+ delete out.upgrade
242
+ }
243
+ for (const name of FORWARDING_HEADERS) delete out[name]
244
+ if (typeof out.cookie === 'string') {
245
+ const kept = withoutUpstreamSessionPairs(out.cookie)
246
+ if (kept === '') delete out.cookie
247
+ else out.cookie = kept
248
+ }
249
+ const relayed = options.upstreamCookie
250
+ if (relayed !== undefined && relayed !== '') {
251
+ const existing = out.cookie
252
+ out.cookie = typeof existing === 'string' && existing !== ''
253
+ ? `${existing}; ${relayed}`
254
+ : relayed
255
+ }
256
+ return out
257
+ }
258
+
259
+ /**
260
+ * Filter one direction's worth of headers through the same cookie rule, so the
261
+ * HTTP and WebSocket branches cannot drift apart on it.
262
+ */
263
+ function stripUpstreamCookies(entry: string): boolean {
264
+ return !isUpstreamSessionCookie(entry.trim())
265
+ }
266
+
267
+ /**
268
+ * The headers to send back to the client: hop-by-hop headers dropped, and the
269
+ * upstream session cookie withheld. Upstream's one cookie-minting route is the
270
+ * launch-token exchange at `/`, so a client that already holds a gateway session
271
+ * could otherwise post the token through the gateway and walk away with a
272
+ * durable upstream credential the relay exists to keep on this side. Cookies
273
+ * from other routes (plugins) still pass through.
274
+ */
275
+ export function downstreamResponseHeaders(upstream: IncomingHttpHeaders): OutgoingHttpHeaders {
276
+ const headers: OutgoingHttpHeaders = {}
277
+ for (const [key, value] of Object.entries(upstream)) {
278
+ if (value === undefined) continue
279
+ const lower = key.toLowerCase()
280
+ if (HOP_BY_HOP_HEADERS.has(lower)) continue
281
+ if (lower === 'set-cookie') {
282
+ const list = (Array.isArray(value) ? value : [value]).filter(stripUpstreamCookies)
283
+ if (list.length > 0) headers[key] = list
284
+ continue
285
+ }
286
+ headers[key] = value
287
+ }
288
+ return headers
289
+ }
290
+
291
+ /**
292
+ * The headers of a 101 Switching Protocols response, replayed to the client on
293
+ * the socket the gateway just spliced.
294
+ *
295
+ * {@link downstreamResponseHeaders} cannot be reused verbatim here: a successful
296
+ * upgrade has to keep Connection/Upgrade, which are hop-by-hop on every other
297
+ * response. The cookie rule is not relaxed with them — the relay's session is
298
+ * withheld on this path too, so upstream cannot hand a client a durable
299
+ * credential by attaching it to the handshake.
300
+ */
301
+ export function upgradeResponseHeaders(upstream: IncomingHttpHeaders): OutgoingHttpHeaders {
302
+ const headers: OutgoingHttpHeaders = {}
303
+ for (const [key, value] of Object.entries(upstream)) {
304
+ if (value === undefined) continue
305
+ const lower = key.toLowerCase()
306
+ if (HOP_BY_HOP_HEADERS.has(lower) && !UPGRADE_HANDSHAKE_HEADERS.has(lower)) continue
307
+ if (lower === 'set-cookie') {
308
+ const list = (Array.isArray(value) ? value : [value]).filter(stripUpstreamCookies)
309
+ if (list.length > 0) headers[key] = list
310
+ continue
311
+ }
312
+ headers[key] = value
313
+ }
314
+ return headers
315
+ }
package/src/state.ts CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
11
11
  import { join } from 'node:path'
12
- import { randomBytes, scrypt, scryptSync, timingSafeEqual } from 'node:crypto'
12
+ import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
13
13
  import { homedir } from 'node:os'
14
14
 
15
15
  /** The state directory: `~/.dsh/lan-gateway`. */
@@ -84,8 +84,13 @@ export async function verifyPassword(state: GatewayState, password: string): Pro
84
84
  * Set (or clear) the password, re-salted on every write. Both operations bump
85
85
  * the session epoch so every cookie issued under the previous epoch dies — a
86
86
  * password change must invalidate sessions the old password authorized.
87
+ *
88
+ * Deriving the key is asynchronous for the same reason
89
+ * {@link verifyPassword} is: `scryptSync` occupies the event loop for tens of
90
+ * milliseconds, and that loop is shared with the dsh process the gateway
91
+ * forwards to. Every caller is already async.
87
92
  */
88
- export function setPassword(state: GatewayState, password: string | undefined): GatewayState {
93
+ export async function setPassword(state: GatewayState, password: string | undefined): Promise<GatewayState> {
89
94
  // The new epoch invalidates every cookie on its own account, so the list of
90
95
  // individually revoked sessions has nothing left to say: drop it rather than
91
96
  // carry entries that can never match again.
@@ -95,7 +100,7 @@ export function setPassword(state: GatewayState, password: string | undefined):
95
100
  }
96
101
  if (password === undefined) return base
97
102
  const salt = randomBytes(16)
98
- const hash = scryptSync(password, salt, 64)
103
+ const hash = await deriveKey(password, salt, 64)
99
104
  return {
100
105
  ...base,
101
106
  password: { hash: hash.toString('hex'), salt: salt.toString('hex') },
@@ -107,9 +112,15 @@ export function setPassword(state: GatewayState, password: string | undefined):
107
112
  * @param expiresMs - the revoked cookie's own expiry. Past it the cookie is
108
113
  * rejected on its own account, so the entry is no longer needed; dropping
109
114
  * expired entries here is what keeps the list bounded.
115
+ * @param now - epoch millis to judge the existing entries against, injected so
116
+ * a test can age the list without fake timers.
110
117
  */
111
- export function revokeSession(state: GatewayState, sid: string, expiresMs: number): GatewayState {
112
- const now = Date.now()
118
+ export function revokeSession(
119
+ state: GatewayState,
120
+ sid: string,
121
+ expiresMs: number,
122
+ now: number = Date.now(),
123
+ ): GatewayState {
113
124
  const revoked: Record<string, number> = {}
114
125
  for (const [id, exp] of Object.entries(state.revokedSessions ?? {})) {
115
126
  if (exp > now) revoked[id] = exp
package/src/tls.ts CHANGED
@@ -105,6 +105,29 @@ export function regenerateSelfSigned(opts: SelfSignedTlsOptions, home: string =
105
105
  return material
106
106
  }
107
107
 
108
+ /**
109
+ * Read the persisted self-signed certificate for a status report, or undefined
110
+ * when none has been generated yet.
111
+ *
112
+ * Nothing is created or written here. The status path answers a question about
113
+ * a listener that is already running (or was), and minting a key pair — an RSA
114
+ * generation plus two file writes — to answer a read would both be slow and
115
+ * leave material on disk for a gateway that never started. Generation belongs
116
+ * to {@link loadOrRenewSelfSigned} and {@link regenerateSelfSigned}.
117
+ * @param home - dsh home override (tests).
118
+ * @returns the certificate material as persisted, or undefined.
119
+ */
120
+ export function readSelfSignedStatus(home: string = homedir()): TlsMaterial | undefined {
121
+ const dir = tlsDir(home)
122
+ const certPath = join(dir, SELF_SIGNED_CERT_FILE)
123
+ const keyPath = join(dir, SELF_SIGNED_KEY_FILE)
124
+ if (!existsSync(certPath) || !existsSync(keyPath)) return undefined
125
+ const cert = readFileSync(certPath, 'utf8')
126
+ const key = readFileSync(keyPath, 'utf8')
127
+ new X509Certificate(cert) // sanity: must parse as a certificate
128
+ return { cert, key }
129
+ }
130
+
108
131
  function generateSelfSignedMaterial(opts: SelfSignedTlsOptions): TlsMaterial {
109
132
  const hosts = opts.hosts.map(h => h.trim()).filter(h => h !== '')
110
133
  if (hosts.length === 0) {
@@ -35,10 +35,14 @@ interface HeldCookie {
35
35
  expiresAt: number
36
36
  }
37
37
 
38
- /** The minimal shared-session contract the gateway consumes. */
38
+ /**
39
+ * The minimal shared-session contract the gateway consumes.
40
+ *
41
+ * Two methods, and deliberately no reader: a caller that needs the value
42
+ * already holds the one `cookie()` returned, and a second way to read the
43
+ * cache only invites the caller to skip the acquisition it just awaited.
44
+ */
39
45
  export interface UpstreamSession {
40
- /** The current `name=value` without triggering a re-acquisition. */
41
- peek(): string | undefined
42
46
  /** The current `name=value`, re-acquiring when missing or stale. Never throws. */
43
47
  cookie(): Promise<string | undefined>
44
48
  /** Forget a session upstream rejected, so the next request re-acquires. */
@@ -92,14 +96,30 @@ function cookieNameOf(setCookie: string): string {
92
96
  return eq === -1 ? '' : setCookie.slice(0, eq).trim()
93
97
  }
94
98
 
99
+ /**
100
+ * Whether one `name=value` fragment of a request `Cookie` header names the
101
+ * upstream session namespace, and so must be dropped before the relay's own
102
+ * copy is appended. This is the *filter* rule: it matches the whole reserved
103
+ * namespace, name only, whether or not the pair is a well-formed session.
104
+ */
105
+ export function isUpstreamCookiePair(pair: string): boolean {
106
+ return pair.trim().startsWith(UPSTREAM_COOKIE_PREFIX)
107
+ }
108
+
95
109
  /**
96
110
  * Whether a `Set-Cookie` string is the upstream browser-session cookie. The
97
111
  * name upstream mints is `dsh-auth-<base64url(sha256(authority))>`: the prefix
98
112
  * is followed by the authority hash, never by `=` itself, so the test is a
99
113
  * prefix plus at least one character — matching on `dsh-auth-=` finds nothing
100
114
  * and silently relays every request anonymously.
115
+ *
116
+ * This is the *accept* rule, and it is stricter than {@link isUpstreamCookiePair}
117
+ * on purpose: filtering drops a whole namespace the gateway owns, whereas
118
+ * accepting a session has to recognize the one cookie upstream actually mints.
119
+ * Both live here because this module owns the protocol fact; a consumer that
120
+ * re-derives it is how the two rules drifted apart before.
101
121
  */
102
- function isUpstreamSessionCookie(setCookie: string): boolean {
122
+ export function isUpstreamSessionCookie(setCookie: string): boolean {
103
123
  const name = cookieNameOf(setCookie)
104
124
  return name.startsWith(UPSTREAM_COOKIE_PREFIX) && name.length > UPSTREAM_COOKIE_PREFIX.length
105
125
  }
@@ -208,10 +228,6 @@ export class UpstreamSessionRelay implements UpstreamSession {
208
228
  return Date.now() < held.expiresAt - 60_000
209
229
  }
210
230
 
211
- peek(): string | undefined {
212
- return this.held?.header
213
- }
214
-
215
231
  invalidate(): void {
216
232
  if (this.held !== undefined) this.log('invalidating held session (upstream rejected it)')
217
233
  this.held = undefined