@prosopo/procaptcha-frictionless 2.12.4 → 2.12.5
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 +8 -7
- package/.turbo/turbo-build$colon$tsc.log +25 -25
- package/.turbo/turbo-build.log +9 -8
- package/CHANGELOG.md +20 -0
- package/dist/ProcaptchaFrictionless.d.ts.map +1 -1
- package/dist/ProcaptchaFrictionless.js +28 -3
- package/dist/ProcaptchaFrictionless.js.map +1 -1
- package/dist/cjs/ProcaptchaFrictionless.cjs +28 -3
- package/dist/cjs/sessionInvalidatedRecovery.cjs +20 -0
- package/dist/sessionInvalidatedRecovery.d.ts +15 -0
- package/dist/sessionInvalidatedRecovery.d.ts.map +1 -0
- package/dist/sessionInvalidatedRecovery.js +20 -0
- package/dist/sessionInvalidatedRecovery.js.map +1 -0
- package/dist/tests/sessionInvalidatedRecovery.test.d.ts +2 -0
- package/dist/tests/sessionInvalidatedRecovery.test.d.ts.map +1 -0
- package/dist/tests/sessionInvalidatedRecovery.test.js +74 -0
- package/dist/tests/sessionInvalidatedRecovery.test.js.map +1 -0
- package/package.json +8 -8
- package/src/ProcaptchaFrictionless.tsx +53 -2
- package/src/sessionInvalidatedRecovery.ts +78 -0
- package/src/tests/sessionInvalidatedRecovery.test.ts +129 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -30,6 +30,11 @@ import {
|
|
|
30
30
|
import { darkTheme, lightTheme } from "@prosopo/widget-skeleton";
|
|
31
31
|
import { useEffect, useRef, useState } from "react";
|
|
32
32
|
import customDetectBot from "./customDetectBot.js";
|
|
33
|
+
import {
|
|
34
|
+
type RetryCoords,
|
|
35
|
+
consumeRetryMountProps,
|
|
36
|
+
handleSessionInvalidated,
|
|
37
|
+
} from "./sessionInvalidatedRecovery.js";
|
|
33
38
|
|
|
34
39
|
// Each session uses exactly one solver — chosen by the /frictionless response.
|
|
35
40
|
const ProcaptchaLoader = async () =>
|
|
@@ -89,6 +94,16 @@ export const ProcaptchaFrictionless = ({
|
|
|
89
94
|
}: ProcaptchaFrictionlessProps) => {
|
|
90
95
|
const stateRef = useRef(defaultLoadingState(0));
|
|
91
96
|
const events = getDefaultEvents(callbacks);
|
|
97
|
+
// Coords carried over from an `onSessionInvalidated` event on the inner
|
|
98
|
+
// widget. Consumed once by the next `renderForCaptchaType` — the resumed
|
|
99
|
+
// widget mounts with `autoStart` + `startCoords` so the user doesn't have
|
|
100
|
+
// to click the checkbox a second time and the checkbox click position is
|
|
101
|
+
// preserved in the eventual solution salt.
|
|
102
|
+
const pendingRetryCoordsRef = useRef<RetryCoords | null>(null);
|
|
103
|
+
// One-shot outer guard so a persistently broken session doesn't loop.
|
|
104
|
+
// After we've retried once, a second NO_SESSION_FOUND falls back to the
|
|
105
|
+
// inner widget's own `frictionlessState.restart()` path.
|
|
106
|
+
const sessionInvalidatedFiredRef = useRef(false);
|
|
92
107
|
|
|
93
108
|
useEffect(() => {
|
|
94
109
|
if (config.language) {
|
|
@@ -175,6 +190,35 @@ export const ProcaptchaFrictionless = ({
|
|
|
175
190
|
);
|
|
176
191
|
};
|
|
177
192
|
|
|
193
|
+
// The provider returned NO_SESSION_FOUND on the inner widget's
|
|
194
|
+
// challenge fetch — the sessionId minted upstream is no longer usable
|
|
195
|
+
// (usually because a duplicate /captcha/{type} POST from a WebView
|
|
196
|
+
// mount storm consumed it first). Re-run the frictionless flow to
|
|
197
|
+
// mint a fresh session, then re-mount the inner widget with the
|
|
198
|
+
// preserved checkbox click coords so the user is not asked to click a
|
|
199
|
+
// second time. One-shot per outer widget lifetime — if the retry
|
|
200
|
+
// also fails, fall through to the inner widget's existing
|
|
201
|
+
// `frictionlessState.restart()` path.
|
|
202
|
+
const onSessionInvalidated = (x?: number, y?: number) => {
|
|
203
|
+
const { shouldRestart } = handleSessionInvalidated(
|
|
204
|
+
x,
|
|
205
|
+
y,
|
|
206
|
+
sessionInvalidatedFiredRef,
|
|
207
|
+
pendingRetryCoordsRef,
|
|
208
|
+
);
|
|
209
|
+
if (!shouldRestart) return;
|
|
210
|
+
resetState(0);
|
|
211
|
+
void start();
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// Consume any pending retry coords now — the resumed widget owns them
|
|
215
|
+
// for exactly one auto-fired `manager.start(x, y)`. Cleared so a
|
|
216
|
+
// subsequent escalation/re-render doesn't accidentally re-inject.
|
|
217
|
+
const { autoStart: resumedAutoStart, startCoords } = consumeRetryMountProps(
|
|
218
|
+
pendingRetryCoordsRef,
|
|
219
|
+
autoStart,
|
|
220
|
+
);
|
|
221
|
+
|
|
178
222
|
if (captchaType === CaptchaType.image) {
|
|
179
223
|
const Procaptcha = await ProcaptchaLoader();
|
|
180
224
|
setComponentToRender(
|
|
@@ -183,7 +227,9 @@ export const ProcaptchaFrictionless = ({
|
|
|
183
227
|
callbacks={callbacks}
|
|
184
228
|
frictionlessState={frictionlessState}
|
|
185
229
|
i18n={i18n}
|
|
186
|
-
autoStart={
|
|
230
|
+
autoStart={resumedAutoStart}
|
|
231
|
+
startCoords={startCoords}
|
|
232
|
+
onSessionInvalidated={onSessionInvalidated}
|
|
187
233
|
/>,
|
|
188
234
|
);
|
|
189
235
|
} else if (captchaType === CaptchaType.puzzle) {
|
|
@@ -194,7 +240,9 @@ export const ProcaptchaFrictionless = ({
|
|
|
194
240
|
callbacks={callbacks}
|
|
195
241
|
frictionlessState={frictionlessState}
|
|
196
242
|
i18n={i18n}
|
|
197
|
-
autoStart={
|
|
243
|
+
autoStart={resumedAutoStart}
|
|
244
|
+
startCoords={startCoords}
|
|
245
|
+
onSessionInvalidated={onSessionInvalidated}
|
|
198
246
|
/>,
|
|
199
247
|
);
|
|
200
248
|
} else {
|
|
@@ -206,6 +254,9 @@ export const ProcaptchaFrictionless = ({
|
|
|
206
254
|
frictionlessState={frictionlessState}
|
|
207
255
|
i18n={i18n}
|
|
208
256
|
onEscalate={onEscalate}
|
|
257
|
+
autoStart={resumedAutoStart}
|
|
258
|
+
startCoords={startCoords}
|
|
259
|
+
onSessionInvalidated={onSessionInvalidated}
|
|
209
260
|
/>,
|
|
210
261
|
);
|
|
211
262
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
// Shared, unit-testable pieces of the ProcaptchaFrictionless recovery path
|
|
16
|
+
// for CAPTCHA.NO_SESSION_FOUND on the inner widget. The React component
|
|
17
|
+
// mutates refs directly; these helpers isolate the logic that decides how a
|
|
18
|
+
// re-mount should be parameterised so it can be exercised without a renderer.
|
|
19
|
+
|
|
20
|
+
export type RetryCoords = { x: number; y: number };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Ref-like container used by ProcaptchaFrictionless. Extracted so tests
|
|
24
|
+
* can pass a plain `{current}` object.
|
|
25
|
+
*/
|
|
26
|
+
export type MutableRef<T> = { current: T };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Semantics of the outer recovery handler. Returns whether the caller
|
|
30
|
+
* should proceed to re-run the frictionless flow (`start()`), and mutates
|
|
31
|
+
* the passed refs to record the one-shot fire + pending coords.
|
|
32
|
+
*
|
|
33
|
+
* - Second calls are ignored (one-shot per outer widget lifetime) so a
|
|
34
|
+
* persistently broken session doesn't loop.
|
|
35
|
+
* - Coords are recorded only for a real trusted checkbox click. A partial
|
|
36
|
+
* pair (only x or only y numeric) is treated as "no coords" so we
|
|
37
|
+
* never accidentally embed `NaN` into the solution salt.
|
|
38
|
+
* - `(0, 0)` is treated as "no coords" too — that's what the widgets
|
|
39
|
+
* emit for an `autoStart` mount (post-PoW escalation) or an untrusted
|
|
40
|
+
* pointer event on the checkbox, neither of which is a real click. The
|
|
41
|
+
* resumed widget re-uses the same default and the outcome on the wire
|
|
42
|
+
* is identical; we discard the pair here so future readers can tell
|
|
43
|
+
* the two apart.
|
|
44
|
+
*/
|
|
45
|
+
export const handleSessionInvalidated = (
|
|
46
|
+
x: number | undefined,
|
|
47
|
+
y: number | undefined,
|
|
48
|
+
firedRef: MutableRef<boolean>,
|
|
49
|
+
pendingCoordsRef: MutableRef<RetryCoords | null>,
|
|
50
|
+
): { shouldRestart: boolean } => {
|
|
51
|
+
if (firedRef.current) return { shouldRestart: false };
|
|
52
|
+
firedRef.current = true;
|
|
53
|
+
const bothNumeric = typeof x === "number" && typeof y === "number";
|
|
54
|
+
const isRealClick = bothNumeric && (x !== 0 || y !== 0);
|
|
55
|
+
pendingCoordsRef.current = isRealClick ? { x, y } : null;
|
|
56
|
+
return { shouldRestart: true };
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Compute the props a resumed inner widget mounts with. Consumes the
|
|
61
|
+
* pending coords ref (sets it back to `null`) so the next render doesn't
|
|
62
|
+
* accidentally re-inject stale coords into a fresh escalation.
|
|
63
|
+
*
|
|
64
|
+
* `escalationAutoStart` reflects the caller's own `autoStart` argument to
|
|
65
|
+
* `renderForCaptchaType` — post-PoW escalations keep the historic
|
|
66
|
+
* autoStart=true behaviour when no retry coords are pending.
|
|
67
|
+
*/
|
|
68
|
+
export const consumeRetryMountProps = (
|
|
69
|
+
pendingCoordsRef: MutableRef<RetryCoords | null>,
|
|
70
|
+
escalationAutoStart: boolean,
|
|
71
|
+
): { autoStart: boolean; startCoords: RetryCoords | undefined } => {
|
|
72
|
+
const startCoords = pendingCoordsRef.current ?? undefined;
|
|
73
|
+
pendingCoordsRef.current = null;
|
|
74
|
+
return {
|
|
75
|
+
autoStart: escalationAutoStart || Boolean(startCoords),
|
|
76
|
+
startCoords,
|
|
77
|
+
};
|
|
78
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
import { describe, expect, it } from "vitest";
|
|
16
|
+
import {
|
|
17
|
+
type MutableRef,
|
|
18
|
+
type RetryCoords,
|
|
19
|
+
consumeRetryMountProps,
|
|
20
|
+
handleSessionInvalidated,
|
|
21
|
+
} from "../sessionInvalidatedRecovery.js";
|
|
22
|
+
|
|
23
|
+
const ref = <T>(initial: T): MutableRef<T> => ({ current: initial });
|
|
24
|
+
|
|
25
|
+
describe("handleSessionInvalidated", () => {
|
|
26
|
+
it("records both coords and signals a restart on the first fire", () => {
|
|
27
|
+
const firedRef = ref(false);
|
|
28
|
+
const coordsRef = ref<RetryCoords | null>(null);
|
|
29
|
+
|
|
30
|
+
const result = handleSessionInvalidated(120, 340, firedRef, coordsRef);
|
|
31
|
+
|
|
32
|
+
expect(result).toEqual({ shouldRestart: true });
|
|
33
|
+
expect(firedRef.current).toBe(true);
|
|
34
|
+
expect(coordsRef.current).toEqual({ x: 120, y: 340 });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("treats (0, 0) as 'no coords' — that's the autoStart / untrusted-event default, not a real click", () => {
|
|
38
|
+
const firedRef = ref(false);
|
|
39
|
+
const coordsRef = ref<RetryCoords | null>(null);
|
|
40
|
+
|
|
41
|
+
const result = handleSessionInvalidated(0, 0, firedRef, coordsRef);
|
|
42
|
+
|
|
43
|
+
expect(result).toEqual({ shouldRestart: true });
|
|
44
|
+
expect(coordsRef.current).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("carries a real click even if only one axis is at the origin", () => {
|
|
48
|
+
const firedRef = ref(false);
|
|
49
|
+
const coordsRef = ref<RetryCoords | null>(null);
|
|
50
|
+
|
|
51
|
+
handleSessionInvalidated(0, 340, firedRef, coordsRef);
|
|
52
|
+
|
|
53
|
+
expect(coordsRef.current).toEqual({ x: 0, y: 340 });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("stores no coords when x or y is undefined (autoStart / non-trusted event)", () => {
|
|
57
|
+
const firedRef = ref(false);
|
|
58
|
+
const coordsRef = ref<RetryCoords | null>(null);
|
|
59
|
+
|
|
60
|
+
const result = handleSessionInvalidated(
|
|
61
|
+
undefined,
|
|
62
|
+
undefined,
|
|
63
|
+
firedRef,
|
|
64
|
+
coordsRef,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(result).toEqual({ shouldRestart: true });
|
|
68
|
+
expect(firedRef.current).toBe(true);
|
|
69
|
+
expect(coordsRef.current).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("stores no coords when only one axis is present — never emit NaN into the salt", () => {
|
|
73
|
+
const firedRef = ref(false);
|
|
74
|
+
const coordsRef = ref<RetryCoords | null>(null);
|
|
75
|
+
|
|
76
|
+
handleSessionInvalidated(120, undefined, firedRef, coordsRef);
|
|
77
|
+
|
|
78
|
+
expect(coordsRef.current).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("is one-shot per outer widget lifetime — a second call is a no-op", () => {
|
|
82
|
+
const firedRef = ref(false);
|
|
83
|
+
const coordsRef = ref<RetryCoords | null>(null);
|
|
84
|
+
|
|
85
|
+
handleSessionInvalidated(100, 200, firedRef, coordsRef);
|
|
86
|
+
const second = handleSessionInvalidated(500, 600, firedRef, coordsRef);
|
|
87
|
+
|
|
88
|
+
expect(second).toEqual({ shouldRestart: false });
|
|
89
|
+
// The second call must not overwrite the first attempt's coords.
|
|
90
|
+
expect(coordsRef.current).toEqual({ x: 100, y: 200 });
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("consumeRetryMountProps", () => {
|
|
95
|
+
it("returns pending retry coords and forces autoStart", () => {
|
|
96
|
+
const coordsRef = ref<RetryCoords | null>({ x: 42, y: 99 });
|
|
97
|
+
|
|
98
|
+
const mount = consumeRetryMountProps(coordsRef, false);
|
|
99
|
+
|
|
100
|
+
expect(mount).toEqual({
|
|
101
|
+
autoStart: true,
|
|
102
|
+
startCoords: { x: 42, y: 99 },
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("clears the coords ref so a subsequent render doesn't re-inject stale values", () => {
|
|
107
|
+
const coordsRef = ref<RetryCoords | null>({ x: 42, y: 99 });
|
|
108
|
+
|
|
109
|
+
consumeRetryMountProps(coordsRef, false);
|
|
110
|
+
|
|
111
|
+
expect(coordsRef.current).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("propagates escalationAutoStart when no retry is pending (post-PoW escalation path)", () => {
|
|
115
|
+
const coordsRef = ref<RetryCoords | null>(null);
|
|
116
|
+
|
|
117
|
+
const mount = consumeRetryMountProps(coordsRef, true);
|
|
118
|
+
|
|
119
|
+
expect(mount).toEqual({ autoStart: true, startCoords: undefined });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("returns autoStart=false, startCoords=undefined for the initial checkbox mount", () => {
|
|
123
|
+
const coordsRef = ref<RetryCoords | null>(null);
|
|
124
|
+
|
|
125
|
+
const mount = consumeRetryMountProps(coordsRef, false);
|
|
126
|
+
|
|
127
|
+
expect(mount).toEqual({ autoStart: false, startCoords: undefined });
|
|
128
|
+
});
|
|
129
|
+
});
|