@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/LICENSE +21 -0
- package/README.md +445 -2
- package/bin/qa-conductor-expose.mjs +113 -0
- package/lib/adapters/build-worktree.mjs +532 -0
- package/lib/adapters/exposure-tailscale.mjs +136 -0
- package/lib/adapters/provisioner-docker.mjs +116 -0
- package/lib/adapters/provisioner-process.mjs +653 -0
- package/lib/config.mjs +245 -0
- package/lib/docker.mjs +233 -0
- package/lib/exec.mjs +23 -0
- package/lib/exposure.mjs +193 -0
- package/lib/github.mjs +218 -0
- package/lib/identity.mjs +51 -0
- package/lib/net.mjs +56 -0
- package/lib/proxy.mjs +327 -0
- package/lib/request-guard.mjs +95 -0
- package/lib/server.mjs +721 -0
- package/lib/session.mjs +250 -0
- package/lib/verdict.mjs +33 -0
- package/package.json +47 -4
- package/public/bridge.js +324 -0
- package/public/harness.js +542 -0
- package/public/index.html +284 -0
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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
+
}
|