Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/frictionless-iframe-url.md
Original file line number Diff line number Diff line change
@@ -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.<tenant>` 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.
2 changes: 2 additions & 0 deletions packages/api/src/api/ProviderApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@ export default class ProviderApi
mode?: ModeEnum,
simdReadings?: string,
currentUrl?: string,
iframeUrl?: string,
): Promise<GetFrictionlessCaptchaResponse> {
const body: GetFrictionlessCaptchaChallengeRequestBodyOutput = {
[ApiParams.token]: token,
Expand All @@ -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,
Expand Down
61 changes: 38 additions & 23 deletions packages/procaptcha-frictionless/src/customDetectBot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -53,24 +56,31 @@ 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;
if (!origin || origin === "null") return 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 */
}
Expand All @@ -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 <T>(
Expand Down Expand Up @@ -165,14 +178,16 @@ 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,
config.account.address,
userAccount.account.address,
config.mode,
undefined,
getCurrentPageUrl(),
currentUrl,
iframeUrl,
);
if (detectionResult.getSimdReadings) {
// Fire-and-forget: triggers the memoised prefetch inside the catcher
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -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 =
Expand Down Expand Up @@ -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,
}),
},
},
)
Expand Down Expand Up @@ -528,6 +544,8 @@ export default (
decryptedHeadHash,
siteKey: dapp,
...(currentUrl && { currentUrl }),
...(iframeUrl && { iframeUrl }),
...(isProtect && { isProtect: true }),
ipInfo: req.ipInfo,
headers: flatHeaders,
mode: sessionMode,
Expand Down Expand Up @@ -577,6 +595,7 @@ export default (
chelloToHandshakeUs: req.chelloToHandshakeUs,
}),
...(currentUrl && { currentUrl }),
...(iframeUrl && { iframeUrl }),
},
});

Expand Down Expand Up @@ -625,6 +644,7 @@ export default (
token,
botThreshold,
currentUrl,
iframeUrl,
},
{ req, res, next },
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/*
Expand Down
16 changes: 16 additions & 0 deletions packages/provider/src/tasks/frictionless/frictionlessTasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Session> {
const sessionRecord: Session = {
sessionId: `${getSessionIDPrefix(this.config.host)}-${uuidv4()}`,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -442,6 +455,9 @@ export class FrictionlessManager extends CaptchaManager {
effectiveParams.currentUrl,
effectiveParams.tcpToChelloUs,
effectiveParams.chelloToHandshakeUs,
undefined,
effectiveParams.iframeUrl,
effectiveParams.isProtect,
);
}

Expand Down
13 changes: 9 additions & 4 deletions packages/provider/src/tasks/powCaptcha/powTasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
},
};

Expand Down
17 changes: 16 additions & 1 deletion packages/types-database/src/types/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,8 +630,15 @@ export const SessionRecordSchema = new Schema<SessionRecord>({
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)
Expand Down Expand Up @@ -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.<tenant>` 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,
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/api/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export enum ApiParams {
behavioralData = "behavioralData",
simdReadings = "simdReadings",
currentUrl = "currentUrl",
iframeUrl = "iframeUrl",
decisionMachineScope = "decisionMachineScope",
decisionMachineRuntime = "decisionMachineRuntime",
decisionMachineSource = "decisionMachineSource",
Expand Down
6 changes: 6 additions & 0 deletions packages/types/src/decisionMachine/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading
Loading