flowviant 0.102.2 → 0.104.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/cli.mjs +87 -474
- package/bin/lib/admission.mjs +19 -3
- package/bin/lib/agentPlan.mjs +2 -2
- package/bin/lib/agentTaskKinds.mjs +93 -0
- package/bin/lib/agySessions.mjs +158 -0
- package/bin/lib/artifactPolicy.mjs +44 -0
- package/bin/lib/artifactPreview.mjs +52 -0
- package/bin/lib/artifactReporter.mjs +163 -0
- package/bin/lib/artifacts.mjs +47 -227
- package/bin/lib/authReject.mjs +16 -4
- package/bin/lib/authproxy.mjs +47 -304
- package/bin/lib/boxIdentity.mjs +245 -0
- package/bin/lib/childEnv.mjs +11 -9
- package/bin/lib/claude.mjs +14 -851
- package/bin/lib/claudeAuth.mjs +3 -3
- package/bin/lib/claudePosture.mjs +367 -0
- package/bin/lib/claudeSessions.mjs +411 -0
- package/bin/lib/claudeStream.mjs +207 -0
- package/bin/lib/cliLimit.mjs +34 -0
- package/bin/lib/commandSpecs.mjs +173 -0
- package/bin/lib/config.mjs +11 -42
- package/bin/lib/credentialDate.mjs +22 -0
- package/bin/lib/credentials.mjs +21 -26
- package/bin/lib/daemonLogging.mjs +55 -0
- package/bin/lib/daemonState.mjs +64 -0
- package/bin/lib/deploy.mjs +148 -647
- package/bin/lib/deployCheckout.mjs +265 -0
- package/bin/lib/deployConfig.mjs +142 -0
- package/bin/lib/deployRunner.mjs +311 -0
- package/bin/lib/deployWire.mjs +41 -0
- package/bin/lib/desktopContract.mjs +56 -105
- package/bin/lib/env.mjs +49 -484
- package/bin/lib/fleet.mjs +184 -2396
- package/bin/lib/fleetJobs.mjs +120 -0
- package/bin/lib/fleetPost.mjs +40 -0
- package/bin/lib/fleetReports.mjs +392 -0
- package/bin/lib/fleetRoster.mjs +470 -0
- package/bin/lib/fleetStartup.mjs +196 -0
- package/bin/lib/fleetWake.mjs +45 -0
- package/bin/lib/fleetWire.mjs +46 -0
- package/bin/lib/git.mjs +34 -3
- package/bin/lib/gitMergeIdentity.mjs +51 -0
- package/bin/lib/grant.mjs +5 -3
- package/bin/lib/help.mjs +3 -125
- package/bin/lib/holder.mjs +482 -0
- package/bin/lib/instance.mjs +124 -46
- package/bin/lib/knowledge.mjs +16 -799
- package/bin/lib/knowledgeFetch.mjs +38 -0
- package/bin/lib/knowledgeFileSync.mjs +112 -0
- package/bin/lib/knowledgeFiles.mjs +487 -0
- package/bin/lib/knowledgeLibrary.mjs +249 -0
- package/bin/lib/landed.mjs +2 -1
- package/bin/lib/listeners.mjs +99 -141
- package/bin/lib/localSessions.mjs +30 -555
- package/bin/lib/login.mjs +3 -2
- package/bin/lib/loginPath.mjs +3 -5
- package/bin/lib/machineDisconnect.mjs +203 -0
- package/bin/lib/machineEnv.mjs +22 -0
- package/bin/lib/machineLimits.mjs +61 -0
- package/bin/lib/machineListing.mjs +338 -0
- package/bin/lib/machines.mjs +78 -455
- package/bin/lib/machinesCommand.mjs +214 -0
- package/bin/lib/mcp-cli.mjs +2 -1
- package/bin/lib/patchRevert.mjs +60 -0
- package/bin/lib/prWorkflow.mjs +157 -0
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/pressurePolicy.mjs +104 -0
- package/bin/lib/preview.mjs +2 -272
- package/bin/lib/previewFramePolicy.mjs +112 -0
- package/bin/lib/previewGatePolicy.mjs +236 -0
- package/bin/lib/previewRegistry.mjs +235 -0
- package/bin/lib/printable.mjs +2 -2
- package/bin/lib/procRegistry.mjs +28 -18
- package/bin/lib/procScan.mjs +146 -0
- package/bin/lib/processes.mjs +29 -37
- package/bin/lib/projectToolRuntimes.mjs +138 -0
- package/bin/lib/projectTools.mjs +13 -88
- package/bin/lib/prompts.mjs +28 -42
- package/bin/lib/repoPath.mjs +26 -0
- package/bin/lib/resources.mjs +7 -69
- package/bin/lib/resumeLost.mjs +107 -0
- package/bin/lib/runTurn.mjs +324 -0
- package/bin/lib/runtimeAntigravity.mjs +230 -0
- package/bin/lib/runtimeCapabilities.mjs +334 -0
- package/bin/lib/runtimeClaude.mjs +126 -0
- package/bin/lib/runtimeCodex.mjs +217 -0
- package/bin/lib/runtimeDetection.mjs +121 -0
- package/bin/lib/runtimeEvents.mjs +482 -0
- package/bin/lib/runtimeOfferings.mjs +4 -1
- package/bin/lib/runtimes.mjs +42 -1402
- package/bin/lib/safeFileName.mjs +74 -0
- package/bin/lib/shipMerge.mjs +33 -2
- package/bin/lib/standDownExit.mjs +181 -0
- package/bin/lib/standDownGate.mjs +48 -0
- package/bin/lib/startProject.mjs +279 -0
- package/bin/lib/trace.mjs +3 -3
- package/bin/lib/turnLock.mjs +68 -0
- package/bin/lib/turnProfile.mjs +77 -0
- package/bin/lib/uninstall.mjs +55 -45
- package/bin/lib/uninstallCopies.mjs +112 -0
- package/bin/lib/update.mjs +30 -17
- package/bin/lib/uplinkScrub.mjs +265 -0
- package/bin/lib/vault.mjs +22 -151
- package/bin/lib/vaultDiff.mjs +183 -0
- package/bin/lib/versionOrder.mjs +42 -0
- package/bin/lib/views.mjs +35 -12
- package/bin/lib/wikiRunner.mjs +630 -0
- package/bin/lib/work.mjs +188 -3037
- package/bin/lib/workAdoptCarry.mjs +94 -0
- package/bin/lib/workAgentCheck.mjs +278 -0
- package/bin/lib/workAgentMerges.mjs +54 -142
- package/bin/lib/workAgentPlans.mjs +14 -12
- package/bin/lib/workAgentPrecheck.mjs +393 -0
- package/bin/lib/workAgentPublish.mjs +177 -0
- package/bin/lib/workAgentReview.mjs +8 -666
- package/bin/lib/workAgentTurnBegun.mjs +109 -0
- package/bin/lib/workAgentTurnExecution.mjs +604 -0
- package/bin/lib/workAgentTurnOutcome.mjs +207 -0
- package/bin/lib/workAgentTurnReports.mjs +189 -0
- package/bin/lib/workAgentTurns.mjs +92 -897
- package/bin/lib/workAttachments.mjs +93 -0
- package/bin/lib/workBrain.mjs +53 -0
- package/bin/lib/workChildren.mjs +110 -0
- package/bin/lib/workCommandAudit.mjs +62 -0
- package/bin/lib/workDiffs.mjs +2 -1
- package/bin/lib/workNarration.mjs +152 -0
- package/bin/lib/workPlaceReports.mjs +426 -0
- package/bin/lib/workPlaces.mjs +228 -0
- package/bin/lib/workPreviews.mjs +31 -9
- package/bin/lib/workProcesses.mjs +4 -5
- package/bin/lib/workPullRequests.mjs +52 -126
- package/bin/lib/workReportQueue.mjs +254 -0
- package/bin/lib/workRetire.mjs +185 -0
- package/bin/lib/workSessionGroups.mjs +166 -0
- package/bin/lib/workSessionRuntime.mjs +164 -0
- package/bin/lib/workSessionTokens.mjs +68 -0
- package/bin/lib/workSessionTurns.mjs +892 -0
- package/bin/lib/workShip.mjs +9 -29
- package/bin/lib/workToolLog.mjs +76 -0
- package/package.json +3 -2
- package/bin/lib/patch.mjs +0 -363
package/bin/lib/authproxy.mjs
CHANGED
|
@@ -36,41 +36,23 @@
|
|
|
36
36
|
* old "cannot be embedded, the Workbench must not try" rule deliberately —
|
|
37
37
|
* the Workbench now DOES frame the share, so both halves are re-argued
|
|
38
38
|
* here). Two changes, one per blocker:
|
|
39
|
-
* (a) THE COOKIE.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* rather than always sending both attributes matters: Safari 18.5–26.1
|
|
47
|
-
* DROPPED any Set-Cookie carrying `Partitioned` outright, so stamping it
|
|
48
|
-
* unconditionally would have broken the working top-level path on those
|
|
49
|
-
* builds. A browser too old to send Sec-Fetch-Dest gets Lax and the
|
|
50
|
-
* frame fails exactly as it always did — Open remains the escape.
|
|
51
|
-
* AND THE PARTITIONING CLAIM HAS A BACKSTOP, because one browser class
|
|
52
|
-
* breaks it: Chromium 76–113 (and same-vintage webviews/forks) sends
|
|
53
|
-
* Sec-Fetch-Dest — so it takes this branch — while ignoring the
|
|
54
|
-
* `Partitioned` attribute it has never heard of, storing a PLAIN
|
|
55
|
-
* unpartitioned SameSite=None cookie: the exact CSRF hole the sentence
|
|
56
|
-
* above says partitioning prevents. So `crossSiteAbuse` refuses a
|
|
57
|
-
* GRANT-authenticated request that Fetch Metadata marks cross-site
|
|
58
|
-
* unless it is a top-level GET/HEAD navigation (the Open link, the
|
|
59
|
-
* frame's own src). Every browser in the vulnerable class sends
|
|
60
|
-
* Sec-Fetch-Site (it shipped alongside Dest), a browser sending
|
|
61
|
-
* neither never got a None cookie in the first place, and the password
|
|
62
|
-
* path is exempt — Basic auth is never attached cross-site by a
|
|
63
|
-
* browser, so curl and Playwright feel nothing.
|
|
39
|
+
* (a) THE COOKIE. A top-level visit's grant cookie stays `SameSite=Lax`;
|
|
40
|
+
* a framed navigation gets a partitioned one. The attribute and its
|
|
41
|
+
* argument (CHIPS, the Safari Partitioned drop) live in
|
|
42
|
+
* `previewFramePolicy.mjs` (`grantCookieSite`); its CSRF backstop is
|
|
43
|
+
* `crossSiteAbuse` in `previewGatePolicy.mjs`. What stays here is only
|
|
44
|
+
* the server's own callback, which asks `grantCookieSite` with the
|
|
45
|
+
* request's `Sec-Fetch-Dest` when it sets the cookie.
|
|
64
46
|
* (b) THE FRAME POLICY. In grant mode the forwarded response's frame policy
|
|
65
|
-
* is REWRITTEN to `frame-ancestors 'self' <app origin
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
47
|
+
* is REWRITTEN to `frame-ancestors 'self' <app origin>`; password-only
|
|
48
|
+
* mode rewrites NOTHING. The rule and its argument live in
|
|
49
|
+
* `previewFramePolicy.mjs` (`rewriteFramePolicy`).
|
|
50
|
+
*
|
|
51
|
+
* WHO GETS IN — the credential predicate, the per-source and global failure
|
|
52
|
+
* counts, the cross-site backstop and the admission order both transports ask
|
|
53
|
+
* — is `previewGatePolicy.mjs` (split out 2026-09-26, SOLID SRP). This file is
|
|
54
|
+
* the server: it answers the callback, forwards HTTP, pipes upgrades and owns
|
|
55
|
+
* every socket's lifetime.
|
|
74
56
|
*
|
|
75
57
|
* Three things this file gets wrong easily, all of them fixed here and all of
|
|
76
58
|
* them worth keeping fixed:
|
|
@@ -78,7 +60,8 @@
|
|
|
78
60
|
* `authorization` verbatim, handing the gate password to whatever code the
|
|
79
61
|
* branch happens to be running. It is stripped now.
|
|
80
62
|
* - the comparison is over a secret, so it is constant-time over a digest
|
|
81
|
-
* rather than `===` over a string.
|
|
63
|
+
* rather than `===` over a string (`sameSecret`, `grant.mjs`, asked by
|
|
64
|
+
* `previewGatePolicy.mjs`).
|
|
82
65
|
* - the grant cookie must be stripped from the forwarded request too, and
|
|
83
66
|
* ONLY ours: deleting the whole `cookie` header breaks the driver's own app,
|
|
84
67
|
* which legitimately owns its session cookies.
|
|
@@ -89,46 +72,10 @@
|
|
|
89
72
|
*/
|
|
90
73
|
|
|
91
74
|
import { createServer, request } from 'node:http';
|
|
92
|
-
import { randomBytes
|
|
93
|
-
import { GRANT_COOKIE,
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
* attempts are refused outright. A quick tunnel's hostname is unguessable,
|
|
97
|
-
* so this is not the primary control — but it is KNOWN to every past
|
|
98
|
-
* member, tester and password recipient, whose access cannot be recalled.
|
|
99
|
-
* The threshold is per source because one global counter made 25 wrong
|
|
100
|
-
* guesses from any URL holder a kill switch on everyone else's share. */
|
|
101
|
-
const MAX_FAILED = 25;
|
|
102
|
-
|
|
103
|
-
/** Counted failures across ALL sources before the whole share tears down.
|
|
104
|
-
* The per-source block above answers a single abuser; this answers a
|
|
105
|
-
* DISTRIBUTED guessing run (addresses rotating to stay under MAX_FAILED
|
|
106
|
-
* each) — the original self-closing-incident property, kept at a threshold
|
|
107
|
-
* ordinary use cannot reach: a blocked source stops counting, so getting
|
|
108
|
-
* here takes eight independent sources each burning their full allowance. */
|
|
109
|
-
const MAX_FAILED_TOTAL = 200;
|
|
110
|
-
|
|
111
|
-
/** Bound on the per-source map — a rotating attacker must not grow daemon
|
|
112
|
-
* memory without limit. Eviction is oldest-first and CAN un-block an evicted
|
|
113
|
-
* source, but cycling 500 fresh sources costs at least 500 counted failures,
|
|
114
|
-
* and the global backstop closes the share long before that. */
|
|
115
|
-
const MAX_SOURCES = 500;
|
|
116
|
-
|
|
117
|
-
const digest = (s) => createHash('sha256').update(String(s)).digest();
|
|
118
|
-
|
|
119
|
-
/** An Authorization header in the Basic scheme — the only shape the gate's own
|
|
120
|
-
* password ever arrives in, and so the only one it grades. */
|
|
121
|
-
const isBasic = (v) => typeof v === 'string' && /^\s*basic(\s|$)/i.test(v);
|
|
122
|
-
|
|
123
|
-
/** Constant-time over sha256 digests, so length never leaks and a missing
|
|
124
|
-
* header costs the same as a wrong one. */
|
|
125
|
-
function sameSecret(a, b) {
|
|
126
|
-
try {
|
|
127
|
-
return timingSafeEqual(digest(a ?? ''), digest(b ?? ''));
|
|
128
|
-
} catch {
|
|
129
|
-
return false;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
75
|
+
import { randomBytes } from 'node:crypto';
|
|
76
|
+
import { GRANT_COOKIE, safePathname, safeRelative, stripCookie, verifyGrant } from './grant.mjs';
|
|
77
|
+
import { createGatePolicy, isBrowserNav } from './previewGatePolicy.mjs';
|
|
78
|
+
import { appOriginOf, grantCookieSite, rewriteFramePolicy } from './previewFramePolicy.mjs';
|
|
132
79
|
|
|
133
80
|
/**
|
|
134
81
|
* Start the gate in front of a dev server on `targetPort`. Resolves
|
|
@@ -139,7 +86,7 @@ function sameSecret(a, b) {
|
|
|
139
86
|
* `onAbuse` fires once, after MAX_FAILED_TOTAL rejected attempts across all
|
|
140
87
|
* sources, so the caller can tear the whole share down rather than leaving a
|
|
141
88
|
* URL under attack. A single source is blocked on its own, at MAX_FAILED,
|
|
142
|
-
* without ending anybody else's share.
|
|
89
|
+
* without ending anybody else's share (`previewGatePolicy.mjs`).
|
|
143
90
|
*/
|
|
144
91
|
export function startAuthProxy({
|
|
145
92
|
targetPort,
|
|
@@ -167,204 +114,11 @@ export function startAuthProxy({
|
|
|
167
114
|
const password = randomBytes(24).toString('base64url');
|
|
168
115
|
const expected = 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64');
|
|
169
116
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const failedBySource = new Map();
|
|
176
|
-
/** The payload of the last validly-signed but EXPIRED grant seen, so a
|
|
177
|
-
* tester can be re-bounced with the token inside it. */
|
|
178
|
-
let lastExpired = null;
|
|
179
|
-
|
|
180
|
-
/** cloudflared forwards the real client address in Cf-Connecting-Ip; a
|
|
181
|
-
* direct local connection (the machine's own curl, the tests) has only the
|
|
182
|
-
* socket. The header is attacker-writable in principle, but lying in it
|
|
183
|
-
* only SPREADS one attacker across per-source counters — which is exactly
|
|
184
|
-
* the shape MAX_FAILED_TOTAL exists to answer. */
|
|
185
|
-
const sourceOf = (req) =>
|
|
186
|
-
String(req.headers['cf-connecting-ip'] || req.socket?.remoteAddress || 'unknown');
|
|
187
|
-
|
|
188
|
-
const sourceBlocked = (req) => (failedBySource.get(sourceOf(req)) ?? 0) >= MAX_FAILED;
|
|
189
|
-
|
|
190
|
-
const noteFailure = (req) => {
|
|
191
|
-
const src = sourceOf(req);
|
|
192
|
-
const count = (failedBySource.get(src) ?? 0) + 1;
|
|
193
|
-
// Delete-then-set so Map insertion order tracks recency, making the
|
|
194
|
-
// eviction below an LRU rather than "whoever failed first".
|
|
195
|
-
failedBySource.delete(src);
|
|
196
|
-
failedBySource.set(src, count);
|
|
197
|
-
if (failedBySource.size > MAX_SOURCES) {
|
|
198
|
-
failedBySource.delete(failedBySource.keys().next().value);
|
|
199
|
-
}
|
|
200
|
-
if (count === MAX_FAILED) {
|
|
201
|
-
log?.(`preview gate: ${count} failed attempts from ${src} — refusing that source.`);
|
|
202
|
-
}
|
|
203
|
-
// MONOTONE, never reset by a success: a distributed run has no successes
|
|
204
|
-
// to hide behind, and a legitimate share cannot reach the backstop —
|
|
205
|
-
// every source stops counting at MAX_FAILED, so 200 needs eight distinct
|
|
206
|
-
// sources each exhausting their own allowance.
|
|
207
|
-
failedTotal += 1;
|
|
208
|
-
if (failedTotal >= MAX_FAILED_TOTAL && !abused) {
|
|
209
|
-
abused = true;
|
|
210
|
-
log?.(`preview gate: ${failedTotal} failed attempts across sources — closing the share.`);
|
|
211
|
-
try {
|
|
212
|
-
onAbuse?.();
|
|
213
|
-
} catch {
|
|
214
|
-
/* the caller's teardown is best-effort */
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
};
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* A PURE CREDENTIAL PREDICATE — no method, no Accept, no path. That is what
|
|
221
|
-
* lets the websocket upgrade handler reuse it verbatim, and it is why routing
|
|
222
|
-
* decisions live in the request handler instead.
|
|
223
|
-
*
|
|
224
|
-
* Tristate-plus: 'ok' | 'none' | 'expired' | 'forged' | 'badpass' | 'blocked'.
|
|
225
|
-
*/
|
|
226
|
-
const credential = (req) => {
|
|
227
|
-
if (abused) return 'badpass';
|
|
228
|
-
// ONLY A WRONG PASSWORD COUNTS AS AN ATTEMPT, and this is a reason rather
|
|
229
|
-
// than a preference. A forged HMAC is not brute-forceable, so counting it
|
|
230
|
-
// buys nothing — while counting it would hand any stranger who finds the
|
|
231
|
-
// hostname a kill switch on the owner's share, because onAbuse tears the
|
|
232
|
-
// whole thing down. An expired-but-validly-signed grant must never count
|
|
233
|
-
// either, or a viewer who left a tab open overnight closes the share on
|
|
234
|
-
// their own reload.
|
|
235
|
-
//
|
|
236
|
-
// AND ONLY A *BASIC* HEADER IS A PASSWORD ATTEMPT (audit 2026-09-24). Any
|
|
237
|
-
// Authorization header used to be graded against the gate password, so a
|
|
238
|
-
// previewed SPA calling its own `/api` with `Authorization: Bearer …` was
|
|
239
|
-
// answered 401 with a Basic challenge — over a valid grant cookie — and 25
|
|
240
|
-
// such calls blocked that viewer, eight viewers tore the share down, and
|
|
241
|
-
// the origin never saw its own header. A Bearer (or any other scheme) is
|
|
242
|
-
// the APP's credential: it is not ours to grade and it rides through.
|
|
243
|
-
//
|
|
244
|
-
// A VALID GRANT COOKIE IS ASKED FIRST, for the same reason: an app that
|
|
245
|
-
// itself speaks Basic sends a header that is not our password, and a
|
|
246
|
-
// viewer the app has vouched for must not be graded on it. A grant is
|
|
247
|
-
// HMAC-verified, so asking it first opens no brute-force path.
|
|
248
|
-
let grantVerdict = 'none';
|
|
249
|
-
if (grants) {
|
|
250
|
-
// EVERY value for our name, not the first — a duplicate must not shadow.
|
|
251
|
-
for (const raw of cookieValues(req.headers.cookie, GRANT_COOKIE)) {
|
|
252
|
-
const r = verifyGrant(raw, { secret: grantSecret, shareId });
|
|
253
|
-
if (r.ok) return 'ok';
|
|
254
|
-
if (r.reason === 'exp' && grantVerdict === 'none') {
|
|
255
|
-
lastExpired = r.payload;
|
|
256
|
-
grantVerdict = 'expired';
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
if (grantVerdict === 'none' && String(req.headers.cookie ?? '').includes(GRANT_COOKIE)) {
|
|
260
|
-
grantVerdict = 'forged';
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
if (isBasic(req.headers['authorization'])) {
|
|
265
|
-
// A blocked source is refused BEFORE the comparison — a block that
|
|
266
|
-
// still grades guesses would let the brute force run to a correct hit.
|
|
267
|
-
if (sourceBlocked(req)) return 'blocked';
|
|
268
|
-
if (sameSecret(req.headers['authorization'], expected)) {
|
|
269
|
-
// Per-source only: a shared NAT recovers when one person behind it
|
|
270
|
-
// gets the password right; failedTotal stays monotone (see above).
|
|
271
|
-
failedBySource.delete(sourceOf(req));
|
|
272
|
-
return 'ok';
|
|
273
|
-
}
|
|
274
|
-
noteFailure(req);
|
|
275
|
-
return 'badpass';
|
|
276
|
-
}
|
|
277
|
-
return grantVerdict;
|
|
278
|
-
};
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* The CSRF backstop for the partitioned cookie (header rule 4a): a browser
|
|
282
|
-
* old enough to ignore `Partitioned` while honouring `SameSite=None` will
|
|
283
|
-
* attach the grant to cross-site requests, so a request that FETCH METADATA
|
|
284
|
-
* says is cross-site is refused unless it is a plain navigation GET/HEAD —
|
|
285
|
-
* the two shapes this product legitimately serves cross-site (the Open
|
|
286
|
-
* link, the Workbench frame's own src; a rendered attacker frame is then
|
|
287
|
-
* stopped by the frame-ancestors rewrite). Applies ONLY to grant-cookie
|
|
288
|
-
* auth: a password in an Authorization header is never attached cross-site
|
|
289
|
-
* by a browser, and clients that send no Sec-Fetch headers never held a
|
|
290
|
-
* None cookie, so absence stays permitted.
|
|
291
|
-
*/
|
|
292
|
-
const crossSiteAbuse = (req) => {
|
|
293
|
-
if (String(req.headers['sec-fetch-site'] || '').toLowerCase() !== 'cross-site') return false;
|
|
294
|
-
const mode = String(req.headers['sec-fetch-mode'] || '').toLowerCase();
|
|
295
|
-
// Cross-site fetch/XHR/img/script with the cookie — nothing legitimate
|
|
296
|
-
// looks like this: the framed app's own subresources are same-origin.
|
|
297
|
-
if (mode && mode !== 'navigate') return true;
|
|
298
|
-
// A cross-site POST navigation is the classic auto-submitted CSRF form.
|
|
299
|
-
return !(req.method === 'GET' || req.method === 'HEAD');
|
|
300
|
-
};
|
|
301
|
-
|
|
302
|
-
/** True when THIS request authenticated with the password rather than the
|
|
303
|
-
* cookie — the credential class CSRF cannot ride. */
|
|
304
|
-
const viaPassword = (req) => sameSecret(req.headers['authorization'], expected);
|
|
305
|
-
|
|
306
|
-
/** The one app fact this gate holds: the origin allowed to frame us. Derived
|
|
307
|
-
* from `authorizeUrl` (server-built from PUBLIC_APP_URL) rather than a new
|
|
308
|
-
* wire field, so an older server changes nothing and no floor is needed. */
|
|
309
|
-
let appOrigin = null;
|
|
310
|
-
try {
|
|
311
|
-
if (grants) appOrigin = new URL(authorizeUrl).origin;
|
|
312
|
-
} catch {
|
|
313
|
-
appOrigin = null;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* Replace the origin's frame policy with ours: `frame-ancestors 'self'
|
|
318
|
-
* <app origin>`. Rules that must survive any edit:
|
|
319
|
-
* - REWRITE the directive inside an existing enforced CSP, never append a
|
|
320
|
-
* second policy beside it — multiple CSP headers intersect, so an origin
|
|
321
|
-
* `frame-ancestors 'none'` would still win over anything we added.
|
|
322
|
-
* - APPEND a policy holding only our directive when the origin stated no
|
|
323
|
-
* frame-ancestors at all — a public tunnel hostname deserves a frame
|
|
324
|
-
* policy even when localhost never needed one.
|
|
325
|
-
* - LEAVE Report-Only alone: browsers ignore frame-ancestors there, and
|
|
326
|
-
* rewriting a report channel would be editing the driver's telemetry.
|
|
327
|
-
* - DELETE X-Frame-Options: an enforced frame-ancestors makes every current
|
|
328
|
-
* engine ignore it anyway; deleting is for the stragglers.
|
|
329
|
-
*/
|
|
330
|
-
const framePolicy = () => `frame-ancestors 'self' ${appOrigin}`;
|
|
331
|
-
const rewriteFramePolicy = (headers) => {
|
|
332
|
-
if (!grants || !appOrigin) return headers;
|
|
333
|
-
const out = { ...headers };
|
|
334
|
-
delete out['x-frame-options'];
|
|
335
|
-
let sawDirective = false;
|
|
336
|
-
// Node joins duplicate response headers with ', ', so one string can hold
|
|
337
|
-
// SEVERAL policies (comma-separated), each holding several directives
|
|
338
|
-
// (semicolon-separated). Split on BOTH levels or replacing a directive
|
|
339
|
-
// eats the tail of a neighbouring policy — CSP source lists never contain
|
|
340
|
-
// a comma, so the outer split is safe.
|
|
341
|
-
const rewriteOne = (v) =>
|
|
342
|
-
String(v)
|
|
343
|
-
.split(',')
|
|
344
|
-
.map((policy) =>
|
|
345
|
-
policy
|
|
346
|
-
.split(';')
|
|
347
|
-
.map((part) => {
|
|
348
|
-
if (/^\s*frame-ancestors(\s|$)/i.test(part)) {
|
|
349
|
-
sawDirective = true;
|
|
350
|
-
return ` ${framePolicy()}`;
|
|
351
|
-
}
|
|
352
|
-
return part;
|
|
353
|
-
})
|
|
354
|
-
.join(';')
|
|
355
|
-
)
|
|
356
|
-
.join(',');
|
|
357
|
-
const csp = out['content-security-policy'];
|
|
358
|
-
if (csp !== undefined) {
|
|
359
|
-
out['content-security-policy'] = Array.isArray(csp) ? csp.map(rewriteOne) : rewriteOne(csp);
|
|
360
|
-
}
|
|
361
|
-
if (!sawDirective) {
|
|
362
|
-
const existing = out['content-security-policy'];
|
|
363
|
-
if (existing === undefined) out['content-security-policy'] = framePolicy();
|
|
364
|
-
else out['content-security-policy'] = Array.isArray(existing) ? [...existing, framePolicy()] : [existing, framePolicy()];
|
|
365
|
-
}
|
|
366
|
-
return out;
|
|
367
|
-
};
|
|
117
|
+
// Who gets in, and this share's failure counts (`previewGatePolicy.mjs`).
|
|
118
|
+
const policy = createGatePolicy({ grants, grantSecret, shareId, expected, log, onAbuse });
|
|
119
|
+
// The one origin allowed to frame us, null in password-only mode
|
|
120
|
+
// (`previewFramePolicy.mjs`).
|
|
121
|
+
const appOrigin = appOriginOf(grants, authorizeUrl);
|
|
368
122
|
|
|
369
123
|
// The gate credential is OURS and stops here. Everything else is passed
|
|
370
124
|
// through untouched: the origin is the driver's own dev server and rewriting
|
|
@@ -374,7 +128,7 @@ export function startAuthProxy({
|
|
|
374
128
|
// Only when it IS the gate credential. Anything else in Authorization is
|
|
375
129
|
// the previewed app's own (a Bearer to its API, its own Basic realm) and
|
|
376
130
|
// the app breaks without it; ours must still never reach branch code.
|
|
377
|
-
if (
|
|
131
|
+
if (policy.viaPassword(req)) delete headers.authorization;
|
|
378
132
|
delete headers['proxy-authorization'];
|
|
379
133
|
// ONLY OURS. The driver's app owns its own cookies and breaks without them;
|
|
380
134
|
// our grant is a signed bearer token and must not reach branch code.
|
|
@@ -408,14 +162,6 @@ export function startAuthProxy({
|
|
|
408
162
|
// Every live socket, so stop() can actually end the ones already talking.
|
|
409
163
|
const sockets = new Set();
|
|
410
164
|
|
|
411
|
-
/** A top-level browser navigation, and nothing else. A 302 is re-issued as
|
|
412
|
-
* GET and drops the body, so bouncing a POST would turn an unauthenticated
|
|
413
|
-
* write into a mystery GET; and this is what keeps curl and native clients
|
|
414
|
-
* on the password path. */
|
|
415
|
-
const isBrowserNav = (req) =>
|
|
416
|
-
(req.method === 'GET' || req.method === 'HEAD') &&
|
|
417
|
-
/text\/html/.test(req.headers.accept || '');
|
|
418
|
-
|
|
419
165
|
const noStore = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' };
|
|
420
166
|
|
|
421
167
|
/** Send them to the app to be vouched for. The gate never names its own
|
|
@@ -429,6 +175,7 @@ export function startAuthProxy({
|
|
|
429
175
|
// Re-bounce a tester with the token that was inside their expired grant —
|
|
430
176
|
// they no longer hold the original link, and the server re-checks the hash
|
|
431
177
|
// ONLINE, which is what makes a tester link revocable at all.
|
|
178
|
+
const lastExpired = policy.lastExpired();
|
|
432
179
|
if (verdict === 'expired' && lastExpired?.k === 't' && lastExpired?.r) {
|
|
433
180
|
u.searchParams.set('t', String(lastExpired.r));
|
|
434
181
|
u.searchParams.set('x', '1');
|
|
@@ -448,12 +195,8 @@ export function startAuthProxy({
|
|
|
448
195
|
if (!r.ok) return challenge(res);
|
|
449
196
|
const to = safeRelative(u.searchParams.get('to'));
|
|
450
197
|
const maxAge = Math.max(0, r.payload.exp - Math.floor(Date.now() / 1000));
|
|
451
|
-
// FRAMED means Partitioned (header rule 4a).
|
|
452
|
-
|
|
453
|
-
// callback sees `iframe` exactly when the Workbench is the one asking.
|
|
454
|
-
const dest = String(req.headers['sec-fetch-dest'] || '').toLowerCase();
|
|
455
|
-
const framed = dest === 'iframe' || dest === 'frame' || dest === 'embed' || dest === 'object';
|
|
456
|
-
const site = framed ? 'SameSite=None; Partitioned' : 'SameSite=Lax';
|
|
198
|
+
// FRAMED means Partitioned (header rule 4a, `previewFramePolicy.mjs`).
|
|
199
|
+
const site = grantCookieSite(req.headers['sec-fetch-dest']);
|
|
457
200
|
// The immediate 302 to a clean path is MANDATORY, not cosmetic: it takes
|
|
458
201
|
// `?g=` out of the address bar, out of the Referer every subresource would
|
|
459
202
|
// carry, and out of browser history. It cannot take it out of cloudflared's
|
|
@@ -476,8 +219,8 @@ export function startAuthProxy({
|
|
|
476
219
|
res.end('not found');
|
|
477
220
|
return;
|
|
478
221
|
}
|
|
479
|
-
// 3. One
|
|
480
|
-
const verdict =
|
|
222
|
+
// 3. One admission, the same one the upgrade path asks.
|
|
223
|
+
const verdict = policy.admit(req);
|
|
481
224
|
// A blocked source gets 429, not the challenge — a WWW-Authenticate here
|
|
482
225
|
// would invite the retry the block exists to end. Only that source's
|
|
483
226
|
// Basic attempts are refused; every other viewer's share is untouched.
|
|
@@ -486,22 +229,22 @@ export function startAuthProxy({
|
|
|
486
229
|
res.end('too many failed attempts from this address');
|
|
487
230
|
return;
|
|
488
231
|
}
|
|
489
|
-
if (verdict !== 'ok') {
|
|
490
|
-
if (grants && verdict !== 'badpass' && isBrowserNav(req)) return bounce(req, res, verdict);
|
|
491
|
-
return challenge(res);
|
|
492
|
-
}
|
|
493
232
|
// 4. The grant is a cookie, and a cookie can be ridden (header rule 4a's
|
|
494
233
|
// backstop). Never a bounce — the caller HAS a credential; the request
|
|
495
234
|
// SHAPE is what is refused.
|
|
496
|
-
if (
|
|
235
|
+
if (verdict === 'cross-site') {
|
|
497
236
|
res.writeHead(403, { 'Content-Type': 'text/plain', ...noStore });
|
|
498
237
|
res.end('cross-site request refused');
|
|
499
238
|
return;
|
|
500
239
|
}
|
|
240
|
+
if (verdict !== 'ok') {
|
|
241
|
+
if (grants && verdict !== 'badpass' && isBrowserNav(req)) return bounce(req, res, verdict);
|
|
242
|
+
return challenge(res);
|
|
243
|
+
}
|
|
501
244
|
const proxyReq = request(forwardOpts(req), (proxyRes) => {
|
|
502
245
|
// The one RESPONSE rewrite this gate performs (header rule 4b): the
|
|
503
246
|
// frame policy. Everything else is the driver's own app talking.
|
|
504
|
-
res.writeHead(proxyRes.statusCode || 502, rewriteFramePolicy(proxyRes.headers));
|
|
247
|
+
res.writeHead(proxyRes.statusCode || 502, rewriteFramePolicy(proxyRes.headers, appOrigin));
|
|
505
248
|
proxyRes.pipe(res);
|
|
506
249
|
});
|
|
507
250
|
proxyReq.on('error', () => {
|
|
@@ -537,25 +280,25 @@ export function startAuthProxy({
|
|
|
537
280
|
// upgrade stays a 401 and the page's HMR client reconnects once the human
|
|
538
281
|
// has re-authenticated in the main document. A blocked source's Basic
|
|
539
282
|
// handshake gets the same 429 the request path sends, and no challenge.
|
|
540
|
-
const upgradeVerdict =
|
|
283
|
+
const upgradeVerdict = policy.admit(req);
|
|
541
284
|
if (upgradeVerdict === 'blocked') {
|
|
542
285
|
socket.write('HTTP/1.1 429 Too Many Requests\r\n\r\n');
|
|
543
286
|
socket.destroy();
|
|
544
287
|
return;
|
|
545
288
|
}
|
|
546
|
-
if (upgradeVerdict !== 'ok') {
|
|
547
|
-
socket.write('HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm="Flowviant preview"\r\n\r\n');
|
|
548
|
-
socket.destroy();
|
|
549
|
-
return;
|
|
550
|
-
}
|
|
551
289
|
// The same cross-site backstop as the request path: a cookie-ridden
|
|
552
290
|
// cross-site handshake (Sec-Fetch-Mode: websocket) is refused; the framed
|
|
553
291
|
// app's own HMR socket is same-origin and never trips it.
|
|
554
|
-
if (
|
|
292
|
+
if (upgradeVerdict === 'cross-site') {
|
|
555
293
|
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
556
294
|
socket.destroy();
|
|
557
295
|
return;
|
|
558
296
|
}
|
|
297
|
+
if (upgradeVerdict !== 'ok') {
|
|
298
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm="Flowviant preview"\r\n\r\n');
|
|
299
|
+
socket.destroy();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
559
302
|
const proxyReq = request(forwardOpts(req));
|
|
560
303
|
proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
|
|
561
304
|
const headerLines = Object.entries(proxyRes.headers).map(([k, v]) => `${k}: ${v}`);
|