diff --git a/.changeset/frictionless-session-per-widget.md b/.changeset/frictionless-session-per-widget.md new file mode 100644 index 0000000000..6754360b7b --- /dev/null +++ b/.changeset/frictionless-session-per-widget.md @@ -0,0 +1,8 @@ +--- +"@prosopo/types": patch +"@prosopo/api": patch +"@prosopo/procaptcha-frictionless": patch +"@prosopo/provider": patch +--- + +Pages with more than one widget no longer make every widget after the first fail with `No session found`. The provider hands the same live session to every /frictionless call from one user, IP and site key, and the first challenge used it up, so each later widget got a 400 and had to fetch a new session; clicking several at once could run a widget out of retries and reset it. Each widget now sends a slot number (0, 1, 2, … in page order, capped at 15) and the provider keeps one session per slot. The first widget keeps the old dedup key, so single-widget pages are unchanged, and a reload still reuses each widget's own session. diff --git a/packages/api/src/api/ProviderApi.ts b/packages/api/src/api/ProviderApi.ts index 72a199a8f4..9f426d4a2f 100644 --- a/packages/api/src/api/ProviderApi.ts +++ b/packages/api/src/api/ProviderApi.ts @@ -627,6 +627,7 @@ export default class ProviderApi refreshOf?: string, switchToImage?: boolean, fingerprintProof?: string, + widgetSlot?: number, ): Promise { const body: GetFrictionlessCaptchaChallengeRequestBodyOutput = { [ApiParams.dapp]: dapp, @@ -649,6 +650,7 @@ export default class ProviderApi ...(fingerprintProof && { [ApiParams.fingerprintProof]: fingerprintProof, }), + ...(widgetSlot !== undefined && { [ApiParams.widgetSlot]: widgetSlot }), }; const { data, headers } = await this.postWithHeaders< GetFrictionlessCaptchaResponse, diff --git a/packages/procaptcha-frictionless/src/customDetectBot.ts b/packages/procaptcha-frictionless/src/customDetectBot.ts index 3a4699b4a3..ce0259ec44 100644 --- a/packages/procaptcha-frictionless/src/customDetectBot.ts +++ b/packages/procaptcha-frictionless/src/customDetectBot.ts @@ -176,6 +176,7 @@ const customDetectBot: BotDetectionFunction = async ( retryContext?: ProviderSelectRetryContext, refreshOf?: string, switchToImage?: boolean, + widgetSlot?: number, ): Promise => { if (!config.account.address) { throw new ProsopoError({ code: "SITE_KEY_MISSING", errorSite: "FRIC-2" }); @@ -325,6 +326,7 @@ const customDetectBot: BotDetectionFunction = async ( refreshOf, switchToImage, fallbackFingerprintProof, + widgetSlot, ), 10000, ); @@ -387,6 +389,7 @@ const customDetectBot: BotDetectionFunction = async ( refreshOf, switchToImage, fingerprintProof, + widgetSlot, ); if (detectionResult.getSimdReadings) { // Fire-and-forget: triggers the memoised prefetch inside the catcher diff --git a/packages/procaptcha-frictionless/src/procaptchaFrictionless.ts b/packages/procaptcha-frictionless/src/procaptchaFrictionless.ts index 017f891440..0cc24b5c5f 100644 --- a/packages/procaptcha-frictionless/src/procaptchaFrictionless.ts +++ b/packages/procaptcha-frictionless/src/procaptchaFrictionless.ts @@ -57,6 +57,7 @@ import { handleSessionInvalidated, normaliseRetryCoords, } from "./sessionInvalidatedRecovery.js"; +import { claimWidgetSlot, releaseWidgetSlot } from "./widgetSlot.js"; const frontendError = (error: ProsopoError): WidgetError => toPublicError(error, "FRONTEND_ERROR"); @@ -183,9 +184,10 @@ export const mountProcaptchaFrictionless = ( // that window (e.g. a fast form submit) would otherwise be dropped and the // challenge never open, so it is held here and replayed on mount. let pendingExecute = false; - const banner: StaticComponent = mountTestModeBanner(container, { - siteKey: config.account?.address ?? "", - }); + const siteKey = config.account?.address ?? ""; + const banner: StaticComponent = mountTestModeBanner(container, { siteKey }); + const widgetSlot = claimWidgetSlot(siteKey); + teardown.add(() => releaseWidgetSlot(siteKey, widgetSlot)); // The solver (or the placeholder checkbox) renders here. Swapping the slot's // contents replaces what the React version did by putting an element in @@ -545,6 +547,7 @@ export const mountProcaptchaFrictionless = ( { attempt: state.attemptCount }, nextRefreshOf, nextSwitchToImage, + widgetSlot, ); const guard = evaluateFrictionlessResult(result); diff --git a/packages/procaptcha-frictionless/src/tests/procaptchaFrictionless.test.ts b/packages/procaptcha-frictionless/src/tests/procaptchaFrictionless.test.ts index 48201189f6..790b789ac6 100644 --- a/packages/procaptcha-frictionless/src/tests/procaptchaFrictionless.test.ts +++ b/packages/procaptcha-frictionless/src/tests/procaptchaFrictionless.test.ts @@ -620,3 +620,28 @@ describe("tearing down", () => { expect(mocks.mounted).toHaveLength(0); }); }); + +describe("several widgets on one page", () => { + test("gives each widget for a site key its own slot, and reuses a freed one", async () => { + const detectBot = vi + .fn() + .mockResolvedValue(detection(CaptchaType.pow)); + const second = document.createElement("div"); + document.body.appendChild(second); + widget = mountProcaptchaFrictionless(container, props(detectBot)); + const secondWidget = mountProcaptchaFrictionless(second, props(detectBot)); + await settle(); + expect(detectBot.mock.calls.map((call) => call[6])).toEqual([0, 1]); + + widget.destroy(); + const remounted = vi + .fn() + .mockResolvedValue(detection(CaptchaType.pow)); + widget = mountProcaptchaFrictionless(container, props(remounted)); + await settle(); + expect(remounted.mock.calls[0]?.[6]).toBe(0); + + secondWidget.destroy(); + second.remove(); + }); +}); diff --git a/packages/procaptcha-frictionless/src/tests/widgetSlot.test.ts b/packages/procaptcha-frictionless/src/tests/widgetSlot.test.ts new file mode 100644 index 0000000000..c61796649c --- /dev/null +++ b/packages/procaptcha-frictionless/src/tests/widgetSlot.test.ts @@ -0,0 +1,55 @@ +// Copyright 2021-2026 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { MAX_WIDGET_SLOT } from "@prosopo/types"; +import { afterEach, describe, expect, test } from "vitest"; +import { claimWidgetSlot, releaseWidgetSlot } from "../widgetSlot.js"; + +describe("widget slots", () => { + const claimed: { siteKey: string; slot: number }[] = []; + const claim = (siteKey: string): number => { + const slot = claimWidgetSlot(siteKey); + claimed.push({ siteKey, slot }); + return slot; + }; + + afterEach(() => { + for (const { siteKey, slot } of claimed.splice(0)) { + releaseWidgetSlot(siteKey, slot); + } + }); + + test("hands out the lowest free slot", () => { + expect([claim("a"), claim("a"), claim("a")]).toEqual([0, 1, 2]); + releaseWidgetSlot("a", 1); + expect(claim("a")).toBe(1); + }); + + test("counts each site key separately", () => { + expect([claim("a"), claim("b"), claim("a")]).toEqual([0, 0, 1]); + }); + + test("puts widgets past the cap on the last slot", () => { + for (let slot = 0; slot < MAX_WIDGET_SLOT; slot++) claim("a"); + expect([claim("a"), claim("a")]).toEqual([ + MAX_WIDGET_SLOT, + MAX_WIDGET_SLOT, + ]); + }); + + test("ignores releasing a slot that was never claimed", () => { + releaseWidgetSlot("unknown", 3); + expect(claim("unknown")).toBe(0); + }); +}); diff --git a/packages/procaptcha-frictionless/src/widgetSlot.ts b/packages/procaptcha-frictionless/src/widgetSlot.ts new file mode 100644 index 0000000000..2e8d13c310 --- /dev/null +++ b/packages/procaptcha-frictionless/src/widgetSlot.ts @@ -0,0 +1,43 @@ +// Copyright 2021-2026 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { MAX_WIDGET_SLOT } from "@prosopo/types"; + +// The provider hands every /frictionless call from the same user, IP and site +// key the same live session, and a session is used up by the first challenge +// it issues. Without a slot, a second widget on the page is handed a session +// the first one has already spent. Slots are the lowest free index rather than +// random so a reloaded page asks for the same slots and still reuses its +// sessions. Widgets past the cap share the last slot, as every widget did +// before slots existed. +const claimedSlots = new Map>(); + +export const claimWidgetSlot = (siteKey: string): number => { + const claimed = claimedSlots.get(siteKey) ?? new Set(); + claimedSlots.set(siteKey, claimed); + for (let slot = 0; slot < MAX_WIDGET_SLOT; slot++) { + if (!claimed.has(slot)) { + claimed.add(slot); + return slot; + } + } + return MAX_WIDGET_SLOT; +}; + +export const releaseWidgetSlot = (siteKey: string, slot: number): void => { + const claimed = claimedSlots.get(siteKey); + if (!claimed) return; + claimed.delete(slot); + if (claimed.size === 0) claimedSlots.delete(siteKey); +}; diff --git a/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts b/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts index bf2e24c3e7..e47fe6faef 100644 --- a/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts +++ b/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts @@ -118,6 +118,7 @@ export default ( refreshOf, switchToImage, fingerprintProof, + widgetSlot, } = GetFrictionlessCaptchaChallengeRequestBody.parse(req.body); // Recorded on the session and copied onto whichever captcha record it @@ -214,7 +215,12 @@ export default ( } const tasks = new Tasks(env, req.logger); - const userSitekeyIpHash = hashUserIp(user, normalizedIp, dapp); + const userSitekeyIpHash = hashUserIp( + user, + normalizedIp, + dapp, + widgetSlot, + ); // Fan out the three independent async dependencies in // parallel: SIMD reading decrypt, session dedup lookup, and diff --git a/packages/provider/src/tests/unit/api/getFrictionlessCaptchaChallenge.unit.test.ts b/packages/provider/src/tests/unit/api/getFrictionlessCaptchaChallenge.unit.test.ts index ab1c6fed37..32efc4abaf 100644 --- a/packages/provider/src/tests/unit/api/getFrictionlessCaptchaChallenge.unit.test.ts +++ b/packages/provider/src/tests/unit/api/getFrictionlessCaptchaChallenge.unit.test.ts @@ -23,6 +23,7 @@ import type { I18nLookup } from "@prosopo/locale"; import { CaptchaType, type IPInfoResult, + MAX_WIDGET_SLOT, TrafficFilterAction, } from "@prosopo/types"; import { AccessPolicyType } from "@prosopo/user-access-policy"; @@ -155,8 +156,8 @@ vi.mock("../../../utils/hashUserAgent.js", () => ({ // Mock the hashUserIp utility vi.mock("../../../utils/hashUserIp.js", () => ({ hashUserIp: vi.fn( - (user: string, ip: string, sitekey: string) => - `hash-${user}-${ip}-${sitekey}`, + (user: string, ip: string, sitekey: string, widgetSlot?: number) => + `hash-${user}-${ip}-${sitekey}${widgetSlot ? `-${widgetSlot}` : ""}`, ), })); @@ -731,6 +732,58 @@ describe("getFrictionlessCaptchaChallenge - context selection", () => { ); }); + describe("several widgets on one page", () => { + const clientRecord = { + account: "siteManyWidgets", + settings: { + captchaType: CaptchaType.frictionless, + frictionlessThreshold: 0.5, + disallowWebView: false, + }, + }; + const request = (widgetSlot?: number): Record => ({ + token: `tSlot${widgetSlot}`, + headHash: "hhSlot", + dapp: "siteManyWidgets", + user: "u", + ...(widgetSlot !== undefined && { widgetSlot }), + }); + + beforeEach(() => { + tasksInstance.db.getClientRecord.mockResolvedValue(clientRecord); + tasksInstance.frictionlessManager.decryptPayload.mockResolvedValue( + payload(false), + ); + }); + + it("looks up a separate dedup session for each widget slot", async () => { + for (const widgetSlot of [undefined, 0, 1, 2]) { + const { req, res, next } = buildReqRes(request(widgetSlot)); + // biome-ignore lint/suspicious/noExplicitAny: mock request + await handler(req as any, res as any, next); + expect(next).not.toHaveBeenCalled(); + } + + const lookedUp = + tasksInstance.db.getSessionByuserSitekeyIpHash.mock.calls.map( + (call: unknown[]) => call[0], + ); + expect(lookedUp[0]).toBe(lookedUp[1]); + expect(new Set(lookedUp).size).toBe(3); + }); + + it("rejects a widget slot past the cap", async () => { + const { req, res, next } = buildReqRes(request(MAX_WIDGET_SLOT + 1)); + // biome-ignore lint/suspicious/noExplicitAny: mock request + await handler(req as any, res as any, next); + + expect(next).toHaveBeenCalled(); + expect( + tasksInstance.db.getSessionByuserSitekeyIpHash, + ).not.toHaveBeenCalled(); + }); + }); + it("evicts the reused session and re-routes when the routing machine returns a different captchaType", async () => { const clientRecord = { account: "siteDedupRoutingConflict", diff --git a/packages/provider/src/tests/unit/utils/hashUserIp.unit.test.ts b/packages/provider/src/tests/unit/utils/hashUserIp.unit.test.ts index 52918dd988..c1b5a69afd 100644 --- a/packages/provider/src/tests/unit/utils/hashUserIp.unit.test.ts +++ b/packages/provider/src/tests/unit/utils/hashUserIp.unit.test.ts @@ -120,4 +120,21 @@ describe("hashUserIp", () => { expect(result).toBe(result2); expect(result).toHaveLength(64); }); + + it("keeps the first widget slot on the original hash", () => { + expect(hashUserIp("user", "1.2.3.4", "site", 0)).toBe( + hashUserIp("user", "1.2.3.4", "site"), + ); + }); + + it("gives each later widget slot its own hash", () => { + const slots: number[] = [0, 1, 2, 15]; + const hashes = new Set( + slots.map((slot: number) => hashUserIp("user", "1.2.3.4", "site", slot)), + ); + expect(hashes.size).toBe(slots.length); + expect(hashUserIp("user", "1.2.3.4", "site", 1)).toBe( + hashUserIp("user", "1.2.3.4", "site", 1), + ); + }); }); diff --git a/packages/provider/src/utils/hashUserIp.ts b/packages/provider/src/utils/hashUserIp.ts index 44646fc16a..176fc6736d 100644 --- a/packages/provider/src/utils/hashUserIp.ts +++ b/packages/provider/src/utils/hashUserIp.ts @@ -16,14 +16,24 @@ import { createHash } from "node:crypto"; /** * Hash a user and IP address combination to create a unique session identifier - * SHA-256 hash of user:ip:sitekey combination + * SHA-256 hash of user:ip:sitekey combination, plus the widget slot for every + * widget on the page but the first * * @param user - The user identifier * @param ip - The IP address + * @param widgetSlot - Which of the page's widgets for this site key is asking * @returns A 64-character hex string representing the hash */ -export function hashUserIp(user: string, ip: string, sitekey: string): string { +export function hashUserIp( + user: string, + ip: string, + sitekey: string, + widgetSlot?: number, +): string { const hash = createHash("sha256"); - hash.update(`${user}:${ip}:${sitekey}`, "utf8"); + const key = widgetSlot + ? `${user}:${ip}:${sitekey}:${widgetSlot}` + : `${user}:${ip}:${sitekey}`; + hash.update(key, "utf8"); return hash.digest("hex"); } diff --git a/packages/types/src/api/params.ts b/packages/types/src/api/params.ts index 9e989e6eb0..c7d66ffa49 100644 --- a/packages/types/src/api/params.ts +++ b/packages/types/src/api/params.ts @@ -103,6 +103,7 @@ export enum ApiParams { clientSessionId = "clientSessionId", refreshOf = "refreshOf", switchToImage = "switchToImage", + widgetSlot = "widgetSlot", imageSwitchAvailable = "imageSwitchAvailable", honeypot = "honeypot", fingerprintProof = "fingerprintProof", diff --git a/packages/types/src/procaptcha-frictionless/props.ts b/packages/types/src/procaptcha-frictionless/props.ts index 4cc6fe8d1d..562f2587d4 100644 --- a/packages/types/src/procaptcha-frictionless/props.ts +++ b/packages/types/src/procaptcha-frictionless/props.ts @@ -79,6 +79,8 @@ export type BotDetectionFunction = ( refreshOf?: string, // The user asked for the replacement to be an image challenge. switchToImage?: boolean, + // This widget's slot among the page's widgets for the same site key. + widgetSlot?: number, ) => Promise; /** diff --git a/packages/types/src/provider/api.ts b/packages/types/src/provider/api.ts index d8d99bf2d0..0a0364f744 100644 --- a/packages/types/src/provider/api.ts +++ b/packages/types/src/provider/api.ts @@ -736,6 +736,10 @@ export type SubmitPowCaptchaSolutionBodyType = input< typeof SubmitPowCaptchaSolutionBody >; +// Caps how many concurrent frictionless sessions one user can hold per site +// key by varying the slot. +export const MAX_WIDGET_SLOT = 15; + export const GetFrictionlessCaptchaChallengeRequestBody = object({ [ApiParams.dapp]: boundedString(INPUT_LIMITS.ID), // Empty when the client had no detector to run — never a bypass; the @@ -786,6 +790,9 @@ export const GetFrictionlessCaptchaChallengeRequestBody = object({ // Encoded proof of fingerprint. Sent here so every session carries the // device bucket whichever captcha type it is served, not only PoW. [ApiParams.fingerprintProof]: boundedString(INPUT_LIMITS.TOKEN).optional(), + // Which of the page's widgets for this site key is asking, so each one is + // deduplicated onto its own session instead of all sharing the first. + [ApiParams.widgetSlot]: number().int().min(0).max(MAX_WIDGET_SLOT).optional(), }); export type GetFrictionlessCaptchaChallengeRequestBodyOutput = output<