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.
@@ -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 = "";
@@ -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.
@@ -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
- const violation = { ...redactViolation(v), at: new Date().toISOString() };
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
- const sig = `${v.kind}: ${v.detail
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.slice(0, 10).map((v) => ` ⚠ [${v.severity}] ${v.kind}: ${v.detail}`);
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
  }
@@ -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
  }