@prosopo/procaptcha-common 2.14.1 → 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.
@@ -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
- ...(clientSessionId && { clientSessionId }),
43
+ ...(sessionId && { clientSessionId: sessionId }),
36
44
  };
37
45
 
38
46
  return Object.keys(clientMetaData).length > 0 ? clientMetaData : undefined;
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@
14
14
 
15
15
  export * from "./providers.js";
16
16
  export * from "./clientMetaData.js";
17
+ export * from "./protectSession.js";
17
18
  export * from "./events/trust.js";
18
19
  export * from "./state/builder.js";
19
20
  export * from "./callbacks/defaultCallbacks.js";
@@ -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
+ });