From ebd0fc2088fcdecc46be62b78167a3ce8eb0178d Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Thu, 9 Jul 2026 10:56:05 +0100 Subject: [PATCH 1/4] feat(procaptcha-frictionless): record iframe URL alongside top-frame URL Widget now reports both the top-frame URL and the widget's own iframe URL on frictionless sessions. `currentUrl` remains the top-frame URL (resolution rules unchanged); `iframeUrl` carries the widget's own frame URL when embedded so analytics can distinguish the site-wide iframe endpoint from the page the user was actually on. Both fields are sanitised client- and server-side; only `currentUrl` is gated in the decision machine. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/frictionless-iframe-url.md | 16 +++++ packages/api/src/api/ProviderApi.ts | 2 + .../src/customDetectBot.ts | 61 ++++++++++++------- .../src/tests/customDetectBotSimd.test.ts | 2 +- .../handler.ts | 7 +++ .../api/captcha/submitPoWCaptchaSolution.ts | 1 + .../tasks/frictionless/frictionlessTasks.ts | 7 +++ packages/types-database/src/types/provider.ts | 5 +- packages/types/src/api/params.ts | 1 + packages/types/src/provider/api.ts | 9 +++ packages/types/src/provider/database.ts | 10 +++ 11 files changed, 96 insertions(+), 25 deletions(-) create mode 100644 .changeset/frictionless-iframe-url.md diff --git a/.changeset/frictionless-iframe-url.md b/.changeset/frictionless-iframe-url.md new file mode 100644 index 0000000000..6a1d787439 --- /dev/null +++ b/.changeset/frictionless-iframe-url.md @@ -0,0 +1,16 @@ +--- +"@prosopo/procaptcha-frictionless": patch +"@prosopo/provider": patch +"@prosopo/api": patch +"@prosopo/types": patch +"@prosopo/types-database": patch +--- + +Record both the top-frame URL and the widget's own iframe URL on frictionless sessions. + +Previously the client only sent one field (`currentUrl`), which for embedded widgets resolved to the top-frame URL — so we lost visibility into which iframe endpoint the session was actually loaded through. Now the client sends both: + +- `currentUrl`: the top-frame URL (same resolution rules as before — same-origin iframes read `window.top.location.href` directly; cross-origin iframes fall back to `document.referrer`). +- `iframeUrl`: the widget's own frame URL when embedded. Undefined when the widget IS the top frame (nothing to distinguish). + +Both fields are sanitised client- and server-side (origin + path only; query string, fragment and any embedded credentials stripped). The provider persists both on the `Session` record and re-uses them on post-PoW escalation sessions. Only `currentUrl` is gated in the decision machine (unchanged — missing `currentUrl` still forces an image captcha); `iframeUrl` is recorded for analytics. diff --git a/packages/api/src/api/ProviderApi.ts b/packages/api/src/api/ProviderApi.ts index 2862efedc5..40dae71278 100644 --- a/packages/api/src/api/ProviderApi.ts +++ b/packages/api/src/api/ProviderApi.ts @@ -382,6 +382,7 @@ export default class ProviderApi mode?: ModeEnum, simdReadings?: string, currentUrl?: string, + iframeUrl?: string, ): Promise { const body: GetFrictionlessCaptchaChallengeRequestBodyOutput = { [ApiParams.token]: token, @@ -391,6 +392,7 @@ export default class ProviderApi ...(mode && { [ApiParams.mode]: mode }), ...(simdReadings && { [ApiParams.simdReadings]: simdReadings }), ...(currentUrl && { [ApiParams.currentUrl]: currentUrl }), + ...(iframeUrl && { [ApiParams.iframeUrl]: iframeUrl }), }; const { data, headers } = await this.postWithHeaders< GetFrictionlessCaptchaResponse, diff --git a/packages/procaptcha-frictionless/src/customDetectBot.ts b/packages/procaptcha-frictionless/src/customDetectBot.ts index 9422f492f4..d6736d986d 100644 --- a/packages/procaptcha-frictionless/src/customDetectBot.ts +++ b/packages/procaptcha-frictionless/src/customDetectBot.ts @@ -27,22 +27,25 @@ import type { import type { BotDetectionFunctionResult } from "@prosopo/types"; import { DetectorLoader } from "./detectorLoader.js"; -// The page the widget is rendered on, reduced to origin + path. Deliberately -// built from `origin` + `pathname` (never `href`) so the query string, -// fragment, and any embedded `user:pass@` credentials never leave the browser -// — sites routinely carry tokens / session ids / reset codes in those parts. -// Returns undefined in non-browser contexts (SSR / tests) or for opaque -// origins; the provider then treats the session as having no page URL and -// forces an image captcha. +// The page(s) the widget is rendered on, reduced to origin + path. +// Deliberately built from `origin` + `pathname` (never `href`) so the query +// string, fragment, and any embedded `user:pass@` credentials never leave the +// browser — sites routinely carry tokens / session ids / reset codes in those +// parts. // -// When we're loaded inside an iframe (Protect's site-wide embed) the local -// `window.location` is the iframe URL, which conveys nothing about the page -// the user is actually on — every session across the tenant ends up -// reporting the same widget endpoint. Same-origin iframes let us read the -// top frame directly; cross-origin iframes fall through to `document.referrer`, -// which the browser fills with the embedding page URL subject to -// Referrer-Policy. Only when both are unavailable do we fall back to the -// iframe's own URL, matching the previous behaviour. +// Returns two fields: +// - `currentUrl`: the top-frame URL as best we can determine it. Same-origin +// iframes read it directly; cross-origin iframes fall back to +// `document.referrer`, which the browser fills with the embedding page URL +// subject to Referrer-Policy. When neither is available the field falls +// back to the local (iframe) URL so the provider still sees a value. +// - `iframeUrl`: the widget's own frame URL when we're embedded. Undefined +// when the widget IS the top frame — nothing to distinguish. Emitted so +// downstream analytics can separate "Protect's site-wide iframe endpoint" +// from "the page the user was actually on". +// +// Both fields are undefined in non-browser contexts (SSR / tests) or for +// opaque origins. const sanitiseHref = (href: string): string | undefined => { try { const u = new URL(href); @@ -53,9 +56,14 @@ const sanitiseHref = (href: string): string | undefined => { } }; -const getCurrentPageUrl = (): string | undefined => { +type PageUrls = { + currentUrl: string | undefined; + iframeUrl: string | undefined; +}; + +const getCurrentPageUrls = (): PageUrls => { if (typeof window === "undefined" || !window.location) { - return undefined; + return { currentUrl: undefined, iframeUrl: undefined }; } const local = (() => { const { origin, pathname } = window.location; @@ -63,14 +71,16 @@ const getCurrentPageUrl = (): string | undefined => { return `${origin}${pathname || ""}`; })(); - // Top window — the iframe path doesn't apply. - if (window === window.top) return local; + // Top window — no iframe distinction, report the local URL only. + if (window === window.top) { + return { currentUrl: local, iframeUrl: undefined }; + } // Same-origin iframe — read the top URL directly. try { const topHref = window.top?.location?.href; const sanitised = topHref ? sanitiseHref(topHref) : undefined; - if (sanitised) return sanitised; + if (sanitised) return { currentUrl: sanitised, iframeUrl: local }; } catch { /* cross-origin — fall through */ } @@ -79,10 +89,13 @@ const getCurrentPageUrl = (): string | undefined => { // subject to the parent's Referrer-Policy header. if (typeof document !== "undefined" && document.referrer) { const fromReferrer = sanitiseHref(document.referrer); - if (fromReferrer) return fromReferrer; + if (fromReferrer) return { currentUrl: fromReferrer, iframeUrl: local }; } - return local; + // Referrer unavailable — fall back to the iframe URL for `currentUrl` so + // the provider still sees a value, and echo it as `iframeUrl` so + // downstream can tell the top frame was not observed. + return { currentUrl: local, iframeUrl: local }; }; export const withTimeout = async ( @@ -165,6 +178,7 @@ const customDetectBot: BotDetectionFunction = async ( // lets it complete in the worker thread while the network round-trip // burns. Readings still attach on the challenge GET and on solution // submit (first-hop-wins server-side). + const { currentUrl, iframeUrl } = getCurrentPageUrls(); const captchaPromise = providerApi.getFrictionlessCaptcha( detectionResult.token, detectionResult.encryptHeadHash, @@ -172,7 +186,8 @@ const customDetectBot: BotDetectionFunction = async ( userAccount.account.address, config.mode, undefined, - getCurrentPageUrl(), + currentUrl, + iframeUrl, ); if (detectionResult.getSimdReadings) { // Fire-and-forget: triggers the memoised prefetch inside the catcher diff --git a/packages/procaptcha-frictionless/src/tests/customDetectBotSimd.test.ts b/packages/procaptcha-frictionless/src/tests/customDetectBotSimd.test.ts index 0cd9915aa6..2f8ef2a04a 100644 --- a/packages/procaptcha-frictionless/src/tests/customDetectBotSimd.test.ts +++ b/packages/procaptcha-frictionless/src/tests/customDetectBotSimd.test.ts @@ -93,7 +93,7 @@ describe("customDetectBot SIMD deferral", () => { expect(mocks.getFrictionlessCaptcha).toHaveBeenCalledTimes(1); const args = mocks.getFrictionlessCaptcha.mock.calls[0]; - // args: token, headHash, dappAccount, userAccount, mode, simdReadings, currentUrl + // args: token, headHash, dappAccount, userAccount, mode, simdReadings, currentUrl, iframeUrl expect(args?.[5]).toBeUndefined(); }); diff --git a/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts b/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts index 321c0d5037..0a6970ffa3 100644 --- a/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts +++ b/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts @@ -81,6 +81,7 @@ export default ( mode, simdReadings, currentUrl: reportedCurrentUrl, + iframeUrl: reportedIframeUrl, } = GetFrictionlessCaptchaChallengeRequestBody.parse(req.body); // Re-sanitise whatever the client reported: keep only scheme + host @@ -89,7 +90,12 @@ export default ( // undefined when the field is absent or not a usable http(s) URL — // the decision machine treats that as "not reported" and forces an // image captcha. + // + // `iframeUrl` is only populated when the widget was embedded and + // is optional — its absence just means "widget was the top frame". + // It's not gated in the decision machine; recorded for analytics. const currentUrl = sanitisePageUrl(reportedCurrentUrl); + const iframeUrl = sanitisePageUrl(reportedIframeUrl); const normalizedIp = normalizeRequestIp(req.ip, req.logger); const sessionMode = @@ -528,6 +534,7 @@ export default ( decryptedHeadHash, siteKey: dapp, ...(currentUrl && { currentUrl }), + ...(iframeUrl && { iframeUrl }), ipInfo: req.ipInfo, headers: flatHeaders, mode: sessionMode, diff --git a/packages/provider/src/api/captcha/submitPoWCaptchaSolution.ts b/packages/provider/src/api/captcha/submitPoWCaptchaSolution.ts index ccf3f24fc6..8364a9a8e2 100644 --- a/packages/provider/src/api/captcha/submitPoWCaptchaSolution.ts +++ b/packages/provider/src/api/captcha/submitPoWCaptchaSolution.ts @@ -299,6 +299,7 @@ export const buildEscalation = async ( handshakeTiming?.tcpToChelloUs, handshakeTiming?.chelloToHandshakeUs, true, + originSession.iframeUrl, ); // Record the origin → escalation sessionId mapping so a /captcha/* diff --git a/packages/provider/src/tasks/frictionless/frictionlessTasks.ts b/packages/provider/src/tasks/frictionless/frictionlessTasks.ts index b2ac7e48c0..c9356e87b9 100644 --- a/packages/provider/src/tasks/frictionless/frictionlessTasks.ts +++ b/packages/provider/src/tasks/frictionless/frictionlessTasks.ts @@ -138,6 +138,7 @@ export class FrictionlessManager extends CaptchaManager { decryptedHeadHash: params.decryptedHeadHash, siteKey: params.siteKey, currentUrl: params.currentUrl, + iframeUrl: params.iframeUrl, ipInfo: params.ipInfo, headers: params.headers, mode: params.mode, @@ -191,6 +192,7 @@ export class FrictionlessManager extends CaptchaManager { tcpToChelloUs?: Session["tcpToChelloUs"], chelloToHandshakeUs?: Session["chelloToHandshakeUs"], isEscalation?: Session["isEscalation"], + iframeUrl?: Session["iframeUrl"], ): Promise { const sessionRecord: Session = { sessionId: `${getSessionIDPrefix(this.config.host)}-${uuidv4()}`, @@ -215,6 +217,7 @@ export class FrictionlessManager extends CaptchaManager { reason, siteKey, currentUrl, + iframeUrl, blocked, deleted, ipInfo, @@ -371,6 +374,8 @@ export class FrictionlessManager extends CaptchaManager { effectiveParams.currentUrl, effectiveParams.tcpToChelloUs, effectiveParams.chelloToHandshakeUs, + undefined, + effectiveParams.iframeUrl, ); // Fire-and-forget served-counter writes. Skipped when there's no @@ -442,6 +447,8 @@ export class FrictionlessManager extends CaptchaManager { effectiveParams.currentUrl, effectiveParams.tcpToChelloUs, effectiveParams.chelloToHandshakeUs, + undefined, + effectiveParams.iframeUrl, ); } diff --git a/packages/types-database/src/types/provider.ts b/packages/types-database/src/types/provider.ts index 002494696c..0f1304f075 100644 --- a/packages/types-database/src/types/provider.ts +++ b/packages/types-database/src/types/provider.ts @@ -630,8 +630,11 @@ export const SessionRecordSchema = new Schema({ decryptedHeadHash: { type: String, required: false, default: "" }, siteKey: { type: String, required: false }, // Full page URL the widget was rendered on (origin + path only; query - // string, fragment and credentials stripped). See Session.currentUrl. + // string, fragment and credentials stripped). See Session.currentUrl — + // `currentUrl` is the top-frame URL, `iframeUrl` is the widget's own + // frame URL when embedded (undefined when the widget IS the top frame). currentUrl: { type: String, required: false }, + iframeUrl: { type: String, required: false }, reason: { type: String, required: false }, blocked: { type: Boolean, required: false }, // On synthetic blocked-session records (blocked=true, deleted=true) diff --git a/packages/types/src/api/params.ts b/packages/types/src/api/params.ts index 79c9a55ddc..81e85b491b 100644 --- a/packages/types/src/api/params.ts +++ b/packages/types/src/api/params.ts @@ -56,6 +56,7 @@ export enum ApiParams { behavioralData = "behavioralData", simdReadings = "simdReadings", currentUrl = "currentUrl", + iframeUrl = "iframeUrl", decisionMachineScope = "decisionMachineScope", decisionMachineRuntime = "decisionMachineRuntime", decisionMachineSource = "decisionMachineSource", diff --git a/packages/types/src/provider/api.ts b/packages/types/src/provider/api.ts index ff1e5a3571..74f5c3d986 100644 --- a/packages/types/src/provider/api.ts +++ b/packages/types/src/provider/api.ts @@ -560,7 +560,16 @@ export const GetFrictionlessCaptchaChallengeRequestBody = object({ // server-side and gated in the decision machine (a missing value forces // an image captcha). Optional on the wire so the schema still parses for // older clients — the decision machine handles absence. + // + // When the widget is embedded in an iframe, `currentUrl` is resolved to + // the top-frame URL (same-origin: read directly; cross-origin: via + // `document.referrer`). `iframeUrl` carries the widget's own frame URL + // alongside so analytics can distinguish "Protect's site-wide iframe + // endpoint" from "the page the user was actually on". Undefined when the + // widget IS the top frame — nothing to distinguish. Re-sanitised + // server-side; not gated in the decision machine. [ApiParams.currentUrl]: boundedString(INPUT_LIMITS.URL).optional(), + [ApiParams.iframeUrl]: boundedString(INPUT_LIMITS.URL).optional(), }); export type GetFrictionlessCaptchaChallengeRequestBodyOutput = output< diff --git a/packages/types/src/provider/database.ts b/packages/types/src/provider/database.ts index 594f97f706..043710ec93 100644 --- a/packages/types/src/provider/database.ts +++ b/packages/types/src/provider/database.ts @@ -414,7 +414,12 @@ export const SessionSchema = object({ // string, fragment and any embedded credentials are stripped client- and // server-side). Reported by the client in the frictionless payload; its // absence forces an image captcha. Optional so older sessions still parse. + // + // When the widget is embedded, `currentUrl` is the top-frame URL and + // `iframeUrl` is the widget's own frame URL. `iframeUrl` is undefined + // when the widget IS the top frame (nothing to distinguish). currentUrl: string().optional(), + iframeUrl: string().optional(), // Selection reason: writes go through `FrictionlessReason`, but the // schema accepts any string at runtime so old records (or unforeseen // values) still parse. Output type is cast back to the enum so the @@ -503,7 +508,12 @@ export type Session = { // string, fragment and any embedded credentials are stripped client- and // server-side). Reported by the client in the frictionless payload; its // absence forces an image captcha. + // + // When the widget is embedded, `currentUrl` is the top-frame URL and + // `iframeUrl` is the widget's own frame URL. `iframeUrl` is undefined + // when the widget IS the top frame (nothing to distinguish). currentUrl?: string; + iframeUrl?: string; reason?: FrictionlessReason; blocked?: boolean; // When `blocked` is true, these record which access-policy rule matched From b9351f6e8403660d1436dfa4ce29fcaa6f54e697 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Thu, 9 Jul 2026 10:58:59 +0100 Subject: [PATCH 2/4] feat(provider): surface iframeUrl to routing + frictionless decision machines Extends the previous commit: iframeUrl is now available alongside currentUrl on RoutingMachineRawSignals (route + postPow + dedup replay paths) and on the frictionless decision machine input, so a machine can key on the distinction between the widget's own frame URL and the top-frame URL. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/frictionless-iframe-url.md | 4 +++- .../decisionMachine.ts | 4 ++++ .../getFrictionlessCaptchaChallenge/handler.ts | 12 +++++++++--- packages/provider/src/tasks/powCaptcha/powTasks.ts | 13 +++++++++---- packages/types/src/decisionMachine/index.ts | 6 ++++++ 5 files changed, 31 insertions(+), 8 deletions(-) diff --git a/.changeset/frictionless-iframe-url.md b/.changeset/frictionless-iframe-url.md index 6a1d787439..4608fc9c7f 100644 --- a/.changeset/frictionless-iframe-url.md +++ b/.changeset/frictionless-iframe-url.md @@ -13,4 +13,6 @@ Previously the client only sent one field (`currentUrl`), which for embedded wid - `currentUrl`: the top-frame URL (same resolution rules as before — same-origin iframes read `window.top.location.href` directly; cross-origin iframes fall back to `document.referrer`). - `iframeUrl`: the widget's own frame URL when embedded. Undefined when the widget IS the top frame (nothing to distinguish). -Both fields are sanitised client- and server-side (origin + path only; query string, fragment and any embedded credentials stripped). The provider persists both on the `Session` record and re-uses them on post-PoW escalation sessions. Only `currentUrl` is gated in the decision machine (unchanged — missing `currentUrl` still forces an image captcha); `iframeUrl` is recorded for analytics. +Both fields are sanitised client- and server-side (origin + path only; query string, fragment and any embedded credentials stripped). The provider persists both on the `Session` record and re-uses them on post-PoW escalation sessions. Only `currentUrl` is gated in the frictionless decision machine (unchanged — missing `currentUrl` still forces an image captcha); `iframeUrl` is recorded for analytics. + +Both fields are also surfaced to the decision machines as raw signals: `RoutingMachineRawSignals` gains an optional `iframeUrl` populated from the freshly decrypted frictionless payload on the `route` phase, from the persisted Session record on the `postPow` phase, and from the cached Session in the dedup replay path — matching how `currentUrl` is already threaded through. diff --git a/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/decisionMachine.ts b/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/decisionMachine.ts index 0befd7b4e2..94165dd6fe 100644 --- a/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/decisionMachine.ts +++ b/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/decisionMachine.ts @@ -64,6 +64,10 @@ export type DecisionMachineInput = { // fragment / credentials). Undefined when the client didn't report a // usable page URL — see the missing-currentUrl gate below. currentUrl: string | undefined; + // Sanitised iframe URL when the widget was embedded (origin + path only). + // Undefined when the widget was the top frame — not gated in the machine; + // forwarded to the routing machine's raw signals for analytics. + iframeUrl: string | undefined; }; type ExpressHandle = { diff --git a/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts b/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts index 0a6970ffa3..4eb84e66d1 100644 --- a/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts +++ b/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts @@ -299,12 +299,16 @@ export default ( ...(req.chelloToHandshakeUs !== undefined && { chelloToHandshakeUs: req.chelloToHandshakeUs, }), - // currentUrl uses the cached session's value to - // match the rest of the dedup routing input (score, - // webView, captchaType are all pulled from dedup). + // currentUrl / iframeUrl use the cached session's + // values to match the rest of the dedup routing input + // (score, webView, captchaType are all pulled from + // dedup). ...(dedup.session.currentUrl && { currentUrl: dedup.session.currentUrl, }), + ...(dedup.session.iframeUrl && { + iframeUrl: dedup.session.iframeUrl, + }), }, }, ) @@ -584,6 +588,7 @@ export default ( chelloToHandshakeUs: req.chelloToHandshakeUs, }), ...(currentUrl && { currentUrl }), + ...(iframeUrl && { iframeUrl }), }, }); @@ -632,6 +637,7 @@ export default ( token, botThreshold, currentUrl, + iframeUrl, }, { req, res, next }, ); diff --git a/packages/provider/src/tasks/powCaptcha/powTasks.ts b/packages/provider/src/tasks/powCaptcha/powTasks.ts index 0fc649a28f..d8395260f6 100644 --- a/packages/provider/src/tasks/powCaptcha/powTasks.ts +++ b/packages/provider/src/tasks/powCaptcha/powTasks.ts @@ -536,13 +536,18 @@ export class PowCaptchaManager extends CaptchaManager { // (decryptAndAttachSimdReadingsIfAbsent) before this re-fetch, so // they are available here in decoded form for the routing machine. ...(sessionRecord.simdReadings && { simd: sessionRecord.simdReadings }), - // currentUrl was captured at frictionless time from the - // widget's payload — the submit request has no equivalent - // signal (its Referer is the captcha iframe, not the host - // page) so we surface it from the persisted session. + // currentUrl / iframeUrl were captured at frictionless time + // from the widget's payload — the submit request has no + // equivalent signal (its Referer is the captcha iframe, not + // the host page) so we surface them from the persisted + // session. iframeUrl is only present when the widget was + // embedded at frictionless time. ...(sessionRecord.currentUrl && { currentUrl: sessionRecord.currentUrl, }), + ...(sessionRecord.iframeUrl && { + iframeUrl: sessionRecord.iframeUrl, + }), }, }; diff --git a/packages/types/src/decisionMachine/index.ts b/packages/types/src/decisionMachine/index.ts index 461d2f876d..090a18a73e 100644 --- a/packages/types/src/decisionMachine/index.ts +++ b/packages/types/src/decisionMachine/index.ts @@ -240,7 +240,13 @@ export interface RoutingMachineRawSignals { // frictionless payload, and on the `postPow` phase from the persisted // Session record. Undefined when the client omitted it or the session // pre-dates the field. + // + // When the widget is embedded, `currentUrl` is the top-frame URL and + // `iframeUrl` is the widget's own frame URL. `iframeUrl` is undefined + // when the widget IS the top frame (nothing to distinguish) or when the + // client / persisted session pre-dates the field. currentUrl?: string; + iframeUrl?: string; } export type RoutingMachinePhase = "route" | "postPow"; From 574e8f46c38707c5425d26f0bd5a2570a933cc77 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Thu, 9 Jul 2026 11:04:43 +0100 Subject: [PATCH 3/4] feat(provider): computed isProtect flag on frictionless sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At session-creation time, derive a boolean `isProtect` from the (currentUrl, iframeUrl) pair — true when the widget iframe was served from `protect.` and embedded in a page on the same tenant. New `isProtectDeployment` helper in `@prosopo/util` does the check with strict dot-boundary matching (rejects `attackerclient.com` vs `protect.client.com`) and is case-insensitive. Persisted only when true (matches the `isEscalation` pattern); a sparse `{isProtect, createdAt}` index on the session collection makes retrieval by boolean cheap. Post-PoW escalation sessions inherit the flag from the origin session. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/frictionless-iframe-url.md | 3 + .../handler.ts | 9 +- .../api/captcha/submitPoWCaptchaSolution.ts | 1 + .../tasks/frictionless/frictionlessTasks.ts | 9 ++ packages/types-database/src/types/provider.ts | 12 +++ packages/types/src/provider/database.ts | 11 +++ packages/util/src/tests/url.unit.test.ts | 84 +++++++++++++++++++ packages/util/src/url.ts | 52 ++++++++++++ 8 files changed, 180 insertions(+), 1 deletion(-) diff --git a/.changeset/frictionless-iframe-url.md b/.changeset/frictionless-iframe-url.md index 4608fc9c7f..0d3e63800d 100644 --- a/.changeset/frictionless-iframe-url.md +++ b/.changeset/frictionless-iframe-url.md @@ -4,6 +4,7 @@ "@prosopo/api": patch "@prosopo/types": patch "@prosopo/types-database": patch +"@prosopo/util": patch --- Record both the top-frame URL and the widget's own iframe URL on frictionless sessions. @@ -16,3 +17,5 @@ Previously the client only sent one field (`currentUrl`), which for embedded wid Both fields are sanitised client- and server-side (origin + path only; query string, fragment and any embedded credentials stripped). The provider persists both on the `Session` record and re-uses them on post-PoW escalation sessions. Only `currentUrl` is gated in the frictionless decision machine (unchanged — missing `currentUrl` still forces an image captcha); `iframeUrl` is recorded for analytics. Both fields are also surfaced to the decision machines as raw signals: `RoutingMachineRawSignals` gains an optional `iframeUrl` populated from the freshly decrypted frictionless payload on the `route` phase, from the persisted Session record on the `postPow` phase, and from the cached Session in the dedup replay path — matching how `currentUrl` is already threaded through. + +Additionally, sessions carry a new computed boolean `isProtect`, set at session-creation time when the widget iframe was served from `protect.` and embedded in a page on the same tenant (subdomain-of matching, dot-boundary safe — see `isProtectDeployment` in `@prosopo/util`). Persisted only when true (same pattern as `isEscalation`) and backed by a sparse `{isProtect, createdAt}` index so analytics can cheaply retrieve Protect sessions without re-parsing URLs. Post-PoW escalation sessions inherit the flag from the origin session. diff --git a/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts b/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts index 4eb84e66d1..7f7b8e2f37 100644 --- a/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts +++ b/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts @@ -25,7 +25,7 @@ import { AccessPolicyType, type AccessRulesStorage, } from "@prosopo/user-access-policy"; -import { flatten, sanitisePageUrl } from "@prosopo/util"; +import { flatten, isProtectDeployment, sanitisePageUrl } from "@prosopo/util"; import type { NextFunction, Request, Response } from "express"; import { getCompositeIpAddress } from "../../../compositeIpAddress.js"; import type { AugmentedRequest } from "../../../express.js"; @@ -96,6 +96,12 @@ export default ( // It's not gated in the decision machine; recorded for analytics. const currentUrl = sanitisePageUrl(reportedCurrentUrl); const iframeUrl = sanitisePageUrl(reportedIframeUrl); + // Cheap boolean tag ("is the widget being loaded through Protect's + // site-wide iframe endpoint?") derived from the two sanitised + // URLs so downstream analytics can filter Protect sessions + // without re-parsing hosts. Only persisted when true — see the + // sparse index on {isProtect, createdAt}. + const isProtect = isProtectDeployment(currentUrl, iframeUrl); const normalizedIp = normalizeRequestIp(req.ip, req.logger); const sessionMode = @@ -539,6 +545,7 @@ export default ( siteKey: dapp, ...(currentUrl && { currentUrl }), ...(iframeUrl && { iframeUrl }), + ...(isProtect && { isProtect: true }), ipInfo: req.ipInfo, headers: flatHeaders, mode: sessionMode, diff --git a/packages/provider/src/api/captcha/submitPoWCaptchaSolution.ts b/packages/provider/src/api/captcha/submitPoWCaptchaSolution.ts index 8364a9a8e2..c3c20b5a08 100644 --- a/packages/provider/src/api/captcha/submitPoWCaptchaSolution.ts +++ b/packages/provider/src/api/captcha/submitPoWCaptchaSolution.ts @@ -300,6 +300,7 @@ export const buildEscalation = async ( handshakeTiming?.chelloToHandshakeUs, true, originSession.iframeUrl, + originSession.isProtect, ); // Record the origin → escalation sessionId mapping so a /captcha/* diff --git a/packages/provider/src/tasks/frictionless/frictionlessTasks.ts b/packages/provider/src/tasks/frictionless/frictionlessTasks.ts index c9356e87b9..68ef1232ff 100644 --- a/packages/provider/src/tasks/frictionless/frictionlessTasks.ts +++ b/packages/provider/src/tasks/frictionless/frictionlessTasks.ts @@ -139,6 +139,7 @@ export class FrictionlessManager extends CaptchaManager { siteKey: params.siteKey, currentUrl: params.currentUrl, iframeUrl: params.iframeUrl, + isProtect: params.isProtect, ipInfo: params.ipInfo, headers: params.headers, mode: params.mode, @@ -193,6 +194,7 @@ export class FrictionlessManager extends CaptchaManager { chelloToHandshakeUs?: Session["chelloToHandshakeUs"], isEscalation?: Session["isEscalation"], iframeUrl?: Session["iframeUrl"], + isProtect?: Session["isProtect"], ): Promise { const sessionRecord: Session = { sessionId: `${getSessionIDPrefix(this.config.host)}-${uuidv4()}`, @@ -218,6 +220,11 @@ export class FrictionlessManager extends CaptchaManager { siteKey, currentUrl, iframeUrl, + // Same rationale as isEscalation above: only persist the flag + // when it's actually true so non-Protect sessions stay slim and + // the sparse index on {isProtect, createdAt} carries only the + // Protect subset. + ...(isProtect && { isProtect: true }), blocked, deleted, ipInfo, @@ -376,6 +383,7 @@ export class FrictionlessManager extends CaptchaManager { effectiveParams.chelloToHandshakeUs, undefined, effectiveParams.iframeUrl, + effectiveParams.isProtect, ); // Fire-and-forget served-counter writes. Skipped when there's no @@ -449,6 +457,7 @@ export class FrictionlessManager extends CaptchaManager { effectiveParams.chelloToHandshakeUs, undefined, effectiveParams.iframeUrl, + effectiveParams.isProtect, ); } diff --git a/packages/types-database/src/types/provider.ts b/packages/types-database/src/types/provider.ts index 0f1304f075..1373eb92f4 100644 --- a/packages/types-database/src/types/provider.ts +++ b/packages/types-database/src/types/provider.ts @@ -635,6 +635,10 @@ export const SessionRecordSchema = new Schema({ // frame URL when embedded (undefined when the widget IS the top frame). currentUrl: { type: String, required: false }, iframeUrl: { type: String, required: false }, + // Computed at session-creation time from (currentUrl, iframeUrl) via + // isProtectDeployment. Persisted only when true so ordinary sessions + // stay slim and the sparse index below only carries Protect records. + isProtect: { type: Boolean, required: false }, reason: { type: String, required: false }, blocked: { type: Boolean, required: false }, // On synthetic blocked-session records (blocked=true, deleted=true) @@ -725,6 +729,14 @@ SessionRecordSchema.index( { isEscalation: 1, createdAt: 1 }, { background: true, sparse: true }, ); +// Protect analytics — same rationale as isEscalation. Only sessions minted +// through a `protect.` iframe carry the field, so the sparse index +// stays small and lets us cheaply filter "give me Protect sessions in the +// last N days" without a full collection scan. +SessionRecordSchema.index( + { isProtect: 1, createdAt: 1 }, + { background: true, sparse: true }, +); // Compound indexes for session aggregation queries SessionRecordSchema.index({ createdAt: 1, diff --git a/packages/types/src/provider/database.ts b/packages/types/src/provider/database.ts index 043710ec93..86808377dc 100644 --- a/packages/types/src/provider/database.ts +++ b/packages/types/src/provider/database.ts @@ -420,6 +420,13 @@ export const SessionSchema = object({ // when the widget IS the top frame (nothing to distinguish). currentUrl: string().optional(), iframeUrl: string().optional(), + // True when this session looks like a Protect deployment: the widget + // iframe was served from `protect.` and embedded in a page on + // the same tenant (see isProtectDeployment in @prosopo/util for the + // exact rule). Persisted only when true — matches the `isEscalation` + // pattern so ordinary sessions stay slim and a sparse index carries + // only the Protect subset. + isProtect: boolean().optional(), // Selection reason: writes go through `FrictionlessReason`, but the // schema accepts any string at runtime so old records (or unforeseen // values) still parse. Output type is cast back to the enum so the @@ -514,6 +521,10 @@ export type Session = { // when the widget IS the top frame (nothing to distinguish). currentUrl?: string; iframeUrl?: string; + // True when this session looks like a Protect deployment — widget + // iframe served from `protect.`, embedded in a page on the + // same tenant. Undefined/absent on non-Protect sessions. + isProtect?: boolean; reason?: FrictionlessReason; blocked?: boolean; // When `blocked` is true, these record which access-policy rule matched diff --git a/packages/util/src/tests/url.unit.test.ts b/packages/util/src/tests/url.unit.test.ts index bc08117739..d75cffab76 100644 --- a/packages/util/src/tests/url.unit.test.ts +++ b/packages/util/src/tests/url.unit.test.ts @@ -15,6 +15,7 @@ import { describe, expect, it } from "vitest"; import { buildDomainSuffixCandidates, decodeGoogleTranslateHost, + isProtectDeployment, sanitisePageUrl, validateDomain, } from "../url.js"; @@ -221,3 +222,86 @@ describe("sanitisePageUrl", () => { expect(sanitisePageUrl("/relative/path")).toBeUndefined(); }); }); + +describe("isProtectDeployment", () => { + it("returns true when the iframe is on protect. and the top frame is on the tenant", () => { + expect( + isProtectDeployment( + "https://client.com/", + "https://protect.client.com/widget", + ), + ).toBe(true); + }); + + it("returns true when the top frame is a subdomain of the tenant", () => { + expect( + isProtectDeployment( + "https://www.client.com/", + "https://protect.client.com/widget", + ), + ).toBe(true); + expect( + isProtectDeployment( + "https://foo.client.com/", + "https://protect.client.com/widget", + ), + ).toBe(true); + }); + + it("is case-insensitive on host", () => { + expect( + isProtectDeployment( + "https://CLIENT.com/", + "https://Protect.Client.COM/widget", + ), + ).toBe(true); + }); + + it("rejects hosts that share a suffix without a dot boundary", () => { + expect( + isProtectDeployment( + "https://attackerclient.com/", + "https://protect.client.com/widget", + ), + ).toBe(false); + }); + + it("rejects mismatched tenants", () => { + expect( + isProtectDeployment( + "https://client.com/", + "https://protect.other.com/widget", + ), + ).toBe(false); + }); + + it("rejects iframes not served from protect.", () => { + expect( + isProtectDeployment( + "https://client.com/", + "https://widget.client.com/", + ), + ).toBe(false); + expect( + isProtectDeployment("https://client.com/", "https://client.com/widget"), + ).toBe(false); + }); + + it("rejects a bare protect. prefix with no tail", () => { + // URL parser will normally reject these; guard both defensively. + expect( + isProtectDeployment("https://client.com/", "https://protect./widget"), + ).toBe(false); + }); + + it("returns false when either URL is missing or unparseable", () => { + expect( + isProtectDeployment(undefined, "https://protect.client.com/"), + ).toBe(false); + expect(isProtectDeployment("https://client.com/", undefined)).toBe(false); + expect(isProtectDeployment(null, null)).toBe(false); + expect( + isProtectDeployment("not a url", "https://protect.client.com/"), + ).toBe(false); + }); +}); diff --git a/packages/util/src/url.ts b/packages/util/src/url.ts index 2e9b428e74..8801252591 100644 --- a/packages/util/src/url.ts +++ b/packages/util/src/url.ts @@ -239,3 +239,55 @@ export const sanitisePageUrl = ( return url.toString(); }; + +/** + * @description Decides whether a frictionless session came through a Protect + * deployment — i.e. the widget was served from `protect.` and embedded + * into a page hosted at the same tenant. + * + * A site is deemed to be running Protect when the widget iframe's host starts + * with `protect.` AND the tail (host minus the leading `protect.` label) either + * equals the top-frame host or the top-frame host is a subdomain of it. + * + * Examples (tail = "client.com"): + * - top `client.com` + iframe `protect.client.com` → true + * - top `www.client.com` + iframe `protect.client.com` → true (subdomain) + * - top `foo.client.com` + iframe `protect.client.com` → true (subdomain) + * - top `attackerclient.com` + iframe `protect.client.com` → false (no dot + * boundary — endsWith is guarded against the "attackerclient.com" case) + * - top `client.com` + iframe `protect.other.com` → false + * + * Returns false when either URL is missing / unparseable, when the iframe host + * doesn't start with `protect.`, or when the tail collapses to empty / a bare + * TLD. Deliberately does no PSL / registrable-domain lookup — we don't want a + * dependency here and the `protect.` DNS record is under our control + * anyway, so an exact suffix check is safe. + * + * @param currentUrl The top-frame URL as recorded on the session. + * @param iframeUrl The widget's own frame URL as recorded on the session. + * @returns True when the session looks like a Protect deployment. + */ +export const isProtectDeployment = ( + currentUrl: string | undefined | null, + iframeUrl: string | undefined | null, +): boolean => { + if (!currentUrl || !iframeUrl) return false; + + let currentHost: string; + let iframeHost: string; + try { + currentHost = new URL(currentUrl).hostname.toLowerCase(); + iframeHost = new URL(iframeUrl).hostname.toLowerCase(); + } catch { + return false; + } + if (!currentHost || !iframeHost) return false; + + const prefix = "protect."; + if (!iframeHost.startsWith(prefix)) return false; + const tail = iframeHost.slice(prefix.length); + // Reject bare TLDs / empty tails: at minimum we need a label + dot + TLD. + if (!tail || !tail.includes(".")) return false; + + return currentHost === tail || currentHost.endsWith(`.${tail}`); +}; From 4ad799518da8bfc6773d5ba1c4d3471bcef7f3aa Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Thu, 9 Jul 2026 11:10:11 +0100 Subject: [PATCH 4/4] style: biome format url.unit.test.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-fix from `biome check --write` — CI biome lint was failing on line-wrap style in the new isProtectDeployment tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/util/src/tests/url.unit.test.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/util/src/tests/url.unit.test.ts b/packages/util/src/tests/url.unit.test.ts index d75cffab76..a5184ec4ef 100644 --- a/packages/util/src/tests/url.unit.test.ts +++ b/packages/util/src/tests/url.unit.test.ts @@ -277,10 +277,7 @@ describe("isProtectDeployment", () => { it("rejects iframes not served from protect.", () => { expect( - isProtectDeployment( - "https://client.com/", - "https://widget.client.com/", - ), + isProtectDeployment("https://client.com/", "https://widget.client.com/"), ).toBe(false); expect( isProtectDeployment("https://client.com/", "https://client.com/widget"), @@ -295,9 +292,9 @@ describe("isProtectDeployment", () => { }); it("returns false when either URL is missing or unparseable", () => { - expect( - isProtectDeployment(undefined, "https://protect.client.com/"), - ).toBe(false); + expect(isProtectDeployment(undefined, "https://protect.client.com/")).toBe( + false, + ); expect(isProtectDeployment("https://client.com/", undefined)).toBe(false); expect(isProtectDeployment(null, null)).toBe(false); expect(