@prosopo/procaptcha-frictionless 2.15.0 → 2.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build$colon$cjs.log +11 -10
- package/.turbo/turbo-build$colon$tsc.log +24 -24
- package/.turbo/turbo-build.log +13 -12
- package/CHANGELOG.md +73 -0
- package/dist/AuthenticatedBadge.d.ts +12 -0
- package/dist/AuthenticatedBadge.d.ts.map +1 -0
- package/dist/AuthenticatedBadge.js +75 -0
- package/dist/AuthenticatedBadge.js.map +1 -0
- package/dist/ProcaptchaFrictionless.d.ts.map +1 -1
- package/dist/ProcaptchaFrictionless.js +35 -9
- package/dist/ProcaptchaFrictionless.js.map +1 -1
- package/dist/cjs/AuthenticatedBadge.cjs +75 -0
- package/dist/cjs/ProcaptchaFrictionless.cjs +35 -9
- package/dist/cjs/customDetectBot.cjs +3 -1
- package/dist/cjs/detectorLoader.cjs +42 -13
- package/dist/cjs/sessionInvalidatedRecovery.cjs +16 -9
- package/dist/customDetectBot.d.ts.map +1 -1
- package/dist/customDetectBot.js +3 -1
- package/dist/customDetectBot.js.map +1 -1
- package/dist/detectorLoader.d.ts +2 -1
- package/dist/detectorLoader.d.ts.map +1 -1
- package/dist/detectorLoader.js +42 -13
- package/dist/detectorLoader.js.map +1 -1
- package/dist/sessionInvalidatedRecovery.d.ts +3 -1
- package/dist/sessionInvalidatedRecovery.d.ts.map +1 -1
- package/dist/sessionInvalidatedRecovery.js +16 -9
- package/dist/sessionInvalidatedRecovery.js.map +1 -1
- package/dist/tests/detectorLoader.test.d.ts +2 -0
- package/dist/tests/detectorLoader.test.d.ts.map +1 -0
- package/dist/tests/detectorLoader.test.js +67 -0
- package/dist/tests/detectorLoader.test.js.map +1 -0
- package/dist/tests/escalationHandoff.integration.test.d.ts +5 -0
- package/dist/tests/escalationHandoff.integration.test.d.ts.map +1 -0
- package/dist/tests/escalationHandoff.integration.test.js +216 -0
- package/dist/tests/escalationHandoff.integration.test.js.map +1 -0
- package/dist/tests/sessionInvalidatedRecovery.test.js +41 -21
- package/dist/tests/sessionInvalidatedRecovery.test.js.map +1 -1
- package/dist/tests/sessionInvalidatedRemint.test.d.ts +5 -0
- package/dist/tests/sessionInvalidatedRemint.test.d.ts.map +1 -0
- package/dist/tests/sessionInvalidatedRemint.test.js +150 -0
- package/dist/tests/sessionInvalidatedRemint.test.js.map +1 -0
- package/package.json +8 -8
- package/src/AuthenticatedBadge.tsx +141 -0
- package/src/ProcaptchaFrictionless.tsx +85 -16
- package/src/customDetectBot.ts +2 -0
- package/src/detectorLoader.ts +52 -12
- package/src/sessionInvalidatedRecovery.ts +30 -10
- package/src/tests/detectorLoader.test.ts +108 -0
- package/src/tests/escalationHandoff.integration.test.tsx +354 -0
- package/src/tests/sessionInvalidatedRecovery.test.ts +49 -21
- package/src/tests/sessionInvalidatedRemint.test.tsx +245 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Copyright 2021-2026 Prosopo (UK) Ltd.
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
|
|
15
|
+
// Widget for the `authenticated` captcha outcome — no user-facing challenge,
|
|
16
|
+
// just a visible acknowledgement that Web Bot Auth verification succeeded.
|
|
17
|
+
// Mounts once, immediately encodes a ProcaptchaToken with the sessionId as
|
|
18
|
+
// its commitmentId (matching what /client/authenticated/verify decodes) and
|
|
19
|
+
// fires `onHuman`. The badge itself is purely presentational; the token
|
|
20
|
+
// submission happens in the mount effect.
|
|
21
|
+
//
|
|
22
|
+
// The token carries empty signature bags (`{provider: {}, user: {}}`) — the
|
|
23
|
+
// authenticated verify endpoint does not check the token's user or provider
|
|
24
|
+
// signatures because there was no captcha challenge to sign. Only the dApp
|
|
25
|
+
// server's signature on the timestamp is checked at verify time, and that
|
|
26
|
+
// is applied by the dApp on the way to /verify (not by the widget here).
|
|
27
|
+
|
|
28
|
+
import { getDefaultEvents } from "@prosopo/procaptcha-common";
|
|
29
|
+
import {
|
|
30
|
+
type Account,
|
|
31
|
+
ApiParams,
|
|
32
|
+
CaptchaType,
|
|
33
|
+
type ProcaptchaCallbacks,
|
|
34
|
+
type RandomProvider,
|
|
35
|
+
encodeProcaptchaOutput,
|
|
36
|
+
} from "@prosopo/types";
|
|
37
|
+
import { type FC, useEffect, useRef } from "react";
|
|
38
|
+
|
|
39
|
+
export type AuthenticatedBadgeProps = {
|
|
40
|
+
sessionId: string;
|
|
41
|
+
agent?: string;
|
|
42
|
+
dapp: string;
|
|
43
|
+
userAccount: Account;
|
|
44
|
+
provider: RandomProvider;
|
|
45
|
+
callbacks: ProcaptchaCallbacks;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const displayHost = (agent?: string): string => {
|
|
49
|
+
if (!agent) return "unknown";
|
|
50
|
+
try {
|
|
51
|
+
return new URL(agent).hostname;
|
|
52
|
+
} catch {
|
|
53
|
+
return agent;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const AuthenticatedBadge: FC<AuthenticatedBadgeProps> = ({
|
|
58
|
+
sessionId,
|
|
59
|
+
agent,
|
|
60
|
+
dapp,
|
|
61
|
+
userAccount,
|
|
62
|
+
provider,
|
|
63
|
+
callbacks,
|
|
64
|
+
}) => {
|
|
65
|
+
// One-shot: React 18 StrictMode double-invokes effects in development;
|
|
66
|
+
// this guard makes sure the token is emitted exactly once even under
|
|
67
|
+
// double-mount. Production doesn't double-invoke, so this is a
|
|
68
|
+
// belt-and-braces against local-dev confusion.
|
|
69
|
+
const emittedRef = useRef(false);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (emittedRef.current) return;
|
|
73
|
+
emittedRef.current = true;
|
|
74
|
+
const events = getDefaultEvents(callbacks);
|
|
75
|
+
const token = encodeProcaptchaOutput({
|
|
76
|
+
[ApiParams.providerUrl]: provider.provider.url,
|
|
77
|
+
[ApiParams.user]: userAccount.account.address,
|
|
78
|
+
[ApiParams.dapp]: dapp,
|
|
79
|
+
// Verify endpoint reads commitmentId as the sessionId — reusing
|
|
80
|
+
// the existing slot avoids a token codec bump for this one flow.
|
|
81
|
+
[ApiParams.commitmentId]: sessionId,
|
|
82
|
+
[ApiParams.timestamp]: Date.now().toString(),
|
|
83
|
+
[ApiParams.signature]: {
|
|
84
|
+
[ApiParams.provider]: {},
|
|
85
|
+
[ApiParams.user]: {},
|
|
86
|
+
},
|
|
87
|
+
[ApiParams.captchaType]: CaptchaType.authenticated,
|
|
88
|
+
});
|
|
89
|
+
events.onHuman(token);
|
|
90
|
+
}, [sessionId, dapp, userAccount, provider, callbacks]);
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div
|
|
94
|
+
style={{
|
|
95
|
+
display: "inline-flex",
|
|
96
|
+
alignItems: "center",
|
|
97
|
+
gap: 8,
|
|
98
|
+
padding: "8px 12px",
|
|
99
|
+
borderRadius: 6,
|
|
100
|
+
background: "#eef2ff",
|
|
101
|
+
border: "1px solid #c7d2fe",
|
|
102
|
+
color: "#3730a3",
|
|
103
|
+
fontFamily:
|
|
104
|
+
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
|
105
|
+
fontSize: 13,
|
|
106
|
+
lineHeight: 1.4,
|
|
107
|
+
}}
|
|
108
|
+
// biome-ignore lint/a11y/useSemanticElements: doesn't make sense
|
|
109
|
+
role="status"
|
|
110
|
+
aria-label="Verified agent"
|
|
111
|
+
>
|
|
112
|
+
<svg
|
|
113
|
+
width="16"
|
|
114
|
+
height="16"
|
|
115
|
+
viewBox="0 0 24 24"
|
|
116
|
+
fill="none"
|
|
117
|
+
stroke="currentColor"
|
|
118
|
+
strokeWidth="2.5"
|
|
119
|
+
strokeLinecap="round"
|
|
120
|
+
strokeLinejoin="round"
|
|
121
|
+
aria-hidden="true"
|
|
122
|
+
>
|
|
123
|
+
<title>Verified</title>
|
|
124
|
+
<path d="M20 6L9 17l-5-5" />
|
|
125
|
+
</svg>
|
|
126
|
+
<span>
|
|
127
|
+
{agent ? (
|
|
128
|
+
<>
|
|
129
|
+
<strong>Verified agent</strong>: {displayHost(agent)}
|
|
130
|
+
</>
|
|
131
|
+
) : (
|
|
132
|
+
// No Signature-Agent URL on the response — the operator's Allow
|
|
133
|
+
// rule matched on a non-Web-Bot-Auth condition (IP CIDR, JA4,
|
|
134
|
+
// UA substring, ASN, country). Fall back to generic copy so
|
|
135
|
+
// the operator isn't misled about which qualifier fired.
|
|
136
|
+
<strong>Trusted request</strong>
|
|
137
|
+
)}
|
|
138
|
+
</span>
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
};
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
import { darkTheme, lightTheme } from "@prosopo/widget-skeleton";
|
|
34
34
|
import type { KeyboardEvent, MouseEvent, TouchEvent } from "react";
|
|
35
35
|
import { useEffect, useRef, useState } from "react";
|
|
36
|
+
import { AuthenticatedBadge } from "./AuthenticatedBadge.js";
|
|
36
37
|
import customDetectBot from "./customDetectBot.js";
|
|
37
38
|
import { evaluateFrictionlessResult } from "./frictionlessResultGuard.js";
|
|
38
39
|
import {
|
|
@@ -42,6 +43,8 @@ import {
|
|
|
42
43
|
normaliseRetryCoords,
|
|
43
44
|
} from "./sessionInvalidatedRecovery.js";
|
|
44
45
|
|
|
46
|
+
const NO_SESSION_FOUND_KEY = "CAPTCHA.NO_SESSION_FOUND";
|
|
47
|
+
|
|
45
48
|
// Each session uses exactly one solver — chosen by the /frictionless response.
|
|
46
49
|
const ProcaptchaLoader = async () =>
|
|
47
50
|
(await import("@prosopo/procaptcha-react")).Procaptcha;
|
|
@@ -94,7 +97,7 @@ const defaultLoadingState = (
|
|
|
94
97
|
attemptCount: number,
|
|
95
98
|
): FrictionlessLoadingState => ({
|
|
96
99
|
loading: false,
|
|
97
|
-
attemptCount
|
|
100
|
+
attemptCount,
|
|
98
101
|
});
|
|
99
102
|
|
|
100
103
|
export const ProcaptchaFrictionless = ({
|
|
@@ -113,10 +116,22 @@ export const ProcaptchaFrictionless = ({
|
|
|
113
116
|
// to click the checkbox a second time and the checkbox click position is
|
|
114
117
|
// preserved in the eventual solution salt.
|
|
115
118
|
const pendingRetryCoordsRef = useRef<RetryCoords | null>(null);
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
119
|
+
// Bounded outer guard so a persistently broken session doesn't loop. Each
|
|
120
|
+
// re-mint costs a /frictionless round trip, so the count is capped — but
|
|
121
|
+
// it is a count rather than a one-shot, because a widget legitimately
|
|
122
|
+
// mints a new session every time the user presses reload. `onReload`
|
|
123
|
+
// clears it for the same reason. When the budget is spent we fall over
|
|
124
|
+
// visibly (see `onSessionInvalidated`) instead of leaving the user on a
|
|
125
|
+
// dead "No session found" checkbox.
|
|
126
|
+
const sessionInvalidatedAttemptsRef = useRef(0);
|
|
127
|
+
// Escalation sessions we have already mounted a widget for. The provider
|
|
128
|
+
// mints exactly one escalation session per PoW solution and consumes it on
|
|
129
|
+
// the first challenge fetch, so a repeat handoff for the same id can only
|
|
130
|
+
// produce a widget that 400s with NO_SESSION_FOUND. The PoW manager fires
|
|
131
|
+
// `onEscalate` from inside its `providerRetry`-wrapped `submit()`, so a
|
|
132
|
+
// throw anywhere after the handoff re-runs submit and escalates a second
|
|
133
|
+
// time on the same envelope.
|
|
134
|
+
const escalatedSessionIdsRef = useRef(new Set<string>());
|
|
120
135
|
// Bumped on every mount so the replacement widget gets a fresh React
|
|
121
136
|
// `key`. Without it a re-render for the same captcha type reconciles onto
|
|
122
137
|
// the existing element, and the inner widget keeps the manager it built on
|
|
@@ -161,9 +176,14 @@ export const ProcaptchaFrictionless = ({
|
|
|
161
176
|
),
|
|
162
177
|
);
|
|
163
178
|
|
|
179
|
+
// `??`, not `||`: every caller that wants the counter back at zero passes
|
|
180
|
+
// literal 0, and `0 || current` silently kept the old count. `start()` then
|
|
181
|
+
// tripped its own `attemptCount >= 5` fall-over after five *cumulative*
|
|
182
|
+
// runs in a widget lifetime — five reload presses were enough to strand the
|
|
183
|
+
// user on the error placeholder even though every one of them succeeded.
|
|
164
184
|
const resetState = (attemptCount?: number) => {
|
|
165
185
|
stateRef.current = defaultLoadingState(
|
|
166
|
-
attemptCount
|
|
186
|
+
attemptCount ?? stateRef.current.attemptCount,
|
|
167
187
|
);
|
|
168
188
|
};
|
|
169
189
|
|
|
@@ -212,6 +232,12 @@ export const ProcaptchaFrictionless = ({
|
|
|
212
232
|
newSessionId: string,
|
|
213
233
|
coords?: RetryCoords,
|
|
214
234
|
) => {
|
|
235
|
+
// Idempotent per escalation session — see `escalatedSessionIdsRef`.
|
|
236
|
+
// Without this a re-run of the PoW widget's `submit()` mounts a
|
|
237
|
+
// second widget against the session the first one already spent,
|
|
238
|
+
// which the provider answers with 400 CAPTCHA.NO_SESSION_FOUND.
|
|
239
|
+
if (escalatedSessionIdsRef.current.has(newSessionId)) return;
|
|
240
|
+
escalatedSessionIdsRef.current.add(newSessionId);
|
|
215
241
|
void renderForCaptchaType(
|
|
216
242
|
next,
|
|
217
243
|
{
|
|
@@ -225,23 +251,38 @@ export const ProcaptchaFrictionless = ({
|
|
|
225
251
|
|
|
226
252
|
// The provider returned NO_SESSION_FOUND on the inner widget's
|
|
227
253
|
// challenge fetch — the sessionId minted upstream is no longer usable
|
|
228
|
-
// (
|
|
229
|
-
//
|
|
254
|
+
// (a duplicate /captcha/{type} POST consumed it first, or the widget
|
|
255
|
+
// re-sent an id it had already spent). Re-run the frictionless flow to
|
|
230
256
|
// mint a fresh session, then re-mount the inner widget with the
|
|
231
257
|
// preserved checkbox click coords so the user is not asked to click a
|
|
232
|
-
// second time.
|
|
233
|
-
//
|
|
234
|
-
//
|
|
258
|
+
// second time.
|
|
259
|
+
//
|
|
260
|
+
// The inner widget cannot recover on its own here: it always takes
|
|
261
|
+
// this branch and returns before its own `frictionlessState.restart()`
|
|
262
|
+
// fallback, and its guard ref is fresh on every re-mount. So whatever
|
|
263
|
+
// this handler declines to do, nothing else does — hence the terminal
|
|
264
|
+
// `fallOverWithStyle` rather than a silent return once the retry
|
|
265
|
+
// budget is spent.
|
|
235
266
|
const onSessionInvalidated = (x?: number, y?: number) => {
|
|
236
267
|
const { shouldRestart } = handleSessionInvalidated(
|
|
237
268
|
x,
|
|
238
269
|
y,
|
|
239
|
-
|
|
270
|
+
sessionInvalidatedAttemptsRef,
|
|
240
271
|
pendingRetryCoordsRef,
|
|
241
272
|
);
|
|
242
|
-
if (
|
|
243
|
-
|
|
244
|
-
|
|
273
|
+
if (shouldRestart) {
|
|
274
|
+
resetState(0);
|
|
275
|
+
void start();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
// Budget spent. Surface the error on the checkbox and let
|
|
279
|
+
// `fallOverWithStyle`'s NO_SESSION_FOUND branch schedule the
|
|
280
|
+
// 10-second full restart, so the user always has a way back.
|
|
281
|
+
const message = i18n.isInitialized
|
|
282
|
+
? i18n.t(NO_SESSION_FOUND_KEY)
|
|
283
|
+
: "No session found";
|
|
284
|
+
events.onError(new Error(message));
|
|
285
|
+
fallOverWithStyle(message, NO_SESSION_FOUND_KEY);
|
|
245
286
|
};
|
|
246
287
|
|
|
247
288
|
// The user pressed reload on the challenge. The provider consumed this
|
|
@@ -253,6 +294,9 @@ export const ProcaptchaFrictionless = ({
|
|
|
253
294
|
const onReload = (x?: number, y?: number) => {
|
|
254
295
|
pendingRetryCoordsRef.current = normaliseRetryCoords(x, y);
|
|
255
296
|
nextMountAutoStartRef.current = true;
|
|
297
|
+
// A reload mints a genuinely new session, so the invalidation
|
|
298
|
+
// budget for the *previous* one shouldn't count against it.
|
|
299
|
+
sessionInvalidatedAttemptsRef.current = 0;
|
|
256
300
|
resetState(0);
|
|
257
301
|
void start();
|
|
258
302
|
};
|
|
@@ -275,7 +319,31 @@ export const ProcaptchaFrictionless = ({
|
|
|
275
319
|
mountCountRef.current += 1;
|
|
276
320
|
const mountKey = mountCountRef.current;
|
|
277
321
|
|
|
278
|
-
if (captchaType === CaptchaType.
|
|
322
|
+
if (captchaType === CaptchaType.authenticated) {
|
|
323
|
+
// Web Bot Auth pre-verified pass-through. No challenge, no
|
|
324
|
+
// interaction — the badge component encodes and fires the token
|
|
325
|
+
// on mount. Skip the loader chain the other branches use because
|
|
326
|
+
// there is no captcha module to lazy-import here.
|
|
327
|
+
if (!frictionlessState.sessionId) {
|
|
328
|
+
events.onError(
|
|
329
|
+
new Error(
|
|
330
|
+
"authenticated captcha response missing sessionId — provider is misbehaving",
|
|
331
|
+
),
|
|
332
|
+
);
|
|
333
|
+
fallOverWithStyle();
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
setComponentToRender(
|
|
337
|
+
<AuthenticatedBadge
|
|
338
|
+
sessionId={frictionlessState.sessionId}
|
|
339
|
+
agent={frictionlessState.agent}
|
|
340
|
+
dapp={config.account.address ?? ""}
|
|
341
|
+
userAccount={frictionlessState.userAccount}
|
|
342
|
+
provider={frictionlessState.provider}
|
|
343
|
+
callbacks={callbacks}
|
|
344
|
+
/>,
|
|
345
|
+
);
|
|
346
|
+
} else if (captchaType === CaptchaType.image) {
|
|
279
347
|
const Procaptcha = await ProcaptchaLoader();
|
|
280
348
|
setComponentToRender(
|
|
281
349
|
<Procaptcha
|
|
@@ -374,6 +442,7 @@ export const ProcaptchaFrictionless = ({
|
|
|
374
442
|
encryptBehavioralData: result.encryptBehavioralData,
|
|
375
443
|
getSimdReadings: result.getSimdReadings,
|
|
376
444
|
hp: result.hp,
|
|
445
|
+
agent: result.agent,
|
|
377
446
|
};
|
|
378
447
|
|
|
379
448
|
await renderForCaptchaType(result.captchaType, frictionlessState);
|
package/src/customDetectBot.ts
CHANGED
|
@@ -297,6 +297,7 @@ const customDetectBot: BotDetectionFunction = async (
|
|
|
297
297
|
userAccount: userAccount,
|
|
298
298
|
error: captcha.error,
|
|
299
299
|
hp: captcha.hp,
|
|
300
|
+
agent: captcha.agent,
|
|
300
301
|
};
|
|
301
302
|
}
|
|
302
303
|
|
|
@@ -357,6 +358,7 @@ const customDetectBot: BotDetectionFunction = async (
|
|
|
357
358
|
userAccount: userAccount,
|
|
358
359
|
error: captcha.error,
|
|
359
360
|
hp: captcha.hp,
|
|
361
|
+
agent: captcha.agent,
|
|
360
362
|
// Map specific trackers to generic behavioral collectors
|
|
361
363
|
behaviorCollector1: detectionResult.mouseTracker,
|
|
362
364
|
behaviorCollector2: detectionResult.touchTracker,
|
package/src/detectorLoader.ts
CHANGED
|
@@ -59,22 +59,62 @@ export type DetectorType = (
|
|
|
59
59
|
userAccount: Account;
|
|
60
60
|
}>;
|
|
61
61
|
|
|
62
|
+
interface LoaderAttempt {
|
|
63
|
+
readonly url: string;
|
|
64
|
+
readonly release: () => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const blobAttempt = (script: string): LoaderAttempt => {
|
|
68
|
+
const url = URL.createObjectURL(
|
|
69
|
+
new Blob([script], { type: "text/javascript" }),
|
|
70
|
+
);
|
|
71
|
+
return { url, release: (): void => URL.revokeObjectURL(url) };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const dataAttempt = (script: string): LoaderAttempt => ({
|
|
75
|
+
url: `data:text/javascript;charset=utf-8,${encodeURIComponent(script)}`,
|
|
76
|
+
release: (): void => undefined,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/** Injection point for tests; real callers never pass this. */
|
|
80
|
+
export type ModuleImporter = (url: string) => Promise<unknown>;
|
|
81
|
+
|
|
82
|
+
const defaultImporter: ModuleImporter = (url) =>
|
|
83
|
+
import(/* @vite-ignore */ /* webpackIgnore: true */ url);
|
|
84
|
+
|
|
62
85
|
/**
|
|
63
|
-
* Loads a detector from a provider-served obfuscated ESM string (the
|
|
64
|
-
* pool bundle).
|
|
65
|
-
*
|
|
86
|
+
* Loads a detector from a provider-served obfuscated ESM string (the
|
|
87
|
+
* per-session pool bundle). The module is self-contained, so a dynamic
|
|
88
|
+
* `import()` yields its default export.
|
|
89
|
+
*
|
|
90
|
+
* A blob URL is tried first, then a `data:` URL. The two are gated by
|
|
91
|
+
* different CSP directives and sites commonly allow one without the other — a
|
|
92
|
+
* `script-src` of `*` matches neither, and a policy listing `data:` for
|
|
93
|
+
* scripts and `blob:` only for workers blocks the blob path outright. Without
|
|
94
|
+
* the fallback every session on such a site loses the detector, sends an empty
|
|
95
|
+
* token, and can never pass frictionlessly.
|
|
96
|
+
*
|
|
97
|
+
* A blocked attempt logs a CSP violation before the import rejects. That noise
|
|
98
|
+
* is the cost of recovering the session.
|
|
66
99
|
*/
|
|
67
100
|
export const DetectorLoaderFromScript = async (
|
|
68
101
|
script: string,
|
|
102
|
+
importModule: ModuleImporter = defaultImporter,
|
|
69
103
|
): Promise<DetectorType> => {
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
104
|
+
let lastError: unknown = new Error("no loader attempt ran");
|
|
105
|
+
for (const build of [blobAttempt, dataAttempt]) {
|
|
106
|
+
let attempt: LoaderAttempt | undefined;
|
|
107
|
+
try {
|
|
108
|
+
attempt = build(script);
|
|
109
|
+
const mod = (await importModule(attempt.url)) as {
|
|
110
|
+
default: DetectorType;
|
|
111
|
+
};
|
|
112
|
+
return mod.default;
|
|
113
|
+
} catch (err) {
|
|
114
|
+
lastError = err;
|
|
115
|
+
} finally {
|
|
116
|
+
attempt?.release();
|
|
117
|
+
}
|
|
79
118
|
}
|
|
119
|
+
throw lastError;
|
|
80
120
|
};
|
|
@@ -49,23 +49,43 @@ export const normaliseRetryCoords = (
|
|
|
49
49
|
};
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
52
|
+
* How many times a single outer widget will re-mint a session in response to
|
|
53
|
+
* `CAPTCHA.NO_SESSION_FOUND` on the inner widget before giving up and handing
|
|
54
|
+
* over to the terminal fallback.
|
|
55
55
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
56
|
+
* This used to be one-shot per outer widget lifetime, which stranded users:
|
|
57
|
+
* the inner widget always takes the `onSessionInvalidated` branch (its own
|
|
58
|
+
* guard ref is fresh on every re-mount, because the outer widget bumps its
|
|
59
|
+
* mount key) and returns without touching its own `restart()` fallback. Once
|
|
60
|
+
* the outer one-shot was spent nothing at all handled the second failure, so
|
|
61
|
+
* the checkbox sat on "No session found" forever. A widget legitimately mints
|
|
62
|
+
* many sessions over its lifetime — every reload press is a new one — so a
|
|
63
|
+
* single lifetime-wide attempt is far too coarse a bound.
|
|
64
|
+
*/
|
|
65
|
+
export const MAX_SESSION_INVALIDATED_RETRIES = 3;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Semantics of the outer recovery handler. Returns whether the caller should
|
|
69
|
+
* proceed to re-run the frictionless flow (`start()`), and mutates the passed
|
|
70
|
+
* refs to record the attempt + pending coords.
|
|
71
|
+
*
|
|
72
|
+
* Bounded rather than one-shot: a persistently broken session still stops
|
|
73
|
+
* looping, but the caller is told (`exhausted`) so it can fall back visibly
|
|
74
|
+
* instead of silently doing nothing.
|
|
58
75
|
*/
|
|
59
76
|
export const handleSessionInvalidated = (
|
|
60
77
|
x: number | undefined,
|
|
61
78
|
y: number | undefined,
|
|
62
|
-
|
|
79
|
+
attemptsRef: MutableRef<number>,
|
|
63
80
|
pendingCoordsRef: MutableRef<RetryCoords | null>,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
81
|
+
maxAttempts: number = MAX_SESSION_INVALIDATED_RETRIES,
|
|
82
|
+
): { shouldRestart: boolean; exhausted: boolean } => {
|
|
83
|
+
if (attemptsRef.current >= maxAttempts) {
|
|
84
|
+
return { shouldRestart: false, exhausted: true };
|
|
85
|
+
}
|
|
86
|
+
attemptsRef.current += 1;
|
|
67
87
|
pendingCoordsRef.current = normaliseRetryCoords(x, y);
|
|
68
|
-
return { shouldRestart: true };
|
|
88
|
+
return { shouldRestart: true, exhausted: false };
|
|
69
89
|
};
|
|
70
90
|
|
|
71
91
|
/**
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Copyright 2021-2026 Prosopo (UK) Ltd.
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
|
|
15
|
+
// @vitest-environment jsdom
|
|
16
|
+
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
18
|
+
import {
|
|
19
|
+
DetectorLoaderFromScript,
|
|
20
|
+
type DetectorType,
|
|
21
|
+
type ModuleImporter,
|
|
22
|
+
} from "../detectorLoader.js";
|
|
23
|
+
|
|
24
|
+
const SCRIPT = "export default () => ({ token: 'x' });";
|
|
25
|
+
|
|
26
|
+
const detector = (() => undefined) as unknown as DetectorType;
|
|
27
|
+
|
|
28
|
+
describe("DetectorLoaderFromScript", () => {
|
|
29
|
+
const created: string[] = [];
|
|
30
|
+
const revoked: string[] = [];
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
created.length = 0;
|
|
34
|
+
revoked.length = 0;
|
|
35
|
+
let counter = 0;
|
|
36
|
+
URL.createObjectURL = vi.fn((): string => {
|
|
37
|
+
const url = `blob:https://example.test/${counter++}`;
|
|
38
|
+
created.push(url);
|
|
39
|
+
return url;
|
|
40
|
+
});
|
|
41
|
+
URL.revokeObjectURL = vi.fn((url: string): void => {
|
|
42
|
+
revoked.push(url);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
vi.restoreAllMocks();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("uses a blob URL when the import succeeds", async () => {
|
|
51
|
+
const seen: string[] = [];
|
|
52
|
+
const importer: ModuleImporter = async (url) => {
|
|
53
|
+
seen.push(url);
|
|
54
|
+
return { default: detector };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
await expect(DetectorLoaderFromScript(SCRIPT, importer)).resolves.toBe(
|
|
58
|
+
detector,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(seen).toHaveLength(1);
|
|
62
|
+
expect(seen[0]).toMatch(/^blob:/);
|
|
63
|
+
expect(revoked).toEqual(created);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("falls back to a data URL when the blob import is blocked by CSP", async () => {
|
|
67
|
+
const seen: string[] = [];
|
|
68
|
+
const importer: ModuleImporter = async (url) => {
|
|
69
|
+
seen.push(url);
|
|
70
|
+
if (url.startsWith("blob:")) {
|
|
71
|
+
// What Chrome throws when script-src omits blob:
|
|
72
|
+
throw new TypeError("Failed to fetch dynamically imported module");
|
|
73
|
+
}
|
|
74
|
+
return { default: detector };
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
await expect(DetectorLoaderFromScript(SCRIPT, importer)).resolves.toBe(
|
|
78
|
+
detector,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(seen).toHaveLength(2);
|
|
82
|
+
expect(seen[0]).toMatch(/^blob:/);
|
|
83
|
+
expect(seen[1]).toMatch(/^data:text\/javascript;charset=utf-8,/);
|
|
84
|
+
expect(decodeURIComponent(seen[1]?.split(",")[1] ?? "")).toBe(SCRIPT);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("revokes the blob URL even when its import fails", async () => {
|
|
88
|
+
const importer: ModuleImporter = async (url) => {
|
|
89
|
+
if (url.startsWith("blob:")) throw new Error("blocked");
|
|
90
|
+
return { default: detector };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
await DetectorLoaderFromScript(SCRIPT, importer);
|
|
94
|
+
|
|
95
|
+
expect(revoked).toEqual(created);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("rethrows the last error when both schemes are blocked", async () => {
|
|
99
|
+
const importer: ModuleImporter = async (url) => {
|
|
100
|
+
throw new Error(`blocked: ${url.slice(0, 5)}`);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
await expect(DetectorLoaderFromScript(SCRIPT, importer)).rejects.toThrow(
|
|
104
|
+
"blocked: data:",
|
|
105
|
+
);
|
|
106
|
+
expect(revoked).toEqual(created);
|
|
107
|
+
});
|
|
108
|
+
});
|