diff --git a/.changeset/frictionless-iframe-url.md b/.changeset/frictionless-iframe-url.md new file mode 100644 index 0000000000..0d3e63800d --- /dev/null +++ b/.changeset/frictionless-iframe-url.md @@ -0,0 +1,21 @@ +--- +"@prosopo/procaptcha-frictionless": patch +"@prosopo/provider": patch +"@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. + +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 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/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/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 321c0d5037..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"; @@ -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,18 @@ 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); + // 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 = @@ -293,12 +305,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, + }), }, }, ) @@ -528,6 +544,8 @@ export default ( decryptedHeadHash, siteKey: dapp, ...(currentUrl && { currentUrl }), + ...(iframeUrl && { iframeUrl }), + ...(isProtect && { isProtect: true }), ipInfo: req.ipInfo, headers: flatHeaders, mode: sessionMode, @@ -577,6 +595,7 @@ export default ( chelloToHandshakeUs: req.chelloToHandshakeUs, }), ...(currentUrl && { currentUrl }), + ...(iframeUrl && { iframeUrl }), }, }); @@ -625,6 +644,7 @@ export default ( token, botThreshold, currentUrl, + iframeUrl, }, { req, res, next }, ); diff --git a/packages/provider/src/api/captcha/submitPoWCaptchaSolution.ts b/packages/provider/src/api/captcha/submitPoWCaptchaSolution.ts index ccf3f24fc6..c3c20b5a08 100644 --- a/packages/provider/src/api/captcha/submitPoWCaptchaSolution.ts +++ b/packages/provider/src/api/captcha/submitPoWCaptchaSolution.ts @@ -299,6 +299,8 @@ export const buildEscalation = async ( handshakeTiming?.tcpToChelloUs, 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 b2ac7e48c0..68ef1232ff 100644 --- a/packages/provider/src/tasks/frictionless/frictionlessTasks.ts +++ b/packages/provider/src/tasks/frictionless/frictionlessTasks.ts @@ -138,6 +138,8 @@ export class FrictionlessManager extends CaptchaManager { decryptedHeadHash: params.decryptedHeadHash, siteKey: params.siteKey, currentUrl: params.currentUrl, + iframeUrl: params.iframeUrl, + isProtect: params.isProtect, ipInfo: params.ipInfo, headers: params.headers, mode: params.mode, @@ -191,6 +193,8 @@ export class FrictionlessManager extends CaptchaManager { tcpToChelloUs?: Session["tcpToChelloUs"], chelloToHandshakeUs?: Session["chelloToHandshakeUs"], isEscalation?: Session["isEscalation"], + iframeUrl?: Session["iframeUrl"], + isProtect?: Session["isProtect"], ): Promise { const sessionRecord: Session = { sessionId: `${getSessionIDPrefix(this.config.host)}-${uuidv4()}`, @@ -215,6 +219,12 @@ export class FrictionlessManager extends CaptchaManager { reason, 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, @@ -371,6 +381,9 @@ export class FrictionlessManager extends CaptchaManager { effectiveParams.currentUrl, effectiveParams.tcpToChelloUs, effectiveParams.chelloToHandshakeUs, + undefined, + effectiveParams.iframeUrl, + effectiveParams.isProtect, ); // Fire-and-forget served-counter writes. Skipped when there's no @@ -442,6 +455,9 @@ export class FrictionlessManager extends CaptchaManager { effectiveParams.currentUrl, effectiveParams.tcpToChelloUs, effectiveParams.chelloToHandshakeUs, + undefined, + effectiveParams.iframeUrl, + effectiveParams.isProtect, ); } 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-database/src/types/provider.ts b/packages/types-database/src/types/provider.ts index 002494696c..1373eb92f4 100644 --- a/packages/types-database/src/types/provider.ts +++ b/packages/types-database/src/types/provider.ts @@ -630,8 +630,15 @@ 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 }, + // 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) @@ -722,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/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/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"; 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..86808377dc 100644 --- a/packages/types/src/provider/database.ts +++ b/packages/types/src/provider/database.ts @@ -414,7 +414,19 @@ 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(), + // 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 @@ -503,7 +515,16 @@ 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; + // 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..a5184ec4ef 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,83 @@ 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}`); +};