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
8 changes: 8 additions & 0 deletions .changeset/frictionless-session-per-widget.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/api/src/api/ProviderApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,7 @@ export default class ProviderApi
refreshOf?: string,
switchToImage?: boolean,
fingerprintProof?: string,
widgetSlot?: number,
): Promise<GetFrictionlessCaptchaResponse> {
const body: GetFrictionlessCaptchaChallengeRequestBodyOutput = {
[ApiParams.dapp]: dapp,
Expand All @@ -649,6 +650,7 @@ export default class ProviderApi
...(fingerprintProof && {
[ApiParams.fingerprintProof]: fingerprintProof,
}),
...(widgetSlot !== undefined && { [ApiParams.widgetSlot]: widgetSlot }),
};
const { data, headers } = await this.postWithHeaders<
GetFrictionlessCaptchaResponse,
Expand Down
3 changes: 3 additions & 0 deletions packages/procaptcha-frictionless/src/customDetectBot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ const customDetectBot: BotDetectionFunction = async (
retryContext?: ProviderSelectRetryContext,
refreshOf?: string,
switchToImage?: boolean,
widgetSlot?: number,
): Promise<BotDetectionFunctionResult> => {
if (!config.account.address) {
throw new ProsopoError({ code: "SITE_KEY_MISSING", errorSite: "FRIC-2" });
Expand Down Expand Up @@ -325,6 +326,7 @@ const customDetectBot: BotDetectionFunction = async (
refreshOf,
switchToImage,
fallbackFingerprintProof,
widgetSlot,
),
10000,
);
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -545,6 +547,7 @@ export const mountProcaptchaFrictionless = (
{ attempt: state.attemptCount },
nextRefreshOf,
nextSwitchToImage,
widgetSlot,
);

const guard = evaluateFrictionlessResult(result);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<BotDetectionFunction>()
.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<BotDetectionFunction>()
.mockResolvedValue(detection(CaptchaType.pow));
widget = mountProcaptchaFrictionless(container, props(remounted));
await settle();
expect(remounted.mock.calls[0]?.[6]).toBe(0);

secondWidget.destroy();
second.remove();
});
});
55 changes: 55 additions & 0 deletions packages/procaptcha-frictionless/src/tests/widgetSlot.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
43 changes: 43 additions & 0 deletions packages/procaptcha-frictionless/src/widgetSlot.ts
Original file line number Diff line number Diff line change
@@ -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<string, Set<number>>();

export const claimWidgetSlot = (siteKey: string): number => {
const claimed = claimedSlots.get(siteKey) ?? new Set<number>();
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);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}` : ""}`,
),
}));

Expand Down Expand Up @@ -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<string, unknown> => ({
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",
Expand Down
17 changes: 17 additions & 0 deletions packages/provider/src/tests/unit/utils/hashUserIp.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(
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),
);
});
});
16 changes: 13 additions & 3 deletions packages/provider/src/utils/hashUserIp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
1 change: 1 addition & 0 deletions packages/types/src/api/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export enum ApiParams {
clientSessionId = "clientSessionId",
refreshOf = "refreshOf",
switchToImage = "switchToImage",
widgetSlot = "widgetSlot",
imageSwitchAvailable = "imageSwitchAvailable",
honeypot = "honeypot",
fingerprintProof = "fingerprintProof",
Expand Down
2 changes: 2 additions & 0 deletions packages/types/src/procaptcha-frictionless/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BotDetectionFunctionResult>;

/**
Expand Down
7 changes: 7 additions & 0 deletions packages/types/src/provider/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<
Expand Down
Loading