@prosopo/procaptcha-common 2.14.2 → 2.15.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 +6 -5
- package/.turbo/turbo-build$colon$tsc.log +18 -18
- package/.turbo/turbo-build.log +7 -6
- package/CHANGELOG.md +19 -0
- package/dist/cjs/clientMetaData.cjs +9 -1
- package/dist/cjs/index.cjs +3 -0
- package/dist/cjs/protectSession.cjs +64 -0
- package/dist/clientMetaData.d.ts.map +1 -1
- package/dist/clientMetaData.js +9 -1
- package/dist/clientMetaData.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/protectSession.d.ts +3 -0
- package/dist/protectSession.d.ts.map +1 -0
- package/dist/protectSession.js +63 -0
- package/dist/protectSession.js.map +1 -0
- package/dist/tests/clientMetaData.test.js +20 -1
- package/dist/tests/clientMetaData.test.js.map +1 -1
- package/dist/tests/protectSession.test.d.ts +2 -0
- package/dist/tests/protectSession.test.d.ts.map +1 -0
- package/dist/tests/protectSession.test.js +95 -0
- package/dist/tests/protectSession.test.js.map +1 -0
- package/package.json +4 -4
- package/src/clientMetaData.ts +9 -1
- package/src/index.ts +1 -0
- package/src/protectSession.ts +110 -0
- package/src/tests/clientMetaData.test.ts +27 -1
- package/src/tests/protectSession.test.ts +148 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/clientMetaData.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// limitations under the License.
|
|
14
14
|
|
|
15
15
|
import type { ClientMetaData } from "@prosopo/types";
|
|
16
|
+
import { resolveClientSessionId } from "./protectSession.js";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Assembles the widget-controlled metadata attached to a captcha solution.
|
|
@@ -22,6 +23,12 @@ import type { ClientMetaData } from "@prosopo/types";
|
|
|
22
23
|
* Returns `undefined` when nothing is set, so the submission body omits the
|
|
23
24
|
* key entirely rather than carrying an empty object.
|
|
24
25
|
*
|
|
26
|
+
* A site that renders no session id of its own but runs Prosopo Protect falls
|
|
27
|
+
* back to Protect's session id, which is the same value Protect's own
|
|
28
|
+
* challenge page renders the widget with. Read here rather than at render time
|
|
29
|
+
* because Protect initialises asynchronously and may not have a session yet
|
|
30
|
+
* when the widget mounts.
|
|
31
|
+
*
|
|
25
32
|
* @param hp - live honeypot input value, if the honeypot was filled in
|
|
26
33
|
* @param clientSessionId - the site's session id, if the widget was rendered
|
|
27
34
|
* with `data-sessionid` / `renderOptions.sessionId`
|
|
@@ -30,9 +37,10 @@ export const buildClientMetaData = (
|
|
|
30
37
|
hp?: string,
|
|
31
38
|
clientSessionId?: string,
|
|
32
39
|
): ClientMetaData | undefined => {
|
|
40
|
+
const sessionId = resolveClientSessionId(clientSessionId);
|
|
33
41
|
const clientMetaData: ClientMetaData = {
|
|
34
42
|
...(hp && { hp }),
|
|
35
|
-
...(
|
|
43
|
+
...(sessionId && { clientSessionId: sessionId }),
|
|
36
44
|
};
|
|
37
45
|
|
|
38
46
|
return Object.keys(clientMetaData).length > 0 ? clientMetaData : undefined;
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,110 @@
|
|
|
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 { INPUT_LIMITS } from "@prosopo/types";
|
|
16
|
+
|
|
17
|
+
const PROTECT_GLOBAL = "prosopo_protect";
|
|
18
|
+
const PROTECT_COOKIE = "prosopo_session";
|
|
19
|
+
|
|
20
|
+
type ProtectGlobal = {
|
|
21
|
+
jti?: unknown;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const isProtectGlobal = (value: unknown): value is ProtectGlobal =>
|
|
25
|
+
typeof value === "object" && value !== null;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `bumblebee-<uuid>` today, but the prefix names the edge that issued the
|
|
29
|
+
* session, so this only rejects what could not be an id at all.
|
|
30
|
+
*
|
|
31
|
+
* The length bound is load-bearing rather than defensive: the provider caps
|
|
32
|
+
* `clientSessionId` at `INPUT_LIMITS.ID` and rejects the whole solution body
|
|
33
|
+
* when it is longer, so an oversized value here would fail the solve rather
|
|
34
|
+
* than just losing the correlation.
|
|
35
|
+
*/
|
|
36
|
+
const isUsableJti = (value: string): boolean =>
|
|
37
|
+
value.length > 0 &&
|
|
38
|
+
value.length <= INPUT_LIMITS.ID &&
|
|
39
|
+
/^[A-Za-z0-9._-]+$/.test(value);
|
|
40
|
+
|
|
41
|
+
const readGlobalJti = (): string | undefined => {
|
|
42
|
+
const global: unknown = Reflect.get(window, PROTECT_GLOBAL);
|
|
43
|
+
if (!isProtectGlobal(global) || typeof global.jti !== "string") {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
const jti = global.jti.trim();
|
|
47
|
+
return isUsableJti(jti) ? jti : undefined;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const readCookieJti = (): string | undefined => {
|
|
51
|
+
if (typeof document === "undefined") {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
for (const pair of document.cookie.split(";")) {
|
|
55
|
+
const separator = pair.indexOf("=");
|
|
56
|
+
if (
|
|
57
|
+
separator === -1 ||
|
|
58
|
+
pair.slice(0, separator).trim() !== PROTECT_COOKIE
|
|
59
|
+
) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
// The cookie is `{jti}|{jwt}`. The second half is a bearer token for the
|
|
63
|
+
// Protect session and must never leave the page.
|
|
64
|
+
const jti = pair
|
|
65
|
+
.slice(separator + 1)
|
|
66
|
+
.trim()
|
|
67
|
+
.split("|")[0];
|
|
68
|
+
return jti !== undefined && isUsableJti(jti) ? jti : undefined;
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The Protect session id (JTI) for this page, when the site runs Prosopo
|
|
75
|
+
* Protect alongside the captcha.
|
|
76
|
+
*
|
|
77
|
+
* Protect publishes it two ways, both read here: `window.prosopo_protect.jti`,
|
|
78
|
+
* and the `prosopo_session` cookie it sets on the registrable domain (so a
|
|
79
|
+
* session minted at `protect.example.com` is visible on `www.example.com`).
|
|
80
|
+
* The global is preferred — it is the value Protect itself is using, whereas
|
|
81
|
+
* the cookie may have been rewritten by anything on the page.
|
|
82
|
+
*
|
|
83
|
+
* Undefined whenever the site does not run Protect, the widget is embedded
|
|
84
|
+
* cross-origin, or Protect has not finished initialising. Every caller treats
|
|
85
|
+
* that as "no correlation available", never as a fault.
|
|
86
|
+
*/
|
|
87
|
+
export const getProtectJti = (): string | undefined => {
|
|
88
|
+
if (typeof window === "undefined") {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
return readGlobalJti() ?? readCookieJti();
|
|
93
|
+
} catch {
|
|
94
|
+
// `document.cookie` throws in a sandboxed iframe, and a page is free to
|
|
95
|
+
// define `prosopo_protect` as a throwing getter.
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The session id this widget reports to the provider: whatever the site asked
|
|
102
|
+
* for, and Protect's session id only when the site asked for nothing.
|
|
103
|
+
*
|
|
104
|
+
* One function so the precedence is stated once — the frictionless request and
|
|
105
|
+
* the solution submit have to agree on it, or the provider's own
|
|
106
|
+
* mismatch check would fire on a session it built both halves of.
|
|
107
|
+
*/
|
|
108
|
+
export const resolveClientSessionId = (
|
|
109
|
+
clientSessionId?: string,
|
|
110
|
+
): string | undefined => clientSessionId || getProtectJti();
|
|
@@ -12,9 +12,19 @@
|
|
|
12
12
|
// See the License for the specific language governing permissions and
|
|
13
13
|
// limitations under the License.
|
|
14
14
|
|
|
15
|
-
import { describe, expect, it } from "vitest";
|
|
15
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
16
16
|
import { buildClientMetaData } from "../clientMetaData.js";
|
|
17
17
|
|
|
18
|
+
const PROTECT_JTI = "bumblebee-0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0";
|
|
19
|
+
|
|
20
|
+
const runProtect = (): void => {
|
|
21
|
+
Reflect.set(window, "prosopo_protect", { jti: PROTECT_JTI, ready: true });
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
Reflect.deleteProperty(window, "prosopo_protect");
|
|
26
|
+
});
|
|
27
|
+
|
|
18
28
|
describe("buildClientMetaData", () => {
|
|
19
29
|
it("returns undefined when the widget has nothing to report", () => {
|
|
20
30
|
expect(buildClientMetaData(undefined, undefined)).toBeUndefined();
|
|
@@ -42,4 +52,20 @@ describe("buildClientMetaData", () => {
|
|
|
42
52
|
clientSessionId: "jti-1",
|
|
43
53
|
});
|
|
44
54
|
});
|
|
55
|
+
|
|
56
|
+
it("falls back to Protect's session id when the site rendered none", () => {
|
|
57
|
+
runProtect();
|
|
58
|
+
|
|
59
|
+
expect(buildClientMetaData(undefined, undefined)).toEqual({
|
|
60
|
+
clientSessionId: PROTECT_JTI,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("leaves the site's own session id alone when Protect is also present", () => {
|
|
65
|
+
runProtect();
|
|
66
|
+
|
|
67
|
+
expect(buildClientMetaData(undefined, "site-session-1")).toEqual({
|
|
68
|
+
clientSessionId: "site-session-1",
|
|
69
|
+
});
|
|
70
|
+
});
|
|
45
71
|
});
|
|
@@ -0,0 +1,148 @@
|
|
|
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 { INPUT_LIMITS } from "@prosopo/types";
|
|
16
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
17
|
+
import { getProtectJti, resolveClientSessionId } from "../protectSession.js";
|
|
18
|
+
|
|
19
|
+
const JTI = "bumblebee-0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0";
|
|
20
|
+
const JWT = "eyJhbGciOiJzcjI1NTE5In0.eyJqdGkiOiJ4In0.c2lnbmF0dXJl";
|
|
21
|
+
|
|
22
|
+
const setCookie = (value: string): void => {
|
|
23
|
+
document.cookie = `prosopo_session=${value}; path=/`;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const clearCookie = (): void => {
|
|
27
|
+
document.cookie = "prosopo_session=; path=/; max-age=0";
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const setGlobal = (value: unknown): void => {
|
|
31
|
+
Reflect.set(window, "prosopo_protect", value);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
Reflect.deleteProperty(window, "prosopo_protect");
|
|
36
|
+
clearCookie();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("getProtectJti", () => {
|
|
40
|
+
it("returns undefined when the site does not run Protect", () => {
|
|
41
|
+
expect(getProtectJti()).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("reads the jti from the Protect global", () => {
|
|
45
|
+
setGlobal({ version: "0.1.0", jti: JTI, ready: true });
|
|
46
|
+
|
|
47
|
+
expect(getProtectJti()).toBe(JTI);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("falls back to the session cookie when the global has no jti yet", () => {
|
|
51
|
+
setGlobal({ version: "0.1.0", ready: false });
|
|
52
|
+
setCookie(`${JTI}|${JWT}`);
|
|
53
|
+
|
|
54
|
+
expect(getProtectJti()).toBe(JTI);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("never returns the jwt half of the cookie", () => {
|
|
58
|
+
setCookie(`${JTI}|${JWT}`);
|
|
59
|
+
|
|
60
|
+
const jti = getProtectJti();
|
|
61
|
+
|
|
62
|
+
expect(jti).toBe(JTI);
|
|
63
|
+
expect(jti).not.toContain(JWT);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("reads a cookie that carries the jti alone", () => {
|
|
67
|
+
setCookie(JTI);
|
|
68
|
+
|
|
69
|
+
expect(getProtectJti()).toBe(JTI);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("ignores an empty jti in the cookie", () => {
|
|
73
|
+
setCookie(`|${JWT}`);
|
|
74
|
+
|
|
75
|
+
expect(getProtectJti()).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("ignores other cookies whose name merely ends in the same suffix", () => {
|
|
79
|
+
document.cookie = `not_prosopo_session=${JTI}; path=/`;
|
|
80
|
+
|
|
81
|
+
expect(getProtectJti()).toBeUndefined();
|
|
82
|
+
|
|
83
|
+
document.cookie = "not_prosopo_session=; path=/; max-age=0";
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// The provider caps clientSessionId at INPUT_LIMITS.ID and rejects the whole
|
|
87
|
+
// solution body when it is longer, so an oversized value has to be dropped
|
|
88
|
+
// here rather than failing the solve.
|
|
89
|
+
it("ignores a jti longer than the provider accepts", () => {
|
|
90
|
+
setGlobal({ jti: "a".repeat(INPUT_LIMITS.ID + 1) });
|
|
91
|
+
|
|
92
|
+
expect(getProtectJti()).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("accepts a jti exactly at the limit", () => {
|
|
96
|
+
const atLimit = "a".repeat(INPUT_LIMITS.ID);
|
|
97
|
+
setGlobal({ jti: atLimit });
|
|
98
|
+
|
|
99
|
+
expect(getProtectJti()).toBe(atLimit);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("ignores a jti containing characters an id cannot have", () => {
|
|
103
|
+
setGlobal({ jti: "bumblebee-<script>" });
|
|
104
|
+
|
|
105
|
+
expect(getProtectJti()).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("ignores a non-string jti", () => {
|
|
109
|
+
setGlobal({ jti: 42 });
|
|
110
|
+
|
|
111
|
+
expect(getProtectJti()).toBeUndefined();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("survives a page that defines the global as a throwing getter", () => {
|
|
115
|
+
Object.defineProperty(window, "prosopo_protect", {
|
|
116
|
+
configurable: true,
|
|
117
|
+
get: () => {
|
|
118
|
+
throw new Error("nope");
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(getProtectJti()).toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("resolveClientSessionId", () => {
|
|
127
|
+
it("prefers the session id the site rendered the widget with", () => {
|
|
128
|
+
setGlobal({ jti: JTI });
|
|
129
|
+
|
|
130
|
+
expect(resolveClientSessionId("site-session-1")).toBe("site-session-1");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("falls back to Protect when the site rendered no session id", () => {
|
|
134
|
+
setGlobal({ jti: JTI });
|
|
135
|
+
|
|
136
|
+
expect(resolveClientSessionId(undefined)).toBe(JTI);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("treats an empty site session id as none", () => {
|
|
140
|
+
setGlobal({ jti: JTI });
|
|
141
|
+
|
|
142
|
+
expect(resolveClientSessionId("")).toBe(JTI);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("returns undefined when neither is available", () => {
|
|
146
|
+
expect(resolveClientSessionId(undefined)).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
});
|