flowviant 0.55.3 → 0.56.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/bin/lib/authproxy.mjs +173 -17
- package/bin/lib/grant.mjs +182 -0
- package/bin/lib/preview.mjs +14 -2
- package/bin/lib/work.mjs +36 -1
- package/package.json +5 -1
package/bin/lib/authproxy.mjs
CHANGED
|
@@ -13,6 +13,30 @@
|
|
|
13
13
|
* browser re-sends the cached Basic-auth header on same-origin upgrades, so HMR
|
|
14
14
|
* still authenticates.
|
|
15
15
|
*
|
|
16
|
+
* TWO DOORS SINCE 0.56.0. The password above is now the AUTOMATION path (curl,
|
|
17
|
+
* Playwright, a native mobile client); the default for a human is a Flowviant
|
|
18
|
+
* session. A cookie-less browser NAVIGATION is bounced to the app, which checks
|
|
19
|
+
* the visitor is signed in and on the project and hands back an HMAC grant this
|
|
20
|
+
* gate verifies offline (`grant.mjs`) before setting a cookie. Four rules that
|
|
21
|
+
* fall out of that and must survive any edit:
|
|
22
|
+
*
|
|
23
|
+
* - `/__fv/` IS RESERVED on this origin. The callback must be answered here,
|
|
24
|
+
* before the auth check (it is by definition the unauthenticated request
|
|
25
|
+
* that establishes authentication) and before forwarding (or the grant lands
|
|
26
|
+
* in the dev server's access log). The gate therefore stops being a pure
|
|
27
|
+
* pass-through, which is a deliberate, documented loss.
|
|
28
|
+
* - NEVER 302 A NON-NAVIGATION. A 302 is re-issued as GET and silently drops
|
|
29
|
+
* the body, so an unauthenticated POST from the previewed app would become a
|
|
30
|
+
* mystery GET instead of a visible 401. This is also exactly what keeps
|
|
31
|
+
* curl, Playwright and native clients on the password path.
|
|
32
|
+
* - NEVER 302 A WEBSOCKET UPGRADE. Browsers do not follow 3xx on an upgrade,
|
|
33
|
+
* they fail the connection — HMR would break in a way that looks like a dead
|
|
34
|
+
* dev server. A cookie-less upgrade stays a 401.
|
|
35
|
+
* - `SameSite=Lax` is chosen, so this gate CANNOT be embedded in a
|
|
36
|
+
* cross-site iframe. The Workbench must not try; making it work would mean
|
|
37
|
+
* `SameSite=None`, which is a different security posture and a conscious
|
|
38
|
+
* re-argument, not a quiet flag change.
|
|
39
|
+
*
|
|
16
40
|
* Three things this file gets wrong easily, all of them fixed here and all of
|
|
17
41
|
* them worth keeping fixed:
|
|
18
42
|
* - the credential must NOT reach the origin. `headers: req.headers` forwarded
|
|
@@ -20,6 +44,9 @@
|
|
|
20
44
|
* branch happens to be running. It is stripped now.
|
|
21
45
|
* - the comparison is over a secret, so it is constant-time over a digest
|
|
22
46
|
* rather than `===` over a string.
|
|
47
|
+
* - the grant cookie must be stripped from the forwarded request too, and
|
|
48
|
+
* ONLY ours: deleting the whole `cookie` header breaks the driver's own app,
|
|
49
|
+
* which legitimately owns its session cookies.
|
|
23
50
|
* - `stop()` was a bare `server.close()`, which refuses NEW connections and
|
|
24
51
|
* leaves live ones alone — so a held HMR websocket kept the page alive for
|
|
25
52
|
* the one most-engaged viewer after teardown. Live sockets are tracked and
|
|
@@ -28,6 +55,7 @@
|
|
|
28
55
|
|
|
29
56
|
import { createServer, request } from 'node:http';
|
|
30
57
|
import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
|
|
58
|
+
import { GRANT_COOKIE, cookieValues, safePathname, safeRelative, stripCookie, verifyGrant } from './grant.mjs';
|
|
31
59
|
|
|
32
60
|
/** Failed attempts before the proxy stops answering at all. A quick tunnel's
|
|
33
61
|
* hostname is unguessable, so this is not the primary control — it is what
|
|
@@ -56,7 +84,14 @@ function sameSecret(a, b) {
|
|
|
56
84
|
* `onAbuse` fires once, after MAX_FAILED rejected attempts, so the caller can
|
|
57
85
|
* tear the whole share down rather than leaving a URL under attack.
|
|
58
86
|
*/
|
|
59
|
-
export function startAuthProxy({ targetPort, log, onAbuse }) {
|
|
87
|
+
export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId, authorizeUrl }) {
|
|
88
|
+
// ALL THREE OR NONE. Two of the three is a gate that cannot bounce anybody:
|
|
89
|
+
// a secret with no authorize URL has nowhere to send them, an authorize URL
|
|
90
|
+
// with no secret cannot verify what comes back. An older SERVER sends none of
|
|
91
|
+
// them, and that degrades to exactly today's behaviour — password only.
|
|
92
|
+
// A MISSING SECRET MUST NEVER DEGRADE TO OPEN: the mandatory-gate invariant
|
|
93
|
+
// above is unchanged, and a null return still aborts the share.
|
|
94
|
+
const grants = Boolean(grantSecret && shareId && authorizeUrl);
|
|
60
95
|
const user = 'preview';
|
|
61
96
|
// 24 bytes → 32 url-safe chars. It was 9 bytes, chosen when this was an
|
|
62
97
|
// opt-in convenience; it is the only thing between a public hostname and a
|
|
@@ -66,27 +101,60 @@ export function startAuthProxy({ targetPort, log, onAbuse }) {
|
|
|
66
101
|
|
|
67
102
|
let failed = 0;
|
|
68
103
|
let abused = false;
|
|
69
|
-
|
|
70
|
-
|
|
104
|
+
/** The payload of the last validly-signed but EXPIRED grant seen, so a
|
|
105
|
+
* tester can be re-bounced with the token inside it. */
|
|
106
|
+
let lastExpired = null;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A PURE CREDENTIAL PREDICATE — no method, no Accept, no path. That is what
|
|
110
|
+
* lets the websocket upgrade handler reuse it verbatim, and it is why routing
|
|
111
|
+
* decisions live in the request handler instead.
|
|
112
|
+
*
|
|
113
|
+
* Tristate-plus: 'ok' | 'none' | 'expired' | 'forged' | 'badpass'.
|
|
114
|
+
*/
|
|
115
|
+
const credential = (req) => {
|
|
116
|
+
if (abused) return 'badpass';
|
|
71
117
|
if (sameSecret(req.headers['authorization'], expected)) {
|
|
72
118
|
failed = 0;
|
|
73
|
-
return
|
|
119
|
+
return 'ok';
|
|
74
120
|
}
|
|
75
|
-
// A
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
121
|
+
// ONLY A WRONG PASSWORD COUNTS AS AN ATTEMPT, and this is a reason rather
|
|
122
|
+
// than a preference. A forged HMAC is not brute-forceable, so counting it
|
|
123
|
+
// buys nothing — while counting it would hand any stranger who finds the
|
|
124
|
+
// hostname a 25-request KILL SWITCH on the owner's share, because onAbuse
|
|
125
|
+
// tears the whole thing down. An expired-but-validly-signed grant must
|
|
126
|
+
// never count either, or a viewer who left a tab open overnight closes the
|
|
127
|
+
// share on their own reload. MAX_FAILED keeps its exact meaning, which is
|
|
128
|
+
// also why the 'abuse' ended-reason sentence stays true.
|
|
129
|
+
if (req.headers['authorization']) {
|
|
130
|
+
failed += 1;
|
|
131
|
+
if (failed >= MAX_FAILED && !abused) {
|
|
132
|
+
abused = true;
|
|
133
|
+
log?.(`preview gate: ${failed} failed attempts — closing the share.`);
|
|
134
|
+
try {
|
|
135
|
+
onAbuse?.();
|
|
136
|
+
} catch {
|
|
137
|
+
/* the caller's teardown is best-effort */
|
|
138
|
+
}
|
|
85
139
|
}
|
|
140
|
+
return 'badpass';
|
|
86
141
|
}
|
|
87
|
-
|
|
142
|
+
|
|
143
|
+
if (!grants) return 'none';
|
|
144
|
+
// EVERY value for our name, not the first — a duplicate must not shadow.
|
|
145
|
+
for (const raw of cookieValues(req.headers.cookie, GRANT_COOKIE)) {
|
|
146
|
+
const r = verifyGrant(raw, { secret: grantSecret, shareId });
|
|
147
|
+
if (r.ok) return 'ok';
|
|
148
|
+
if (r.reason === 'exp') {
|
|
149
|
+
lastExpired = r.payload;
|
|
150
|
+
return 'expired';
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return String(req.headers.cookie ?? '').includes(GRANT_COOKIE) ? 'forged' : 'none';
|
|
88
154
|
};
|
|
89
155
|
|
|
156
|
+
const authed = (req) => credential(req) === 'ok';
|
|
157
|
+
|
|
90
158
|
// The gate credential is OURS and stops here. Everything else is passed
|
|
91
159
|
// through untouched: the origin is the driver's own dev server and rewriting
|
|
92
160
|
// its request would be us editing their app's input.
|
|
@@ -94,6 +162,11 @@ export function startAuthProxy({ targetPort, log, onAbuse }) {
|
|
|
94
162
|
const headers = { ...req.headers };
|
|
95
163
|
delete headers.authorization;
|
|
96
164
|
delete headers['proxy-authorization'];
|
|
165
|
+
// ONLY OURS. The driver's app owns its own cookies and breaks without them;
|
|
166
|
+
// our grant is a signed bearer token and must not reach branch code.
|
|
167
|
+
const rest = stripCookie(headers.cookie, GRANT_COOKIE);
|
|
168
|
+
if (rest) headers.cookie = rest;
|
|
169
|
+
else delete headers.cookie; // never send a bare empty `cookie:`
|
|
97
170
|
return {
|
|
98
171
|
host: '127.0.0.1',
|
|
99
172
|
port: targetPort,
|
|
@@ -111,14 +184,84 @@ export function startAuthProxy({ targetPort, log, onAbuse }) {
|
|
|
111
184
|
// in a cache the viewer cannot see.
|
|
112
185
|
'Cache-Control': 'no-store',
|
|
113
186
|
});
|
|
114
|
-
res.end(
|
|
187
|
+
res.end(
|
|
188
|
+
grants
|
|
189
|
+
? 'This preview needs a Flowviant session, or the automation password shown in Flowviant.'
|
|
190
|
+
: 'This preview is password-protected. Enter the password shown in Flowviant.'
|
|
191
|
+
);
|
|
115
192
|
};
|
|
116
193
|
|
|
117
194
|
// Every live socket, so stop() can actually end the ones already talking.
|
|
118
195
|
const sockets = new Set();
|
|
119
196
|
|
|
197
|
+
/** A top-level browser navigation, and nothing else. A 302 is re-issued as
|
|
198
|
+
* GET and drops the body, so bouncing a POST would turn an unauthenticated
|
|
199
|
+
* write into a mystery GET; and this is what keeps curl and native clients
|
|
200
|
+
* on the password path. */
|
|
201
|
+
const isBrowserNav = (req) =>
|
|
202
|
+
(req.method === 'GET' || req.method === 'HEAD') &&
|
|
203
|
+
/text\/html/.test(req.headers.accept || '');
|
|
204
|
+
|
|
205
|
+
const noStore = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' };
|
|
206
|
+
|
|
207
|
+
/** Send them to the app to be vouched for. The gate never names its own
|
|
208
|
+
* hostname: it sends the share id, and the SERVER builds the absolute
|
|
209
|
+
* callback from the URL it already stored — so the app never trusts a
|
|
210
|
+
* hostname supplied by the tunnel side. */
|
|
211
|
+
const bounce = (req, res, verdict) => {
|
|
212
|
+
const u = new URL(authorizeUrl);
|
|
213
|
+
u.searchParams.set('s', shareId);
|
|
214
|
+
u.searchParams.set('to', safeRelative(req.url));
|
|
215
|
+
// Re-bounce a tester with the token that was inside their expired grant —
|
|
216
|
+
// they no longer hold the original link, and the server re-checks the hash
|
|
217
|
+
// ONLINE, which is what makes a tester link revocable at all.
|
|
218
|
+
if (verdict === 'expired' && lastExpired?.k === 't' && lastExpired?.r) {
|
|
219
|
+
u.searchParams.set('t', String(lastExpired.r));
|
|
220
|
+
u.searchParams.set('x', '1');
|
|
221
|
+
} else if (verdict === 'expired') {
|
|
222
|
+
u.searchParams.set('x', '1');
|
|
223
|
+
}
|
|
224
|
+
res.writeHead(302, { Location: u.toString(), ...noStore });
|
|
225
|
+
res.end();
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/** The callback. Answered ENTIRELY here — it never touches the origin. */
|
|
229
|
+
const handleCallback = (req, res) => {
|
|
230
|
+
const u = new URL(req.url, 'http://x');
|
|
231
|
+
const r = verifyGrant(u.searchParams.get('g'), { secret: grantSecret, shareId });
|
|
232
|
+
// NOT a redirect: bouncing a failed callback back to the app is how you
|
|
233
|
+
// build an infinite loop out of a clock skew.
|
|
234
|
+
if (!r.ok) return challenge(res);
|
|
235
|
+
const to = safeRelative(u.searchParams.get('to'));
|
|
236
|
+
const maxAge = Math.max(0, r.payload.exp - Math.floor(Date.now() / 1000));
|
|
237
|
+
// The immediate 302 to a clean path is MANDATORY, not cosmetic: it takes
|
|
238
|
+
// `?g=` out of the address bar, out of the Referer every subresource would
|
|
239
|
+
// carry, and out of browser history. It cannot take it out of cloudflared's
|
|
240
|
+
// access log — which is why the grant is short-lived and share-bound.
|
|
241
|
+
res.writeHead(302, {
|
|
242
|
+
'Set-Cookie': `${GRANT_COOKIE}=${r.raw}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`,
|
|
243
|
+
Location: to,
|
|
244
|
+
...noStore,
|
|
245
|
+
});
|
|
246
|
+
res.end();
|
|
247
|
+
};
|
|
248
|
+
|
|
120
249
|
const server = createServer((req, res) => {
|
|
121
|
-
|
|
250
|
+
const path = safePathname(req.url);
|
|
251
|
+
// 1. The callback, BEFORE the auth check and BEFORE any forwarding.
|
|
252
|
+
if (grants && path === '/__fv/cb') return handleCallback(req, res);
|
|
253
|
+
// 2. Reserve the prefix so nothing under it is ever proxied.
|
|
254
|
+
if (path.startsWith('/__fv/')) {
|
|
255
|
+
res.writeHead(404, { 'Content-Type': 'text/plain', ...noStore });
|
|
256
|
+
res.end('not found');
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
// 3. One predicate.
|
|
260
|
+
const verdict = credential(req);
|
|
261
|
+
if (verdict !== 'ok') {
|
|
262
|
+
if (grants && verdict !== 'badpass' && isBrowserNav(req)) return bounce(req, res, verdict);
|
|
263
|
+
return challenge(res);
|
|
264
|
+
}
|
|
122
265
|
const proxyReq = request(forwardOpts(req), (proxyRes) => {
|
|
123
266
|
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
|
|
124
267
|
proxyRes.pipe(res);
|
|
@@ -138,6 +281,16 @@ export function startAuthProxy({ targetPort, log, onAbuse }) {
|
|
|
138
281
|
// WS upgrade (HMR). The browser resends the Basic-auth header on same-origin
|
|
139
282
|
// upgrades, so we gate it too, then pipe the two sockets together.
|
|
140
283
|
server.on('upgrade', (req, socket, head) => {
|
|
284
|
+
// Nothing under the reserved prefix is ever piped to the origin.
|
|
285
|
+
if (safePathname(req.url).startsWith('/__fv/')) {
|
|
286
|
+
socket.destroy();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// NEVER 302 HERE — browsers fail an upgrade rather than following a 3xx, so
|
|
290
|
+
// a bounce would read as a dead dev server. The cookie IS sent on a
|
|
291
|
+
// same-origin handshake, so `authed` works unchanged; a cookie-less upgrade
|
|
292
|
+
// stays a 401 and the page's HMR client reconnects once the human has
|
|
293
|
+
// re-authenticated in the main document.
|
|
141
294
|
if (!authed(req)) {
|
|
142
295
|
socket.write('HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm="Flowviant preview"\r\n\r\n');
|
|
143
296
|
socket.destroy();
|
|
@@ -171,6 +324,9 @@ export function startAuthProxy({ targetPort, log, onAbuse }) {
|
|
|
171
324
|
port,
|
|
172
325
|
user,
|
|
173
326
|
password,
|
|
327
|
+
// What was ACTUALLY installed, reported back so the app never asserts a
|
|
328
|
+
// door nobody observed.
|
|
329
|
+
gateMode: grants ? 'grant' : 'password',
|
|
174
330
|
stop: () => {
|
|
175
331
|
try {
|
|
176
332
|
server.close();
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE GATE'S HALF OF THE GRANT — verify only. This file never mints.
|
|
3
|
+
*
|
|
4
|
+
* A shared preview is served AROUND Flowviant: cloudflared → the gate in
|
|
5
|
+
* `authproxy.mjs` → the dev server the driver started. When the gate meets a
|
|
6
|
+
* browser with no cookie it bounces to the app, which checks the visitor is
|
|
7
|
+
* signed in and on the project, and hands back an HMAC grant signed with the
|
|
8
|
+
* per-share secret the roster gave this machine. The gate verifies that grant
|
|
9
|
+
* HERE, offline, and sets a cookie.
|
|
10
|
+
*
|
|
11
|
+
* OFFLINE IS THE WHOLE DESIGN. The daemon is a pull client with no
|
|
12
|
+
* server→daemon request path, and the gate is listening before cloudflared
|
|
13
|
+
* spawns — so a gate that had to reach api.flowviant.com to answer its first
|
|
14
|
+
* request would either delay the tunnel or open a window where the public
|
|
15
|
+
* hostname is un-gated. It also means a network blip cannot turn somebody's
|
|
16
|
+
* working preview into a 502.
|
|
17
|
+
*
|
|
18
|
+
* THIS FILE IS ONE HALF OF A WIRE CONTRACT. The other half is
|
|
19
|
+
* `apps/api/src/lib/agent-runner/previewGrant.ts` in the Flowviant repo, and
|
|
20
|
+
* the two are tested against the same vectors. A change here that the server
|
|
21
|
+
* does not follow shows up as a preview nobody can open.
|
|
22
|
+
*
|
|
23
|
+
* NO CANONICAL-ENCODING CONTRACT, deliberately: the signature covers the
|
|
24
|
+
* RECEIVED payload string bytes and the JSON is parsed only afterwards. Field
|
|
25
|
+
* order, whitespace and key set are therefore irrelevant to verification, which
|
|
26
|
+
* removes an entire failure mode — an unbootstrappable redirect loop caused by
|
|
27
|
+
* two languages disagreeing about how to serialise an object.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
31
|
+
|
|
32
|
+
export const GRANT_VERSION = 1;
|
|
33
|
+
const DOMAIN = `fvgrant.v${GRANT_VERSION}.`;
|
|
34
|
+
|
|
35
|
+
/** `__Host-` is required rather than decorative: every quick tunnel is a
|
|
36
|
+
* hostname under the shared `trycloudflare.com` parent, so without the prefix
|
|
37
|
+
* a neighbour with their own tunnel could set a Domain-scoped cookie the
|
|
38
|
+
* browser would also send to ours. The prefix makes the browser refuse any
|
|
39
|
+
* Domain attribute and forces host-only + Secure + Path=/. */
|
|
40
|
+
export const GRANT_COOKIE = '__Host-fv_grant';
|
|
41
|
+
|
|
42
|
+
/** Tolerance past `exp`, one direction only. Absorbs ordinary clock drift
|
|
43
|
+
* between the Worker and this box. */
|
|
44
|
+
export const GRANT_SKEW_MS = 60_000;
|
|
45
|
+
|
|
46
|
+
const b64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
47
|
+
|
|
48
|
+
/** Constant-time over sha256 digests, so length never leaks and a missing
|
|
49
|
+
* value costs the same as a wrong one. Never `===` on a signature. */
|
|
50
|
+
function sameDigest(a, b) {
|
|
51
|
+
try {
|
|
52
|
+
const da = createHash('sha256').update(String(a ?? '')).digest();
|
|
53
|
+
const db = createHash('sha256').update(String(b ?? '')).digest();
|
|
54
|
+
return timingSafeEqual(da, db);
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Verify a grant against this share's secret.
|
|
62
|
+
*
|
|
63
|
+
* Returns `{ ok: true, raw, payload }`, or `{ ok: false, reason, payload? }`
|
|
64
|
+
* where reason is one of empty | shape | sig | version | share | exp. The
|
|
65
|
+
* payload rides back on an EXPIRY (and only after the signature checked out) so
|
|
66
|
+
* the caller can re-bounce a tester using the token inside it.
|
|
67
|
+
*/
|
|
68
|
+
export function verifyGrant(raw, { secret, shareId, nowMs, skewMs } = {}) {
|
|
69
|
+
// An empty secret is refused BEFORE anything is hashed. Every share created
|
|
70
|
+
// before this feature has none, and null must read as "no SSO gate here",
|
|
71
|
+
// never as "the empty key".
|
|
72
|
+
if (!raw || !secret) return { ok: false, reason: 'empty' };
|
|
73
|
+
const s = String(raw);
|
|
74
|
+
const dot = s.indexOf('.');
|
|
75
|
+
if (dot <= 0 || dot === s.length - 1) return { ok: false, reason: 'shape' };
|
|
76
|
+
const body = s.slice(0, dot);
|
|
77
|
+
const sig = s.slice(dot + 1);
|
|
78
|
+
if (!/^[A-Za-z0-9_-]+$/.test(body) || !/^[A-Za-z0-9_-]+$/.test(sig)) {
|
|
79
|
+
return { ok: false, reason: 'shape' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const expected = b64url(createHmac('sha256', String(secret)).update(DOMAIN + body).digest());
|
|
83
|
+
if (!sameDigest(sig, expected)) return { ok: false, reason: 'sig' };
|
|
84
|
+
|
|
85
|
+
let payload;
|
|
86
|
+
try {
|
|
87
|
+
payload = JSON.parse(Buffer.from(body.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
|
|
88
|
+
} catch {
|
|
89
|
+
return { ok: false, reason: 'shape' };
|
|
90
|
+
}
|
|
91
|
+
if (!payload || typeof payload !== 'object') return { ok: false, reason: 'shape' };
|
|
92
|
+
if (payload.v !== GRANT_VERSION) return { ok: false, reason: 'version' };
|
|
93
|
+
// Binds the grant to THIS share. One machine may serve several; a grant for
|
|
94
|
+
// one must not open another.
|
|
95
|
+
if (payload.s !== shareId) return { ok: false, reason: 'share' };
|
|
96
|
+
|
|
97
|
+
const now = nowMs ?? Date.now();
|
|
98
|
+
const skew = skewMs ?? GRANT_SKEW_MS;
|
|
99
|
+
if (typeof payload.exp !== 'number' || now > payload.exp * 1000 + skew) {
|
|
100
|
+
return { ok: false, reason: 'exp', payload };
|
|
101
|
+
}
|
|
102
|
+
return { ok: true, raw: s, payload };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* EVERY value for `name` in a Cookie header, not just the first.
|
|
107
|
+
*
|
|
108
|
+
* A `__Host-` cookie cannot be shadowed by a Domain-scoped one, but a page on
|
|
109
|
+
* the origin can still call `document.cookie` and a malformed header can carry
|
|
110
|
+
* a duplicate. Checking only the first match would let a planted value shadow
|
|
111
|
+
* the real one and lock the viewer out; checking all of them cannot.
|
|
112
|
+
*/
|
|
113
|
+
export function cookieValues(header, name) {
|
|
114
|
+
if (!header) return [];
|
|
115
|
+
const out = [];
|
|
116
|
+
for (const part of String(header).split(';')) {
|
|
117
|
+
const eq = part.indexOf('=');
|
|
118
|
+
if (eq < 0) continue;
|
|
119
|
+
if (part.slice(0, eq).trim() !== name) continue;
|
|
120
|
+
out.push(part.slice(eq + 1).trim());
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Drop ONLY our cookie from a Cookie header, preserving everything else.
|
|
127
|
+
*
|
|
128
|
+
* The whole header must not be deleted: the driver's own app legitimately owns
|
|
129
|
+
* its session cookies and would break. And ours must not be forwarded: a signed
|
|
130
|
+
* grant handed to whatever code the branch happens to be running is a bearer
|
|
131
|
+
* token given to the untrusted side of this boundary — the same bug the
|
|
132
|
+
* `authorization` strip already encodes, and that one shipped once.
|
|
133
|
+
*
|
|
134
|
+
* Returns the remaining header, or '' when nothing is left (the caller must
|
|
135
|
+
* then delete the header rather than send an empty one).
|
|
136
|
+
*/
|
|
137
|
+
export function stripCookie(header, name) {
|
|
138
|
+
if (!header) return '';
|
|
139
|
+
const kept = String(header)
|
|
140
|
+
.split(';')
|
|
141
|
+
.filter((part) => {
|
|
142
|
+
const eq = part.indexOf('=');
|
|
143
|
+
const key = eq < 0 ? part.trim() : part.slice(0, eq).trim();
|
|
144
|
+
return key !== name;
|
|
145
|
+
})
|
|
146
|
+
.map((p) => p.trim())
|
|
147
|
+
.filter(Boolean);
|
|
148
|
+
return kept.join('; ');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The pathname of a request, PARSED rather than prefix-matched.
|
|
153
|
+
*
|
|
154
|
+
* `req.url.startsWith('/__fv/cb')` also matches `/__fv/cbXYZ`, and an equality
|
|
155
|
+
* test against `req.url` misses a reordered query string, a bare path with no
|
|
156
|
+
* query, and a fragment. Both mistakes are the difference between the callback
|
|
157
|
+
* being handled and being proxied to somebody's dev server with the grant in
|
|
158
|
+
* its access log.
|
|
159
|
+
*/
|
|
160
|
+
export function safePathname(url) {
|
|
161
|
+
try {
|
|
162
|
+
return new URL(String(url ?? '/'), 'http://x').pathname;
|
|
163
|
+
} catch {
|
|
164
|
+
return '/';
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A destination that cannot be read as an origin.
|
|
170
|
+
*
|
|
171
|
+
* `//evil.com` and `/\evil.com` are protocol-relative to a browser, so a naive
|
|
172
|
+
* "starts with /" check turns the callback into an open redirect on the tunnel
|
|
173
|
+
* origin. Anything that is not a single-slash relative path becomes '/'.
|
|
174
|
+
*/
|
|
175
|
+
export function safeRelative(raw) {
|
|
176
|
+
if (!raw) return '/';
|
|
177
|
+
const s = String(raw);
|
|
178
|
+
if (s.length > 512) return '/';
|
|
179
|
+
if (/[\r\n]/.test(s)) return '/';
|
|
180
|
+
if (!/^\/(?![/\\])/.test(s)) return '/';
|
|
181
|
+
return s;
|
|
182
|
+
}
|
package/bin/lib/preview.mjs
CHANGED
|
@@ -311,7 +311,7 @@ const TAIL_BYTES = 2000;
|
|
|
311
311
|
/**
|
|
312
312
|
* Gate `port` behind a password and publish it on a quick tunnel.
|
|
313
313
|
*
|
|
314
|
-
* Resolves { url, user, password, stop } on success, or { error } — a sentence
|
|
314
|
+
* Resolves { url, user, password, gateMode, stop } on success, or { error } — a sentence
|
|
315
315
|
* from this machine, to be relayed as-is. It never resolves a URL without a
|
|
316
316
|
* password, and it never returns a tunnel whose origin was not listening when
|
|
317
317
|
* we checked.
|
|
@@ -348,6 +348,11 @@ export async function openTunnel({
|
|
|
348
348
|
onTunnelGone,
|
|
349
349
|
stillServing,
|
|
350
350
|
probeMs = 20_000,
|
|
351
|
+
// The members-gate triple, all three or none. Absent = an older server, or a
|
|
352
|
+
// password-mode share: the gate runs exactly as it always has.
|
|
353
|
+
grantSecret,
|
|
354
|
+
shareId,
|
|
355
|
+
authorizeUrl,
|
|
351
356
|
}) {
|
|
352
357
|
// Re-validate at the machine. The server checked this port against the last
|
|
353
358
|
// report; reports are up to a minute old and a dev server is a process a
|
|
@@ -392,6 +397,13 @@ export async function openTunnel({
|
|
|
392
397
|
gate = await startAuthProxy({
|
|
393
398
|
targetPort: port,
|
|
394
399
|
log,
|
|
400
|
+
// Never logged, never written to previews.json, never in the reap
|
|
401
|
+
// signature, never in argv or a child env — /proc/<pid>/cmdline is
|
|
402
|
+
// world-readable and this box also runs the driver's dev server and every
|
|
403
|
+
// CLI turn. In-process only.
|
|
404
|
+
grantSecret,
|
|
405
|
+
shareId,
|
|
406
|
+
authorizeUrl,
|
|
395
407
|
onAbuse: () => {
|
|
396
408
|
stop();
|
|
397
409
|
try {
|
|
@@ -483,7 +495,7 @@ export async function openTunnel({
|
|
|
483
495
|
}
|
|
484
496
|
});
|
|
485
497
|
|
|
486
|
-
finish({ url: m[0], user: gate.user, password: gate.password, stop });
|
|
498
|
+
finish({ url: m[0], user: gate.user, password: gate.password, gateMode: gate.gateMode, stop });
|
|
487
499
|
};
|
|
488
500
|
|
|
489
501
|
tunnel.stdout.on('data', onOut);
|
package/bin/lib/work.mjs
CHANGED
|
@@ -519,8 +519,34 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
519
519
|
}
|
|
520
520
|
|
|
521
521
|
if (!Number.isInteger(port) || port <= 0 || port > 65535) continue;
|
|
522
|
+
|
|
523
|
+
// VALIDATE THE MEMBERS-GATE TRIPLE AT THE BOUNDARY, the way sessionId and
|
|
524
|
+
// port already are — one place doing the check is one deploy away from
|
|
525
|
+
// being zero places. A malformed value is DROPPED rather than errored and
|
|
526
|
+
// the share opens password-only: a gate is never degraded to open, but it
|
|
527
|
+
// is also never left un-opened over a field we could not read.
|
|
528
|
+
const secret = /^[A-Za-z0-9_-]{32,128}$/.test(String(job?.secret ?? ''))
|
|
529
|
+
? String(job.secret)
|
|
530
|
+
: null;
|
|
531
|
+
const shareId = isSafePathSegment(String(job?.shareId ?? '')) ? String(job.shareId) : null;
|
|
532
|
+
let authorizeUrl = null;
|
|
533
|
+
try {
|
|
534
|
+
const u = new URL(String(job?.authorizeUrl ?? ''));
|
|
535
|
+
if (u.protocol === 'https:') authorizeUrl = u.toString();
|
|
536
|
+
} catch {
|
|
537
|
+
/* not a URL — password-only, which is honest */
|
|
538
|
+
}
|
|
539
|
+
// All three or none: two of the three is a gate that cannot bounce.
|
|
540
|
+
const gateOk = Boolean(secret && shareId && authorizeUrl);
|
|
541
|
+
|
|
522
542
|
// Already serving exactly this. Re-opening would replace a working URL
|
|
523
543
|
// somebody may be looking at right now.
|
|
544
|
+
//
|
|
545
|
+
// NOTE this key is (session, port) and NOT the secret. Rotating a secret
|
|
546
|
+
// under a LIVE share is deliberately unsupported: `requestPreview`
|
|
547
|
+
// early-returns on a live row of the same port, so a new secret only ever
|
|
548
|
+
// arrives with a genuinely new row, by which time this map has been
|
|
549
|
+
// cleared. Anyone adding rotation must widen the key first.
|
|
524
550
|
if (livePreviews.get(sessionId)?.port === port) continue;
|
|
525
551
|
if (previewClaiming.has(sessionId)) continue;
|
|
526
552
|
previewClaiming.add(sessionId);
|
|
@@ -566,6 +592,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
566
592
|
// a bare TCP probe green, and the share's URL+password would serve
|
|
567
593
|
// a worktree nobody consented to publish.
|
|
568
594
|
stillServing: async () => listenersIn(wt).some((l) => l.port === port),
|
|
595
|
+
...(gateOk ? { grantSecret: secret, shareId, authorizeUrl } : {}),
|
|
569
596
|
// The gate closed itself after repeated failed passwords. Stored,
|
|
570
597
|
// so the incident is visible — and the entry is dropped so the
|
|
571
598
|
// owner can re-share the port without restarting the daemon.
|
|
@@ -588,7 +615,15 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
588
615
|
return;
|
|
589
616
|
}
|
|
590
617
|
livePreviews.set(sessionId, { port, url: t.url, stop: t.stop });
|
|
591
|
-
|
|
618
|
+
// The gate we ACTUALLY installed, so the app never asserts a door
|
|
619
|
+
// nobody observed. An older server ignores the field.
|
|
620
|
+
await postPreview({
|
|
621
|
+
sessionId,
|
|
622
|
+
url: t.url,
|
|
623
|
+
user: t.user,
|
|
624
|
+
password: t.password,
|
|
625
|
+
gate: t.gateMode,
|
|
626
|
+
});
|
|
592
627
|
} finally {
|
|
593
628
|
previewClaiming.delete(sessionId);
|
|
594
629
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.56.0",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,12 +8,16 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bin",
|
|
11
|
+
"!bin/lib/*.test.mjs",
|
|
11
12
|
"README.md",
|
|
12
13
|
"LICENSE"
|
|
13
14
|
],
|
|
14
15
|
"engines": {
|
|
15
16
|
"node": ">=20"
|
|
16
17
|
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node --test bin/lib/*.test.mjs"
|
|
20
|
+
},
|
|
17
21
|
"dependencies": {
|
|
18
22
|
"@anthropic-ai/claude-agent-sdk": "^0.3.0",
|
|
19
23
|
"libsodium-wrappers-sumo": "^0.8.4",
|