@critical-labs/qa-conductor 0.0.0-stage → 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/lib/proxy.mjs ADDED
@@ -0,0 +1,327 @@
1
+ // Pane proxy for QA sessions. Fronts one pane's app container:
2
+ //
3
+ // - Holds the pane's cookies in a server-side jar. Cookies ignore ports, so
4
+ // two app versions behind one tailnet hostname would collide in the
5
+ // browser; pane Set-Cookie headers are absorbed here and never forwarded.
6
+ // - Injects the mirror bridge <script> into text/html responses and serves
7
+ // the bridge file at /__qa/bridge.js (read per request, no cache). The tag
8
+ // carries the harness origin (data-harness), the only origin the bridge
9
+ // talks to.
10
+ // - Stamps activity (onActivity) for the idle reaper.
11
+ // - Drops tailscale serve's `Tailscale-*` identity headers before the app,
12
+ // which runs PR code, sees the request.
13
+ // - Rewrites absolute http://127.0.0.1:<upstreamPort> Locations to relative
14
+ // so redirects stay on the pane's public origin, and drops the
15
+ // Referrer-Policy of a redirect that stays on the pane, so the harness's
16
+ // Referer still admits the next hop.
17
+ // - Answers 421 to any request whose Host isn't loopback or an allowed
18
+ // hostname, before routing: a DNS-rebound page must not drive a signed-in
19
+ // pane. The harness server reuses the same check.
20
+ // - Since the jar signs every request in as the operator, refuses requests
21
+ // other pages make, and navigations from another site that the harness did
22
+ // not start (lib/request-guard.mjs).
23
+ // - Decides who may frame the pane: every response it sends carries
24
+ // `frame-ancestors 'self' <harness origin> <frameAncestors…>` in place of
25
+ // the app's own frame-ancestors directive and X-Frame-Options.
26
+
27
+ import http from 'node:http'
28
+ import { readFile } from 'node:fs/promises'
29
+
30
+ import { webOrigin } from './net.mjs'
31
+ import { paneRefusal } from './request-guard.mjs'
32
+
33
+ const BRIDGE_ROUTE = '/__qa/bridge.js'
34
+ const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '::1']
35
+
36
+ const normalizeHost = h => String(h ?? '').trim().toLowerCase().replace(/^\[(.*)\]$/, '$1')
37
+
38
+ // The hostname a request was addressed to: port dropped, IPv6 brackets
39
+ // stripped, lowercased. '' when the header is missing or malformed.
40
+ export function requestHostname(hostHeader) {
41
+ const h = String(hostHeader ?? '').trim().toLowerCase()
42
+ if (h.startsWith('[')) {
43
+ const end = h.indexOf(']')
44
+ return end === -1 ? '' : h.slice(1, end)
45
+ }
46
+ const colon = h.indexOf(':')
47
+ return colon === -1 ? h : h.slice(0, colon)
48
+ }
49
+
50
+ // Loopback names are always allowed; `allowedHosts` adds more hostnames.
51
+ export function isAllowedHost(hostHeader, allowedHosts = []) {
52
+ const name = requestHostname(hostHeader)
53
+ if (!name) return false
54
+ return LOOPBACK_HOSTS.includes(name) || allowedHosts.some(h => normalizeHost(h) === name)
55
+ }
56
+
57
+ // `headers` adds to the 421, e.g. the pane's frame policy.
58
+ export function misdirected(res, headers = {}) {
59
+ res.writeHead(421, { 'content-type': 'text/plain', ...headers })
60
+ res.end('421: unrecognised Host header')
61
+ }
62
+
63
+ // An http(s) origin, normalized, or null for anything else. `new URL()` alone
64
+ // would turn a file: or data: URL into the string 'null'.
65
+ function originOrNull(value) {
66
+ try { return webOrigin(value) } catch { return null }
67
+ }
68
+
69
+ // The pane's frame policy: the pane itself, the harness origin, then any
70
+ // extra ancestors (a harness that is itself framed). Values that aren't
71
+ // http(s) origins are dropped, and so are IPv6 literals, which a CSP source
72
+ // can't carry. The proxy and the conductor's pane servers share it.
73
+ export function panePolicy(harnessOrigin = null, frameAncestors = []) {
74
+ const sources = new Set()
75
+ for (const value of [harnessOrigin, ...[].concat(frameAncestors ?? [])]) {
76
+ const origin = originOrNull(value)
77
+ if (origin && !new URL(origin).hostname.startsWith('[')) sources.add(origin)
78
+ }
79
+ return ["frame-ancestors 'self'", ...sources].join(' ')
80
+ }
81
+
82
+ const escapeAttr = value => String(value).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
83
+
84
+ function bridgeTag(harnessOrigin) {
85
+ const data = harnessOrigin ? ` data-harness="${escapeAttr(harnessOrigin)}"` : ''
86
+ return `<script src="${BRIDGE_ROUTE}"${data}></script>`
87
+ }
88
+
89
+ // The upstream's own policies minus any frame-ancestors directive, plus ours.
90
+ // Several policies in one header are comma-separated, and a browser enforces
91
+ // each of them.
92
+ function withFrameAncestors(upstreamCsp, frameAncestors) {
93
+ const upstream = Array.isArray(upstreamCsp) ? upstreamCsp.join(', ') : upstreamCsp ?? ''
94
+ const kept = upstream
95
+ .split(',')
96
+ .map(policy => policy.split(';').map(d => d.trim()).filter(d => d && !/^frame-ancestors(\s|$)/i.test(d)).join('; '))
97
+ .filter(Boolean)
98
+ return [...kept, frameAncestors].join(', ')
99
+ }
100
+
101
+ // Parse one Set-Cookie header into { name, value, remove }. A cookie is a
102
+ // removal when Max-Age <= 0, Expires is in the past (Max-Age wins when both
103
+ // are present, per RFC 6265), or the value is empty / the conventional
104
+ // 'deleted' sentinel.
105
+ export function parseSetCookie(header) {
106
+ const [pair, ...attrs] = String(header).split(';')
107
+ const eq = pair.indexOf('=')
108
+ const name = (eq === -1 ? pair : pair.slice(0, eq)).trim()
109
+ const value = eq === -1 ? '' : pair.slice(eq + 1).trim()
110
+ let maxAge = null
111
+ let expires = null
112
+ for (const attr of attrs) {
113
+ const i = attr.indexOf('=')
114
+ const key = (i === -1 ? attr : attr.slice(0, i)).trim().toLowerCase()
115
+ const v = i === -1 ? '' : attr.slice(i + 1).trim()
116
+ if (key === 'max-age') maxAge = Number(v)
117
+ else if (key === 'expires') expires = Date.parse(v)
118
+ }
119
+ let remove = value === '' || value === 'deleted'
120
+ if (maxAge !== null && !Number.isNaN(maxAge)) {
121
+ if (maxAge <= 0) remove = true
122
+ } else if (expires !== null && !Number.isNaN(expires) && expires <= Date.now()) {
123
+ remove = true
124
+ }
125
+ return { name, value, remove }
126
+ }
127
+
128
+ // The client's own Cookie header is kept; the jar wins on a name both have.
129
+ function mergeCookieHeader(clientCookie, jar) {
130
+ const merged = new Map()
131
+ if (clientCookie) {
132
+ for (const part of String(clientCookie).split(';')) {
133
+ const eq = part.indexOf('=')
134
+ if (eq === -1) continue
135
+ merged.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim())
136
+ }
137
+ }
138
+ for (const [name, value] of jar) merged.set(name, value)
139
+ return [...merged].map(([name, value]) => `${name}=${value}`).join('; ')
140
+ }
141
+
142
+ // tailscale serve's identity headers (the reviewer's login, name, picture)
143
+ // are for the conductor's gate; the pane app runs PR code.
144
+ function upstreamHeaders(req, jar) {
145
+ const headers = {}
146
+ for (const [key, value] of Object.entries(req.headers)) {
147
+ const k = key.toLowerCase()
148
+ if (k === 'host' || k === 'accept-encoding' || k === 'cookie') continue
149
+ if (k === 'connection' || k === 'keep-alive' || k === 'proxy-connection') continue
150
+ if (k.startsWith('tailscale-')) continue
151
+ headers[k] = value
152
+ }
153
+ headers['accept-encoding'] = 'identity'
154
+ const cookie = mergeCookieHeader(req.headers.cookie, jar)
155
+ if (cookie) headers.cookie = cookie
156
+ return headers
157
+ }
158
+
159
+ // A Host header's characters, as in lib/request-guard.mjs: anything else
160
+ // (`@`, `/`) would let the URL parser read a different host from it.
161
+ const HOST_HEADER = /^[a-z0-9.\-[\]:]+$/
162
+
163
+ // True when a Location leads back to the pane that answered: a relative
164
+ // reference, or an absolute URL whose host is the request's Host. The URL
165
+ // parser decides, so `//x` and `/\x` count as other hosts, as in a browser.
166
+ function staysOnPane(location, hostHeader) {
167
+ const host = String(hostHeader ?? '').trim().toLowerCase()
168
+ if (typeof location !== 'string' || !HOST_HEADER.test(host)) return false
169
+ try {
170
+ const pane = new URL(`http://${host}/`)
171
+ return new URL(location, pane).host === pane.host
172
+ } catch {
173
+ return false
174
+ }
175
+ }
176
+
177
+ function responseHeaders(upRes, upstreamPort, policy, requestHost) {
178
+ const headers = {}
179
+ for (const [key, value] of Object.entries(upRes.headers)) {
180
+ const k = key.toLowerCase()
181
+ if (k === 'set-cookie' || k === 'transfer-encoding' || k === 'connection' || k === 'keep-alive') {
182
+ continue
183
+ }
184
+ // X-Frame-Options would stop the harness framing the pane; our
185
+ // frame-ancestors (below) decides who may.
186
+ if (k === 'x-frame-options') continue
187
+ headers[k] = value
188
+ }
189
+ headers['content-security-policy'] = withFrameAncestors(headers['content-security-policy'], policy)
190
+ const origin = `http://127.0.0.1:${upstreamPort}`
191
+ if (typeof headers.location === 'string' && headers.location.startsWith(origin)) {
192
+ const rest = headers.location.slice(origin.length)
193
+ if (rest === '') headers.location = '/'
194
+ else if (rest.startsWith('/')) headers.location = rest
195
+ else if (rest.startsWith('?') || rest.startsWith('#')) headers.location = `/${rest}`
196
+ // anything else (e.g. a longer port sharing the prefix) is left untouched
197
+ }
198
+ // A redirect's own Referrer-Policy sets the Referer of the request it leads
199
+ // to. When the harness started the navigation (its iframe load, a reload,
200
+ // a resync, "Open in new tab"), that Referer is what admits the next hop
201
+ // (lib/request-guard.mjs), so an app's no-referrer or same-origin would get
202
+ // its own redirect refused. Without the header, the next hop keeps the
203
+ // policy of the page that started the navigation. Only for a redirect that
204
+ // stays on the pane: elsewhere the app's policy still holds.
205
+ const status = upRes.statusCode ?? 0
206
+ if (status >= 300 && status < 400 && staysOnPane(headers.location, requestHost)) delete headers['referrer-policy']
207
+ return headers
208
+ }
209
+
210
+ function injectBridge(body, tag) {
211
+ const html = body.toString('utf8')
212
+ const match = /<\/head>/i.exec(html)
213
+ const injected = match
214
+ ? html.slice(0, match.index) + tag + html.slice(match.index)
215
+ : tag + html
216
+ return Buffer.from(injected, 'utf8')
217
+ }
218
+
219
+ function serveBridge(bridgePath, res, policy) {
220
+ readFile(bridgePath).then(
221
+ (content) => {
222
+ res.writeHead(200, { 'content-type': 'text/javascript', 'content-length': content.length, 'content-security-policy': policy })
223
+ res.end(content)
224
+ },
225
+ () => {
226
+ res.writeHead(404, { 'content-type': 'text/plain', 'content-security-policy': policy })
227
+ res.end('bridge script not found')
228
+ },
229
+ )
230
+ }
231
+
232
+ // harnessOrigin: the origin the harness page is served at (e.g.
233
+ // https://host:8444). Only it, the pane itself and frameAncestors may frame
234
+ // the pane, only its Referer admits a navigation from another site, and the
235
+ // bridge talks only to it. Without one, nothing else may frame the pane and
236
+ // the mirror is off. Both may be values or functions resolved per request
237
+ // (the conductor derives a port-0 harness's origin once it listens); anything
238
+ // that isn't an http(s) origin is dropped.
239
+ export function createPaneProxy({
240
+ upstreamPort, bridgePath, onActivity = () => {}, httpMod = http, allowedHosts = [], harnessOrigin = null, frameAncestors = [],
241
+ }) {
242
+ // name -> value
243
+ const jar = new Map()
244
+ // A number, or a function resolved per request: the conductor points the
245
+ // pane at whatever port the Provisioner reserved for the running session.
246
+ const portOf = typeof upstreamPort === 'function' ? upstreamPort : () => upstreamPort
247
+ // Likewise: the conductor's pane origins may be assigned after start.
248
+ const hostsOf = typeof allowedHosts === 'function' ? allowedHosts : () => allowedHosts
249
+ const harnessOf = typeof harnessOrigin === 'function' ? harnessOrigin : () => harnessOrigin
250
+ const ancestorsOf = typeof frameAncestors === 'function' ? frameAncestors : () => frameAncestors
251
+
252
+ return function handler(req, res) {
253
+ const harness = originOrNull(harnessOf())
254
+ const policy = panePolicy(harness, ancestorsOf())
255
+ if (!isAllowedHost(req.headers.host, hostsOf())) return misdirected(res, { 'content-security-policy': policy })
256
+
257
+ const refusal = paneRefusal(req, harness)
258
+ if (refusal) {
259
+ res.writeHead(403, {
260
+ 'content-type': 'text/plain; charset=utf-8',
261
+ 'x-content-type-options': 'nosniff',
262
+ 'cache-control': 'no-store',
263
+ 'content-security-policy': policy,
264
+ })
265
+ res.end(`403: ${refusal}\n`)
266
+ return
267
+ }
268
+
269
+ const pathname = (req.url ?? '/').split('?')[0]
270
+ if (req.method === 'GET' && pathname === BRIDGE_ROUTE) {
271
+ serveBridge(bridgePath, res, policy)
272
+ return
273
+ }
274
+
275
+ onActivity()
276
+
277
+ const port = portOf()
278
+ if (!port) {
279
+ res.writeHead(503, { 'content-type': 'text/plain', 'content-security-policy': policy })
280
+ res.end('503: no QA session is running on this pane')
281
+ return
282
+ }
283
+
284
+ const upReq = httpMod.request(
285
+ {
286
+ host: '127.0.0.1',
287
+ port,
288
+ method: req.method,
289
+ path: req.url,
290
+ headers: upstreamHeaders(req, jar),
291
+ },
292
+ (upRes) => {
293
+ const rawSetCookies = upRes.headers['set-cookie'] ?? []
294
+ for (const raw of Array.isArray(rawSetCookies) ? rawSetCookies : [rawSetCookies]) {
295
+ const { name, value, remove } = parseSetCookie(raw)
296
+ if (!name) continue
297
+ if (remove) jar.delete(name)
298
+ else jar.set(name, value)
299
+ }
300
+
301
+ const headers = responseHeaders(upRes, port, policy, req.headers.host)
302
+ const isHtml = /^text\/html\b/i.test(upRes.headers['content-type'] ?? '')
303
+ if (!isHtml) {
304
+ res.writeHead(upRes.statusCode ?? 502, headers)
305
+ upRes.pipe(res)
306
+ return
307
+ }
308
+
309
+ const chunks = []
310
+ upRes.on('data', (chunk) => chunks.push(chunk))
311
+ upRes.on('end', () => {
312
+ const body = injectBridge(Buffer.concat(chunks), bridgeTag(harness))
313
+ headers['content-length'] = body.length
314
+ res.writeHead(upRes.statusCode ?? 502, headers)
315
+ res.end(body)
316
+ })
317
+ },
318
+ )
319
+
320
+ upReq.on('error', () => {
321
+ if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain', 'content-security-policy': policy })
322
+ res.end('502: QA pane upstream unavailable')
323
+ })
324
+
325
+ req.pipe(upReq)
326
+ }
327
+ }
@@ -0,0 +1,95 @@
1
+ // Which page made a request (homefree #307, ported from its #329). The Host
2
+ // allowlist stops DNS rebinding, but not a page the reviewer has open in the
3
+ // same browser: that page can make the browser send requests to the harness
4
+ // and the panes, and a front door that authenticates the device (tailscale
5
+ // serve) vouches for those too. These checks keep such pages out of the
6
+ // harness API and the panes.
7
+ //
8
+ // `same-site` is never enough: on loopback every port is same-site, and
9
+ // ts.net is on the Public Suffix List, so every host in a tailnet, and every
10
+ // port of this one, is same-site as well.
11
+
12
+ import { isLoopbackHost } from './net.mjs'
13
+
14
+ // A Host header's characters: a hostname, an IPv6 literal and a port. Anything
15
+ // else (`@`, `/`) would let the URL parser read a different host from it.
16
+ const HOST_HEADER = /^[a-z0-9.\-[\]:]+$/
17
+
18
+ // True when a browser request comes from the target's own origin, or from
19
+ // no page at all. Browsers send Sec-Fetch-Site (on trustworthy origins) or at
20
+ // least Origin on every write; a request with neither is a non-browser client
21
+ // (curl, a platform's own script).
22
+ export function isSameOriginRequest(headers) {
23
+ const site = headers['sec-fetch-site']
24
+ if (site !== undefined) return site === 'same-origin' || site === 'none'
25
+ if (headers.origin !== undefined) {
26
+ try { return new URL(headers.origin).host === headers.host } catch { return false }
27
+ }
28
+ return true
29
+ }
30
+
31
+ // True unless a browser reached the harness at a host or port other than the
32
+ // harness origin's. A page served by another handler that proxies to the
33
+ // harness (a stale `tailscale serve` mount on another port, say) is
34
+ // same-origin with that handler, so isSameOriginRequest admits its calls;
35
+ // this check doesn't. Exempt:
36
+ // - non-browser clients (neither Sec-Fetch-Site nor Origin);
37
+ // - a null harness origin, which the conductor reports at startup;
38
+ // - a loopback Host: the harness opened at localhost (to show its banner), a
39
+ // harness on port 0 that derives its origin per request, and a nested demo,
40
+ // whose outer pane proxy forwards with a loopback Host.
41
+ export function isHarnessHost(headers, harnessOrigin) {
42
+ if (headers['sec-fetch-site'] === undefined && headers.origin === undefined) return true
43
+ if (harnessOrigin == null) return true
44
+ const host = String(headers.host ?? '').trim().toLowerCase()
45
+ if (!HOST_HEADER.test(host)) return false
46
+ let harness, request
47
+ try {
48
+ harness = new URL(harnessOrigin)
49
+ // Parsed with the harness's scheme, so its default port drops out too.
50
+ request = new URL(`${harness.protocol}//${host}`)
51
+ } catch {
52
+ return false
53
+ }
54
+ return isLoopbackHost(request.hostname) || request.host === harness.host
55
+ }
56
+
57
+ const FRAME_DESTS = new Set(['iframe', 'frame', 'embed', 'object', 'fencedframe'])
58
+ const OTHER_SITES = new Set(['same-site', 'cross-site'])
59
+ const NAVIGATE_MODES = new Set(['navigate', 'nested-navigate'])
60
+
61
+ // The origin a Referer names, or null.
62
+ function refererOrigin(headers) {
63
+ try { return new URL(headers.referer).origin } catch { return null }
64
+ }
65
+
66
+ // Why a pane proxy must refuse this request, or null to serve it. The proxy
67
+ // adds the operator's session from its own cookie jar, so the app's SameSite
68
+ // cookies protect nothing behind it, and the app acts as the operator on
69
+ // every request that reaches it, whether or not the page that made it can
70
+ // read the answer:
71
+ // - writes and preflights must come from the pane's own pages;
72
+ // - so must CORS reads and WebSockets (an app that echoes Origin back would
73
+ // otherwise hand the operator's data to the page that asked);
74
+ // - so must subresource loads (<img>, <script>, <link>, a no-cors fetch):
75
+ // Sec-Fetch-Site same-site or cross-site is refused;
76
+ // - a navigation from another site, into a frame or top-level, must come
77
+ // from the harness, which loads the panes in its iframes and opens them in
78
+ // new tabs: its Referer must be harnessOrigin (browsers send the referring
79
+ // origin by default). frame-ancestors, set on every response, stops only
80
+ // the render, after the app has handled the request.
81
+ // A navigation the operator starts (Sec-Fetch-Site none) is served. A
82
+ // request with no Sec-Fetch-Site (curl, or a browser too old to send it) is
83
+ // judged by its Origin alone.
84
+ export function paneRefusal(req, harnessOrigin = null) {
85
+ const headers = req.headers
86
+ const site = headers['sec-fetch-site']
87
+ const mode = headers['sec-fetch-mode']
88
+ const read = req.method === 'GET' || req.method === 'HEAD'
89
+ const fromPage = !read || mode === 'cors' || mode === 'websocket' || headers.origin !== undefined
90
+ if (fromPage && !isSameOriginRequest(headers)) return 'cross-site request refused'
91
+ if (!OTHER_SITES.has(site)) return null
92
+ if (!NAVIGATE_MODES.has(mode)) return 'cross-site request refused'
93
+ if (harnessOrigin !== null && refererOrigin(headers) === harnessOrigin) return null
94
+ return FRAME_DESTS.has(headers['sec-fetch-dest']) ? 'cross-site framing refused' : 'cross-site navigation refused'
95
+ }