scenescout 3.7.0 → 3.9.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/CHANGELOG.md +14 -0
- package/dist/engine/browser.js +562 -37
- package/dist/engine/collector.js +146 -0
- package/dist/engine/memory.js +16 -1
- package/dist/engine/oracles.js +23 -3
- package/dist/engine/policy.js +358 -2
- package/dist/engine/report.js +54 -5
- package/dist/mcp-server.js +11 -2
- package/package.json +1 -1
- package/skills/scenescout/SKILL.md +1 -1
package/dist/engine/collector.js
CHANGED
|
@@ -402,6 +402,152 @@ export const BROKEN_IMAGES_SCRIPT = `(() => {
|
|
|
402
402
|
}
|
|
403
403
|
return { images, total };
|
|
404
404
|
})()`;
|
|
405
|
+
/**
|
|
406
|
+
* An element's coverage key inside a frame: the frame's origin (another site)
|
|
407
|
+
* or path (the app's own) before the element's own key, so a "Submit" in an
|
|
408
|
+
* embed is not the page's "Submit". Elements of the page itself keep their key
|
|
409
|
+
* unchanged, so pages without frames keep the coverage they had.
|
|
410
|
+
*/
|
|
411
|
+
export function frameElementKey(baseKey, frame) {
|
|
412
|
+
if (!frame)
|
|
413
|
+
return baseKey;
|
|
414
|
+
let where = frame.origin || frame.url;
|
|
415
|
+
if (!frame.foreign) {
|
|
416
|
+
try {
|
|
417
|
+
const u = new URL(frame.url);
|
|
418
|
+
// A frame with no web address (srcdoc, about:blank) is told apart by its title.
|
|
419
|
+
where = u.protocol === "http:" || u.protocol === "https:" ? u.pathname : `${frame.url}#${frame.title}`;
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
where = `${frame.url}#${frame.title}`;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return `frame:${where}|${baseKey}`;
|
|
426
|
+
}
|
|
427
|
+
/** The longest name kept for a control in another site's frame: a chat that renders messages as buttons would otherwise print them whole. */
|
|
428
|
+
export const MAX_FOREIGN_NAME = 40;
|
|
429
|
+
/** A control's name from another site's frame, cut to MAX_FOREIGN_NAME. */
|
|
430
|
+
export function capForeignName(name) {
|
|
431
|
+
return name.length > MAX_FOREIGN_NAME ? `${name.slice(0, MAX_FOREIGN_NAME)}…` : name;
|
|
432
|
+
}
|
|
433
|
+
/** A link's address from another site's frame without its query and fragment, where tokens and addresses travel. */
|
|
434
|
+
export function stripForeignHref(href) {
|
|
435
|
+
try {
|
|
436
|
+
const u = new URL(href);
|
|
437
|
+
return u.origin + u.pathname;
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
return href.split(/[?#]/)[0];
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* A rect read inside a frame, in the page's document coordinates: the frame
|
|
445
|
+
* element's box on screen, less the frame's own scroll, plus the page's.
|
|
446
|
+
*/
|
|
447
|
+
export function frameToPageRect(rect, box, frameScroll, pageScroll) {
|
|
448
|
+
return { ...rect, x: rect.x + box.x + pageScroll.x - frameScroll.x, y: rect.y + box.y + pageScroll.y - frameScroll.y };
|
|
449
|
+
}
|
|
450
|
+
/** How a snapshot line names the frame an element is in. */
|
|
451
|
+
export function frameLabel(frame) {
|
|
452
|
+
let where = frame.url;
|
|
453
|
+
if (!frame.foreign) {
|
|
454
|
+
try {
|
|
455
|
+
const u = new URL(frame.url);
|
|
456
|
+
where = u.pathname + u.search;
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
/* shown as it is */
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
else if (frame.origin)
|
|
463
|
+
where = frame.origin;
|
|
464
|
+
return `${frame.foreign ? "cross-origin" : "same-origin"} frame ${where.slice(0, 80)}${frame.title ? ` "${frame.title.slice(0, 40)}"` : ""}`;
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Whether an element's name, read from another site's frame, is masked. A
|
|
468
|
+
* name taken from a container's text — a select's options, a textarea's
|
|
469
|
+
* contents, a tagged <div> — can carry other people's data (a support chat,
|
|
470
|
+
* a customer record) into the snapshot, the transcript and the report. The
|
|
471
|
+
* label of a link, a button or a field is the interface itself and is kept:
|
|
472
|
+
* the agent needs it to act.
|
|
473
|
+
*/
|
|
474
|
+
export function masksForeignName(tag, role) {
|
|
475
|
+
if (tag === "a" || tag === "button" || tag === "input")
|
|
476
|
+
return false;
|
|
477
|
+
return !/^(button|link|tab|menuitem|checkbox|switch|radio)$/.test(role);
|
|
478
|
+
}
|
|
479
|
+
/** The name shown for a masked element. */
|
|
480
|
+
export const MASKED_NAME = "(content masked: another site's frame)";
|
|
481
|
+
/** A frame smaller than this in both directions is plumbing (a tracking pixel, a messaging bridge), not something a user sees. */
|
|
482
|
+
const VISIBLE_FRAME_PX = 2;
|
|
483
|
+
/**
|
|
484
|
+
* The snapshot's account of the page's frames: what is embedded, where it
|
|
485
|
+
* comes from, and whether its controls were read (`read`, by frame URL) —
|
|
486
|
+
* a page that shows its form in an embed used to look like a page with no
|
|
487
|
+
* form at all. Without `read`, nothing was looked inside. `nested` counts
|
|
488
|
+
* frames that are not read: nested inside others, or past the first 30; `writesRefused` is false only in
|
|
489
|
+
* destructive mode, where a foreign frame's writes do go out.
|
|
490
|
+
*/
|
|
491
|
+
export function frameLines(appUrl, frames, opts = {}) {
|
|
492
|
+
const visible = frames.filter((f) => f.width >= VISIBLE_FRAME_PX && f.height >= VISIBLE_FRAME_PX);
|
|
493
|
+
const hidden = frames.length - visible.length;
|
|
494
|
+
const nested = opts.nested ?? 0;
|
|
495
|
+
if (frames.length === 0 && nested === 0)
|
|
496
|
+
return [];
|
|
497
|
+
let app = "";
|
|
498
|
+
try {
|
|
499
|
+
app = new URL(appUrl).origin;
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
/* no origin: paths are shown in full */
|
|
503
|
+
}
|
|
504
|
+
const lines = visible.slice(0, 10).map((f) => {
|
|
505
|
+
let where = f.url || "(no address)";
|
|
506
|
+
try {
|
|
507
|
+
const u = new URL(f.url);
|
|
508
|
+
if (u.origin === app)
|
|
509
|
+
where = u.pathname + u.search;
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
/* about:blank, srcdoc: shown as they are */
|
|
513
|
+
}
|
|
514
|
+
const label = f.title ? ` "${f.title.slice(0, 60)}"` : "";
|
|
515
|
+
let frameOrigin = "";
|
|
516
|
+
try {
|
|
517
|
+
frameOrigin = new URL(f.url).origin;
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
/* no origin */
|
|
521
|
+
}
|
|
522
|
+
const writes = f.foreign
|
|
523
|
+
? opts.writesRefused === false
|
|
524
|
+
? " — its writes go out (destructive mode)"
|
|
525
|
+
: opts.trustedWrites?.has(frameOrigin)
|
|
526
|
+
? " — trusted embed: its writes go out (safe-write)"
|
|
527
|
+
: " — writes it sends outside the app are refused"
|
|
528
|
+
: "";
|
|
529
|
+
const read = opts.read ? (opts.read.has(f.url) ? " — controls listed above" : " — not read") : "";
|
|
530
|
+
return ` ${f.foreign ? "cross-origin" : "same-origin"} ${where.slice(0, 120)}${label} ${f.width}×${f.height}${read}${writes}`;
|
|
531
|
+
});
|
|
532
|
+
if (visible.length > 10)
|
|
533
|
+
lines.push(` … +${visible.length - 10} more`);
|
|
534
|
+
if (hidden > 0)
|
|
535
|
+
lines.push(` (+${hidden} hidden frame${hidden === 1 ? "" : "s"})`);
|
|
536
|
+
if (nested > 0)
|
|
537
|
+
lines.push(` (+${nested} more frame${nested === 1 ? "" : "s"}, nested inside those or past the first 30, not read)`);
|
|
538
|
+
const header = opts.read && opts.read.size > 0
|
|
539
|
+
? "FRAMES — the controls of each frame read are listed above, marked ⟨in … frame⟩, and can be acted on by ref" +
|
|
540
|
+
(visible.some((f) => f.foreign)
|
|
541
|
+
? "; in another site's frame, content is masked and hostile input, repeated-click probes and uploads are refused"
|
|
542
|
+
: "") +
|
|
543
|
+
":"
|
|
544
|
+
: "FRAMES not explored — their controls are not listed above and cannot be acted on:";
|
|
545
|
+
return [header, ...lines];
|
|
546
|
+
}
|
|
547
|
+
/** Whether any frame on the page is one a user can see. */
|
|
548
|
+
export function hasVisibleFrame(frames) {
|
|
549
|
+
return frames.some((f) => f.width >= VISIBLE_FRAME_PX && f.height >= VISIBLE_FRAME_PX);
|
|
550
|
+
}
|
|
405
551
|
/** Snapshot lines for images that failed to load. The origin is dropped when it is the page's own, to keep the line short. */
|
|
406
552
|
export function brokenImageIssues(scan, pageUrl) {
|
|
407
553
|
let origin = "";
|
package/dist/engine/memory.js
CHANGED
|
@@ -282,6 +282,14 @@ function decisionKey(d) {
|
|
|
282
282
|
* project would otherwise accumulate every decision ever made and re-serialise
|
|
283
283
|
* them on each save, which is what made an old history slow to open.
|
|
284
284
|
*/
|
|
285
|
+
/**
|
|
286
|
+
* Whether a coverage key belongs to a control inside another site's frame:
|
|
287
|
+
* those keys carry the frame's origin (collector.ts frameElementKey), where
|
|
288
|
+
* the app's own frames carry a path. Not the app's to cover.
|
|
289
|
+
*/
|
|
290
|
+
export function isEmbedKey(key) {
|
|
291
|
+
return /^frame:https?:\/\//.test(key);
|
|
292
|
+
}
|
|
285
293
|
export const MAX_LANE_DECISIONS = 1000;
|
|
286
294
|
/**
|
|
287
295
|
* Most options a dropdown may have and still be tracked for unchosen options.
|
|
@@ -1388,6 +1396,7 @@ export class MemoryStore {
|
|
|
1388
1396
|
let elementsTotal = 0;
|
|
1389
1397
|
let elementsExercised = 0;
|
|
1390
1398
|
const unexercised = [];
|
|
1399
|
+
const embeds = { total: 0, exercised: 0 };
|
|
1391
1400
|
for (const [route, elements] of byRoute) {
|
|
1392
1401
|
const own = [];
|
|
1393
1402
|
// The route's OWN element count — deduped across states and with shared
|
|
@@ -1398,6 +1407,12 @@ export class MemoryStore {
|
|
|
1398
1407
|
// untouched route silently drops out of the gap ledger.
|
|
1399
1408
|
let ownTotal = 0;
|
|
1400
1409
|
for (const [key, done] of elements) {
|
|
1410
|
+
if (isEmbedKey(key)) {
|
|
1411
|
+
embeds.total += 1;
|
|
1412
|
+
if (done)
|
|
1413
|
+
embeds.exercised += 1;
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1401
1416
|
if (isChrome(key)) {
|
|
1402
1417
|
chrome.set(key, (chrome.get(key) ?? false) || done);
|
|
1403
1418
|
continue;
|
|
@@ -1424,7 +1439,7 @@ export class MemoryStore {
|
|
|
1424
1439
|
if (chromeLeft.length > 0) {
|
|
1425
1440
|
unexercised.push({ state: SHARED_CHROME_ROUTE, keys: chromeLeft, total: chrome.size });
|
|
1426
1441
|
}
|
|
1427
|
-
return { states: Object.keys(this.data.states).length, elementsTotal, elementsExercised, unexercised };
|
|
1442
|
+
return { states: Object.keys(this.data.states).length, elementsTotal, elementsExercised, unexercised, embeds };
|
|
1428
1443
|
}
|
|
1429
1444
|
/**
|
|
1430
1445
|
* Remember which styled-element signatures the design audit saw on a route.
|
package/dist/engine/oracles.js
CHANGED
|
@@ -84,6 +84,7 @@ export class OracleMonitor {
|
|
|
84
84
|
severity: "medium",
|
|
85
85
|
detail: `${req.method()} ${req.url().slice(0, 200)} → ${failure}`,
|
|
86
86
|
url: page.url(),
|
|
87
|
+
embed: this.embedOfRequest(req) ?? undefined,
|
|
87
88
|
});
|
|
88
89
|
});
|
|
89
90
|
page.on("response", (res) => {
|
|
@@ -106,6 +107,7 @@ export class OracleMonitor {
|
|
|
106
107
|
severity: status >= 500 ? "high" : "medium",
|
|
107
108
|
detail: `${res.request().method()} ${res.url().slice(0, 200)} → HTTP ${status}`,
|
|
108
109
|
url: page.url(),
|
|
110
|
+
embed: this.embedOfRequest(res.request()) ?? undefined,
|
|
109
111
|
});
|
|
110
112
|
});
|
|
111
113
|
}
|
|
@@ -128,6 +130,17 @@ export class OracleMonitor {
|
|
|
128
130
|
* errors look the same.
|
|
129
131
|
*/
|
|
130
132
|
policyAttributed = 0;
|
|
133
|
+
embedOfRequest = () => null;
|
|
134
|
+
/**
|
|
135
|
+
* The engine knows which frame a request came from; a failing request is
|
|
136
|
+
* attributed to an embed through this. Console and page errors are not
|
|
137
|
+
* attributed: a console message says where its script was served from, not
|
|
138
|
+
* which frame ran it, so an SDK the app's page loads from the embed's own
|
|
139
|
+
* site would be taken for the embed.
|
|
140
|
+
*/
|
|
141
|
+
setEmbedAttribution(ofRequest) {
|
|
142
|
+
this.embedOfRequest = ofRequest;
|
|
143
|
+
}
|
|
131
144
|
/** The engine knows exactly which requests its policy stopped; failed requests and stand-in refusals are matched against that, not against wording. */
|
|
132
145
|
setPolicyRefusalCheck(check) {
|
|
133
146
|
this.refusedByPolicy = check;
|
|
@@ -158,7 +171,11 @@ export class OracleMonitor {
|
|
|
158
171
|
this.policyAttributed += 1;
|
|
159
172
|
return;
|
|
160
173
|
}
|
|
161
|
-
|
|
174
|
+
// Another site's frame: its behaviour, reported, but never as the app's high-severity defect.
|
|
175
|
+
const attributed = v.embed ? { ...v, severity: "medium" } : v;
|
|
176
|
+
if (!attributed.embed)
|
|
177
|
+
delete attributed.embed;
|
|
178
|
+
const violation = { ...redactViolation(attributed), at: new Date().toISOString() };
|
|
162
179
|
this.buffer.push(violation);
|
|
163
180
|
this.all.push(violation);
|
|
164
181
|
}
|
|
@@ -179,7 +196,8 @@ export class OracleMonitor {
|
|
|
179
196
|
for (const v of out) {
|
|
180
197
|
// Same normalization as the report rollup, so "the same violation"
|
|
181
198
|
// means the same thing in tool output and in the final report.
|
|
182
|
-
|
|
199
|
+
// An embed's violation is not the app's: its signature says whose it is.
|
|
200
|
+
const sig = `${v.embed ? `[${v.embed}] ` : ""}${v.kind}: ${v.detail
|
|
183
201
|
.replace(/\b\d+\b/g, ":n")
|
|
184
202
|
.replace(/[0-9a-f]{8,}/gi, ":h")
|
|
185
203
|
.slice(0, 140)}`;
|
|
@@ -205,7 +223,9 @@ export function formatViolations(violations) {
|
|
|
205
223
|
if (fresh.length === 0) {
|
|
206
224
|
return `\nORACLE: ${repeats} repeat violation(s) of previously reported signatures — nothing new.`;
|
|
207
225
|
}
|
|
208
|
-
const lines = fresh
|
|
226
|
+
const lines = fresh
|
|
227
|
+
.slice(0, 10)
|
|
228
|
+
.map((v) => ` ⚠ [${v.severity}] ${v.kind}${v.embed ? ` (in an embed of ${v.embed}: its behaviour, not the app's)` : ""}: ${v.detail}`);
|
|
209
229
|
const more = fresh.length > 10 ? `\n … and ${fresh.length - 10} more` : "";
|
|
210
230
|
return `\nORACLE VIOLATIONS since last action (${fresh.length} new):\n${lines.join("\n")}${more}${repeatLine}`;
|
|
211
231
|
}
|
package/dist/engine/policy.js
CHANGED
|
@@ -174,6 +174,362 @@ export function isAuthExempt(mode, method, pathname, destructiveWire) {
|
|
|
174
174
|
return (segments.slice(-2).some((seg) => OBSERVE_AUTH_SEGMENT_RE.test(seg)) &&
|
|
175
175
|
!/^(users?|accounts?|members?|password|signup|sign-up|register|verify|invite|invitations?)$/i.test(last));
|
|
176
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* The origin of a request's frame when that frame belongs to another site than
|
|
179
|
+
* the app: an embedded widget, such as a form, chat or payment box served by a
|
|
180
|
+
* third party. A write from one reaches that third party, not the app under
|
|
181
|
+
* test, so no mode short of destructive lets it out.
|
|
182
|
+
*
|
|
183
|
+
* `frameChain` lists the URLs of the frame that issued the request and each of
|
|
184
|
+
* its parents, stopping before the top document. A frame with no address of
|
|
185
|
+
* its own (about:blank, srcdoc) belongs to whoever created it, so it is skipped
|
|
186
|
+
* and its parent decides. Any foreign frame in the chain makes the request
|
|
187
|
+
* foreign: an app page nested inside a widget is still being driven by it.
|
|
188
|
+
* Null for the top document, same-origin frames, and requests with no frame.
|
|
189
|
+
*/
|
|
190
|
+
export function foreignFrameOrigin(appUrl, frameChain) {
|
|
191
|
+
let app;
|
|
192
|
+
try {
|
|
193
|
+
app = new URL(appUrl).origin;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
for (const url of frameChain) {
|
|
199
|
+
let frame;
|
|
200
|
+
try {
|
|
201
|
+
frame = new URL(url);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (frame.protocol !== "http:" && frame.protocol !== "https:")
|
|
207
|
+
continue;
|
|
208
|
+
if (frame.origin !== app)
|
|
209
|
+
return frame.origin;
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The origin to name when a write started by another site is headed outside
|
|
215
|
+
* the app, or null when the write is the app's own or lands in the app.
|
|
216
|
+
*
|
|
217
|
+
* The source is foreign when the frame that sent it (or a parent) is of
|
|
218
|
+
* another origin, or when the request's Origin header names another origin
|
|
219
|
+
* than both the app and the frame it is attributed to. The second catches a
|
|
220
|
+
* foreign frame's form aimed at `_top` or `_blank`, and a popup it opens: the
|
|
221
|
+
* browser reports those against the top page or no frame at all, but the
|
|
222
|
+
* Origin header still names the frame's site. A sign-in page loaded as the
|
|
223
|
+
* whole page is not caught by it, since there the header and the page agree.
|
|
224
|
+
*
|
|
225
|
+
* A foreign write whose destination is the app itself — a sign-in provider's
|
|
226
|
+
* frame posting its reply back to the app's callback — is the app's business
|
|
227
|
+
* and is left to the ordinary rules.
|
|
228
|
+
*/
|
|
229
|
+
export function foreignWrite(appUrl, req) {
|
|
230
|
+
let app;
|
|
231
|
+
try {
|
|
232
|
+
app = new URL(appUrl).origin;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
const originOf = (url) => {
|
|
238
|
+
if (!url)
|
|
239
|
+
return null;
|
|
240
|
+
try {
|
|
241
|
+
const u = new URL(url);
|
|
242
|
+
return u.protocol === "http:" || u.protocol === "https:" ? u.origin : null;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
if (originOf(req.url) === app)
|
|
249
|
+
return null;
|
|
250
|
+
const fromFrame = foreignFrameOrigin(appUrl, req.frameChain);
|
|
251
|
+
if (fromFrame)
|
|
252
|
+
return fromFrame;
|
|
253
|
+
const header = originOf(req.originHeader);
|
|
254
|
+
if (header && header !== app && header !== originOf(req.frameUrl))
|
|
255
|
+
return header;
|
|
256
|
+
// A popup a foreign frame opened on its own site posts from its own script
|
|
257
|
+
// before it can be closed, and there the header and the page agree. The
|
|
258
|
+
// session never drives a page it did not adopt, so its writes out are not
|
|
259
|
+
// the app's.
|
|
260
|
+
if (req.unadoptedPageUrl !== undefined && req.unadoptedPageUrl !== null)
|
|
261
|
+
return originOf(req.unadoptedPageUrl) ?? "a page the session did not open";
|
|
262
|
+
// A frame with a no-referrer policy sends "Origin: null". Out of the app,
|
|
263
|
+
// on a page that embeds another site, that is taken to be the embed.
|
|
264
|
+
if (req.originHeader === "null" && req.pageHasForeignFrame)
|
|
265
|
+
return "an embedded frame (Origin: null)";
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Whether the session's page was moved off the app by one of its embeds. The
|
|
270
|
+
* sandbox forbids a frame to move the page, but WebKit drops it for a frame
|
|
271
|
+
* that loads a `data:` URL in its own place, and a Chromium service worker can
|
|
272
|
+
* serve a frame's document unseen.
|
|
273
|
+
*
|
|
274
|
+
* Decided on the navigation's first request, given the other sites the page
|
|
275
|
+
* embeds at that moment (the engine asks the frames still attached, so a route
|
|
276
|
+
* change, a 204 or a download changes nothing). A move from a page with no
|
|
277
|
+
* embeds, or one carrying the app as its Referer — a click on the app's page —
|
|
278
|
+
* is the tester's: a hosted sign-in page, even one the app also embeds for
|
|
279
|
+
* silent sign-in, keeps the ordinary rules. With another site's Referer, it is
|
|
280
|
+
* an embed's. With no Referer at all (an app that sends none, or a frame that
|
|
281
|
+
* hides its origin) it is an embed's only when it goes to one of the embedded
|
|
282
|
+
* sites.
|
|
283
|
+
*/
|
|
284
|
+
export class EmbedMoveTracker {
|
|
285
|
+
appUrl;
|
|
286
|
+
pending = null;
|
|
287
|
+
/** The origin the page was moved to by an embed, while it stays there. */
|
|
288
|
+
movedTo = null;
|
|
289
|
+
constructor(appUrl) {
|
|
290
|
+
this.appUrl = appUrl;
|
|
291
|
+
}
|
|
292
|
+
/** The top window's navigation to `url` sent its first request, with this Referer, from a page embedding these other sites. */
|
|
293
|
+
navigationStarted(url, referer, embedded) {
|
|
294
|
+
const target = foreignFrameOrigin(this.appUrl, [url]);
|
|
295
|
+
if (!target || embedded.size === 0) {
|
|
296
|
+
this.pending = null;
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const refererIsHttp = !!referer && /^https?:/i.test(referer);
|
|
300
|
+
if (refererIsHttp && foreignFrameOrigin(this.appUrl, [referer]) === null)
|
|
301
|
+
this.pending = null;
|
|
302
|
+
else if (refererIsHttp)
|
|
303
|
+
this.pending = target;
|
|
304
|
+
else
|
|
305
|
+
this.pending = embedded.has(target) ? target : null;
|
|
306
|
+
}
|
|
307
|
+
/** The top window now shows `url`: a new document, or a same-document route change. */
|
|
308
|
+
pageLoaded(url) {
|
|
309
|
+
let origin;
|
|
310
|
+
try {
|
|
311
|
+
const u = new URL(url);
|
|
312
|
+
if (u.protocol !== "http:" && u.protocol !== "https:")
|
|
313
|
+
return;
|
|
314
|
+
origin = u.origin;
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (foreignFrameOrigin(this.appUrl, [url]) === null) {
|
|
320
|
+
this.movedTo = null;
|
|
321
|
+
this.pending = null;
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (this.pending === origin)
|
|
325
|
+
this.movedTo = origin;
|
|
326
|
+
else if (this.movedTo !== origin)
|
|
327
|
+
this.movedTo = null;
|
|
328
|
+
this.pending = null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* The origin to name when the session's page was moved off the app by one of
|
|
333
|
+
* its embeds (EmbedMoveTracker) and writes to another site from there, or
|
|
334
|
+
* null. Refused unless it is a sign-in request.
|
|
335
|
+
*/
|
|
336
|
+
export function offAppPageWrite(appUrl, pageUrl, destinationUrl, movedByEmbed) {
|
|
337
|
+
if (!movedByEmbed)
|
|
338
|
+
return null;
|
|
339
|
+
const originOf = (url) => {
|
|
340
|
+
if (!url)
|
|
341
|
+
return null;
|
|
342
|
+
try {
|
|
343
|
+
const u = new URL(url);
|
|
344
|
+
return u.protocol === "http:" || u.protocol === "https:" ? u.origin : null;
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
const app = originOf(appUrl);
|
|
351
|
+
const page = originOf(pageUrl);
|
|
352
|
+
if (!app || !page || page === app || page !== movedByEmbed)
|
|
353
|
+
return null;
|
|
354
|
+
if (originOf(destinationUrl) === app)
|
|
355
|
+
return null;
|
|
356
|
+
return page;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* The page a sandboxed frame is given in place of a redirect: it navigates to
|
|
360
|
+
* the redirect's target itself, so the next hop is a navigation the policy
|
|
361
|
+
* routes and sandboxes again. A redirect answered as a redirect is followed by
|
|
362
|
+
* the browser without asking, and the page it lands on was not sandboxed.
|
|
363
|
+
*/
|
|
364
|
+
export function sandboxedRedirectPage(target) {
|
|
365
|
+
const json = JSON.stringify(target).replace(/</g, "\\u003c");
|
|
366
|
+
// No referrer: the next hop would otherwise name this stand-in page, where a real redirect names the app.
|
|
367
|
+
return `<!doctype html><meta charset="utf-8"><meta name="referrer" content="no-referrer"><script>location.replace(${json});</script>`;
|
|
368
|
+
}
|
|
369
|
+
/** The last path segment of a page that is a sign-in page, and nothing else: not a verification step, where a payment provider's frame sits. */
|
|
370
|
+
const SIGN_IN_SEGMENT_RE = /^(login|log-in|signin|sign-in|signup|sign-up|sso|oauth)$/i;
|
|
371
|
+
/**
|
|
372
|
+
* Whether a foreign frame's writes out may go on this page after all: a
|
|
373
|
+
* captcha on the app's own sign-in page is a cross-origin frame that posts to
|
|
374
|
+
* its own site, and refusing it would make every login fail. Only on the app's
|
|
375
|
+
* own origin, only when the page's last path segment is a sign-in word (a
|
|
376
|
+
* trailing file extension ignored, `_` read as `-`) — not "auth", which is
|
|
377
|
+
* also the last step of a card payment's verification — and not in observe, where only the login
|
|
378
|
+
* request itself goes out.
|
|
379
|
+
*/
|
|
380
|
+
export function allowsForeignWriteOnSignIn(mode, topPageUrl, appUrl) {
|
|
381
|
+
if (mode === "observe" || mode === "destructive")
|
|
382
|
+
return false;
|
|
383
|
+
let page;
|
|
384
|
+
try {
|
|
385
|
+
page = new URL(topPageUrl);
|
|
386
|
+
if (page.origin !== new URL(appUrl).origin)
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
const segments = page.pathname.split("/").filter(Boolean);
|
|
393
|
+
// "sign_in" is "sign-in": underscores are how some frameworks spell it.
|
|
394
|
+
const last = (segments[segments.length - 1] ?? "").replace(/\.[a-z0-9]+$/i, "").replace(/_/g, "-");
|
|
395
|
+
return SIGN_IN_SEGMENT_RE.test(last);
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* The embed a failing request is attributed to: the other site whose frame
|
|
399
|
+
* sent it (`frameSite`, judged on the frame chain), unless the request went to
|
|
400
|
+
* the app itself — a 500 from the app is the app's to answer, whoever called.
|
|
401
|
+
*/
|
|
402
|
+
export function embedOfRequest(appUrl, requestUrl, frameSite) {
|
|
403
|
+
if (!frameSite)
|
|
404
|
+
return null;
|
|
405
|
+
return foreignFrameOrigin(appUrl, [requestUrl]) === null ? null : frameSite;
|
|
406
|
+
}
|
|
407
|
+
/** The most origins a session may trust with its embeds' writes. */
|
|
408
|
+
export const MAX_TRUSTED_EMBEDS = 10;
|
|
409
|
+
/**
|
|
410
|
+
* The origins a session was told to trust, normalised, and what was given
|
|
411
|
+
* that is not one. An entry must be a plain http(s) origin — scheme, host and
|
|
412
|
+
* port, nothing after — so a path or a wildcard cannot widen it by accident.
|
|
413
|
+
*/
|
|
414
|
+
export function trustedEmbedOrigins(list) {
|
|
415
|
+
const origins = [];
|
|
416
|
+
const rejected = [];
|
|
417
|
+
const overflow = [];
|
|
418
|
+
for (const raw of list ?? []) {
|
|
419
|
+
let u;
|
|
420
|
+
try {
|
|
421
|
+
u = new URL(raw.trim());
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
rejected.push(raw);
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const bare = u.pathname === "/" && !u.search && !u.hash && !u.username && !u.password;
|
|
428
|
+
if ((u.protocol !== "http:" && u.protocol !== "https:") || !bare || raw.includes("*")) {
|
|
429
|
+
rejected.push(raw);
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
// "pay.example.com." is "pay.example.com": one origin, one slot.
|
|
433
|
+
const origin = u.origin.replace(/\.(?=(:\d+)?$)/, "");
|
|
434
|
+
if (origins.includes(origin))
|
|
435
|
+
continue;
|
|
436
|
+
if (origins.length < MAX_TRUSTED_EMBEDS)
|
|
437
|
+
origins.push(origin);
|
|
438
|
+
else
|
|
439
|
+
overflow.push(raw);
|
|
440
|
+
}
|
|
441
|
+
return { origins, rejected, overflow };
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Whether the writes a frame of `origin` sends outside the app may go out
|
|
445
|
+
* after all: only for an origin the user named as trusted (a provider in test
|
|
446
|
+
* mode, say), and only in safe-write — read-only and observe keep their
|
|
447
|
+
* promise, and destructive allows everything already.
|
|
448
|
+
*/
|
|
449
|
+
export function trustsEmbedWrite(mode, trusted, origin) {
|
|
450
|
+
return mode === "safe-write" && trusted.has(origin);
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Whether a foreign write may go out because of trust: every other site
|
|
454
|
+
* involved — each http(s) frame from the sender up to the page, and the
|
|
455
|
+
* Origin header when it names one — must be trusted, so an untrusted embed
|
|
456
|
+
* cannot borrow a trusted one it wraps. A page the session never adopted (a
|
|
457
|
+
* popup) is not a frame, and trust does not reach it.
|
|
458
|
+
*/
|
|
459
|
+
export function trustsForeignWrite(mode, trusted, appUrl, req) {
|
|
460
|
+
if (mode !== "safe-write" || trusted.size === 0 || req.unadoptedPageUrl)
|
|
461
|
+
return false;
|
|
462
|
+
const originOf = (url) => {
|
|
463
|
+
if (!url)
|
|
464
|
+
return null;
|
|
465
|
+
try {
|
|
466
|
+
const u = new URL(url);
|
|
467
|
+
return u.protocol === "http:" || u.protocol === "https:" ? u.origin : null;
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
const app = originOf(appUrl);
|
|
474
|
+
const involved = new Set();
|
|
475
|
+
for (const url of req.frameChain) {
|
|
476
|
+
// A blank or srcdoc frame is its parent's, and the parent is judged next.
|
|
477
|
+
// Any other frame with no web address (blob:, data:) could be an untrusted
|
|
478
|
+
// site that moved itself there to wrap a trusted one: no trust through it.
|
|
479
|
+
if (/^about:(blank|srcdoc)/i.test(url))
|
|
480
|
+
continue;
|
|
481
|
+
const o = originOf(url);
|
|
482
|
+
if (!o)
|
|
483
|
+
return false;
|
|
484
|
+
if (o !== app)
|
|
485
|
+
involved.add(o);
|
|
486
|
+
}
|
|
487
|
+
const header = originOf(req.originHeader);
|
|
488
|
+
if (header && header !== app)
|
|
489
|
+
involved.add(header);
|
|
490
|
+
return involved.size > 0 && [...involved].every((o) => trustsEmbedWrite(mode, trusted, o));
|
|
491
|
+
}
|
|
492
|
+
/** The longest text typed into another site's frame; past it, a value is a fuzzing probe, not a user's input. */
|
|
493
|
+
export const MAX_EMBED_TEXT = 200;
|
|
494
|
+
/**
|
|
495
|
+
* Why a value must not be typed into another site's frame, or null when it
|
|
496
|
+
* may be. The tester is authorised to test the app, not the embeds of
|
|
497
|
+
* others: markup, fuzzing lengths and control characters typed there would
|
|
498
|
+
* be probing a third party's system without its leave.
|
|
499
|
+
*/
|
|
500
|
+
export function hostileForEmbed(text) {
|
|
501
|
+
if (/<\s*[a-z!/?]/i.test(text) || /javascript:/i.test(text))
|
|
502
|
+
return "it is markup";
|
|
503
|
+
if (text.length > MAX_EMBED_TEXT)
|
|
504
|
+
return `it is longer than ${MAX_EMBED_TEXT} characters`;
|
|
505
|
+
if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text))
|
|
506
|
+
return "it holds control characters";
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
/** The refusal for a probe aimed at another site's frame. */
|
|
510
|
+
export function embedProbeRefusal(what, origin) {
|
|
511
|
+
return (`REFUSED: ${what} inside a frame of ${origin}, another site embedded in the page. The tester is authorised to test the app, ` +
|
|
512
|
+
`not the embeds of others, so hostile input, repeated-click probes and file uploads are never sent into one, in any mode. ` +
|
|
513
|
+
`Ordinary clicks and typing there are allowed; the embed's writes out of the app are refused by the write policy.`);
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* The sandbox given to every document a frame of another origin loads, outside
|
|
517
|
+
* destructive mode: scripts, forms and its own origin keep working, and no
|
|
518
|
+
* popups or top-window navigation are allowed. A browser applies it to every
|
|
519
|
+
* realm the document makes, nested frames and blank ones included, which a
|
|
520
|
+
* script patch cannot reach: in Firefox a detached link's click, a
|
|
521
|
+
* `<base target>` or a borrowed `window.open` each opened a popup whose first
|
|
522
|
+
* requests never reached the policy.
|
|
523
|
+
*/
|
|
524
|
+
export const FOREIGN_FRAME_SANDBOX = "sandbox allow-scripts allow-forms allow-same-origin";
|
|
525
|
+
/**
|
|
526
|
+
* A response's Content-Security-Policy with the foreign-frame sandbox added. A
|
|
527
|
+
* second policy joined with a comma is enforced alongside the first, so the
|
|
528
|
+
* document's own policy still holds.
|
|
529
|
+
*/
|
|
530
|
+
export function withForeignFrameSandbox(existing) {
|
|
531
|
+
return existing && existing.trim() ? `${existing}, ${FOREIGN_FRAME_SANDBOX}` : FOREIGN_FRAME_SANDBOX;
|
|
532
|
+
}
|
|
177
533
|
export function allowsWrite(mode, method, destructiveWire, owned) {
|
|
178
534
|
if (mode === "destructive")
|
|
179
535
|
return true;
|
|
@@ -209,7 +565,7 @@ export const POLICY_REFUSAL_HEADER = "x-scenescout-policy";
|
|
|
209
565
|
* drop it all over again. `origin` is the request's own Origin header, echoed
|
|
210
566
|
* only when there is one.
|
|
211
567
|
*/
|
|
212
|
-
export function policyRefusal(mode, method, pathname, origin) {
|
|
568
|
+
export function policyRefusal(mode, method, pathname, origin, why) {
|
|
213
569
|
const headers = { "content-type": "application/json", [POLICY_REFUSAL_HEADER]: `refused; mode=${mode}` };
|
|
214
570
|
if (origin) {
|
|
215
571
|
headers["access-control-allow-origin"] = origin;
|
|
@@ -221,7 +577,7 @@ export function policyRefusal(mode, method, pathname, origin) {
|
|
|
221
577
|
headers,
|
|
222
578
|
body: JSON.stringify({
|
|
223
579
|
error: "Forbidden",
|
|
224
|
-
message: `${method} ${pathname} was refused by the tester's ${mode} write policy. The server never received it.`,
|
|
580
|
+
message: `${method} ${pathname} was refused by the tester's ${mode} write policy${why ? ` (${why})` : ""}. The server never received it.`,
|
|
225
581
|
}),
|
|
226
582
|
};
|
|
227
583
|
}
|