cursedbelt-server 4.4.0 โ 4.6.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/dist/server/analytics/index.d.ts +18 -0
- package/dist/server/analytics/index.js +18 -0
- package/dist/server/analytics/injectedScripts.d.ts +54 -0
- package/dist/server/analytics/injectedScripts.js +138 -0
- package/dist/server/analytics/webAnalytics.d.ts +110 -0
- package/dist/server/analytics/webAnalytics.js +130 -0
- package/dist/server/master-lock/guard.d.ts +14 -0
- package/dist/server/master-lock/guard.js +11 -3
- package/dist/server/master-lock/index.d.ts +1 -1
- package/dist/server/master-lock/index.js +1 -1
- package/package.json +7 -1
- package/src/server/analytics/index.ts +32 -0
- package/src/server/analytics/injectedScripts.spec.ts +144 -0
- package/src/server/analytics/injectedScripts.ts +139 -0
- package/src/server/analytics/webAnalytics.spec.ts +131 -0
- package/src/server/analytics/webAnalytics.ts +144 -0
- package/src/server/master-lock/beaconNeverReachesTheWall.spec.ts +227 -0
- package/src/server/master-lock/guard.ts +11 -3
- package/src/server/master-lock/index.ts +6 -1
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
MASTER_LOCK_PATHS,
|
|
4
|
+
type MasterLockKdfParams,
|
|
5
|
+
deriveMasterLockVerifier,
|
|
6
|
+
} from "cursedbelt-core/master-lock";
|
|
7
|
+
import {
|
|
8
|
+
WEB_ANALYTICS_BEACON_ORIGIN,
|
|
9
|
+
WEB_ANALYTICS_BEACON_SRC,
|
|
10
|
+
WEB_ANALYTICS_SITE_TOKEN,
|
|
11
|
+
WEB_ANALYTICS_TAG,
|
|
12
|
+
foreignScriptSources,
|
|
13
|
+
htmlCarriesWebAnalytics,
|
|
14
|
+
scriptsRefusedByCsp,
|
|
15
|
+
} from "../analytics";
|
|
16
|
+
import { MASTER_LOCK_CSP, createMasterLockGuard } from "./guard";
|
|
17
|
+
import { MasterLock } from "./masterLock";
|
|
18
|
+
import { createMemoryMasterLockStore } from "./store";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* ๐ด **The wall never carries the beacon, and the public shells always do.** Both halves, in
|
|
22
|
+
* one file, because half of this decision is an ABSENCE and an absence is what nobody notices
|
|
23
|
+
* has stopped being true.
|
|
24
|
+
*
|
|
25
|
+
* ## The decision this proves
|
|
26
|
+
*
|
|
27
|
+
* Cloudflare Web Analytics was on `cursedalchemy.com` with **Automatic Setup**: the edge
|
|
28
|
+
* appended its beacon to every HTML response on the zone, including the master-lock wall โ
|
|
29
|
+
* the one page whose entire job is to take a master password with nothing else on it. The
|
|
30
|
+
* wall's {@link MASTER_LOCK_CSP} refused the tag, correctly, and the refusal printed in the
|
|
31
|
+
* console. The owner read that console during an outage on 2026-09-17 and it was the first
|
|
32
|
+
* line he saw.
|
|
33
|
+
*
|
|
34
|
+
* Three ways out were put to him. He took none of them as written (2026-09-18):
|
|
35
|
+
*
|
|
36
|
+
* > *"do your recommendation with option c but not on the wall. If we can stop the console
|
|
37
|
+
* > error that would be good since we are purposely blocking on that page and it makes it look
|
|
38
|
+
* > like there is a mistake when it shows."*
|
|
39
|
+
*
|
|
40
|
+
* and, on why the CSP does not simply gain the host:
|
|
41
|
+
*
|
|
42
|
+
* > *"I don't want to trust cloudflare with master password data that is typed in."*
|
|
43
|
+
*
|
|
44
|
+
* So: manual install, the snippet in the public shells, nothing on the wall โ and no console
|
|
45
|
+
* error, because under manual setup nothing is injected into the wall to be refused.
|
|
46
|
+
*
|
|
47
|
+
* ## Why the check lives HERE
|
|
48
|
+
*
|
|
49
|
+
* The wall is served by this package to every app that mounts the guard, and the CSP is one
|
|
50
|
+
* shared constant. An app-local check can only ever see one app's shell, and the deployed
|
|
51
|
+
* smoke that `apps/collections` gained on 2026-09-18 cannot see the wall at all โ it runs with
|
|
52
|
+
* no credentials and the wall is behind SSO. The seam that covers every app is this one, next
|
|
53
|
+
* to the policy itself.
|
|
54
|
+
*
|
|
55
|
+
* ## What would turn this red
|
|
56
|
+
*
|
|
57
|
+
* ยท somebody adds `static.cloudflareinsights.com` to {@link MASTER_LOCK_CSP} โ the thing he
|
|
58
|
+
* refused;
|
|
59
|
+
* ยท somebody pastes the analytics tag into `lockPage.ts` or `accountsPage.ts`;
|
|
60
|
+
* ยท the wall starts serving any cross-origin script its own policy would refuse, beacon or
|
|
61
|
+
* not โ the general invariant, which names no vendor.
|
|
62
|
+
*/
|
|
63
|
+
const KDF: MasterLockKdfParams = {
|
|
64
|
+
v: 1,
|
|
65
|
+
alg: "PBKDF2-SHA256",
|
|
66
|
+
iter: 1,
|
|
67
|
+
salt: "AAECAwQFBgcICQoLDA0ODw",
|
|
68
|
+
};
|
|
69
|
+
const PAGE = "https://collections.cursedalchemy.com/";
|
|
70
|
+
|
|
71
|
+
let VERIFIER = "";
|
|
72
|
+
let SEED = "";
|
|
73
|
+
|
|
74
|
+
beforeAll(async () => {
|
|
75
|
+
VERIFIER = await deriveMasterLockVerifier("open sesame", KDF);
|
|
76
|
+
SEED = JSON.stringify({
|
|
77
|
+
kdf: KDF,
|
|
78
|
+
verifierHash: await Bun.password.hash(VERIFIER, { algorithm: "argon2id" }),
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
function build() {
|
|
83
|
+
const lock = new MasterLock({ store: createMemoryMasterLockStore(), seedJson: SEED });
|
|
84
|
+
return createMasterLockGuard({ lock, appLabel: "Test App" });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const doc = (path: string, cookie?: string): Request =>
|
|
88
|
+
new Request(`https://collections.cursedalchemy.com${path}`, {
|
|
89
|
+
headers: { "sec-fetch-dest": "document", ...(cookie ? { cookie } : {}) },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/** Unlock, and hand back the cookie pair a browser would send with the next request. */
|
|
93
|
+
async function open(guard: ReturnType<typeof build>): Promise<string> {
|
|
94
|
+
const res = await guard.handle(
|
|
95
|
+
new Request(`https://collections.cursedalchemy.com${MASTER_LOCK_PATHS.unlock}`, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: { "content-type": "application/json" },
|
|
98
|
+
body: JSON.stringify({ verifier: VERIFIER }),
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
expect(res?.status).toBe(200);
|
|
102
|
+
return (res?.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Every HTML surface the wall can answer a browser with, as real responses. */
|
|
106
|
+
async function wallPages(): Promise<{ label: string; html: string; csp: string | null }[]> {
|
|
107
|
+
const guard = build();
|
|
108
|
+
const locked = await guard.handle(doc("/some/deep/page"));
|
|
109
|
+
expect(locked?.status).toBe(200);
|
|
110
|
+
const cookie = await open(guard);
|
|
111
|
+
const accounts = await guard.handle(doc(MASTER_LOCK_PATHS.accounts, cookie));
|
|
112
|
+
expect(accounts?.status).toBe(200);
|
|
113
|
+
const pages = [
|
|
114
|
+
{ label: "lock page", res: locked as Response },
|
|
115
|
+
{ label: "accounts page", res: accounts as Response },
|
|
116
|
+
];
|
|
117
|
+
return Promise.all(
|
|
118
|
+
pages.map(async ({ label, res }) => ({
|
|
119
|
+
label,
|
|
120
|
+
html: await res.text(),
|
|
121
|
+
csp: res.headers.get("content-security-policy"),
|
|
122
|
+
})),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
describe("the wall, as it is actually served", () => {
|
|
127
|
+
test("every HTML surface answers with the shared CSP", async () => {
|
|
128
|
+
for (const page of await wallPages()) {
|
|
129
|
+
expect(page.csp, `${page.label} must carry the wall's CSP`).toBe(MASTER_LOCK_CSP);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("carries NO cross-origin script โ beacon or otherwise", async () => {
|
|
134
|
+
for (const page of await wallPages()) {
|
|
135
|
+
expect(foreignScriptSources(page.html, PAGE), `${page.label} pulled in a foreign script`).toEqual(
|
|
136
|
+
[],
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("does not mention the analytics beacon anywhere in its markup", async () => {
|
|
142
|
+
for (const page of await wallPages()) {
|
|
143
|
+
expect(page.html).not.toContain(WEB_ANALYTICS_BEACON_SRC);
|
|
144
|
+
expect(page.html).not.toContain(WEB_ANALYTICS_SITE_TOKEN);
|
|
145
|
+
expect(htmlCarriesWebAnalytics(page.html)).toBe(false);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/** The general invariant: nothing that arrives may be refused by the policy on that response. */
|
|
150
|
+
test("nothing it serves is refused by its own CSP", async () => {
|
|
151
|
+
for (const page of await wallPages()) {
|
|
152
|
+
const refused = scriptsRefusedByCsp(page.csp, foreignScriptSources(page.html, PAGE), PAGE);
|
|
153
|
+
expect(refused.map((s) => s.origin), `${page.label} serves a script it then refuses`).toEqual(
|
|
154
|
+
[],
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("the CSP keeps the beacon out by construction", () => {
|
|
161
|
+
test("MASTER_LOCK_CSP never names the beacon's host", () => {
|
|
162
|
+
expect(MASTER_LOCK_CSP).not.toContain("cloudflareinsights");
|
|
163
|
+
expect(MASTER_LOCK_CSP).toContain("script-src 'self'");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* ๐ด The coupling, and the reason this is one file rather than two assertions in two repos:
|
|
168
|
+
* the wall's policy REFUSES the tag. So "the wall carries no beacon" cannot quietly become
|
|
169
|
+
* "the wall carries a beacon that works" โ reaching that state means somebody loosened the
|
|
170
|
+
* policy, and the assertion above is what catches them.
|
|
171
|
+
*/
|
|
172
|
+
test("the tag, pasted into the wall, would be refused โ and this names the origin", () => {
|
|
173
|
+
const refused = scriptsRefusedByCsp(
|
|
174
|
+
MASTER_LOCK_CSP,
|
|
175
|
+
foreignScriptSources(WEB_ANALYTICS_TAG, PAGE),
|
|
176
|
+
PAGE,
|
|
177
|
+
);
|
|
178
|
+
expect(refused.map((s) => s.origin)).toEqual([WEB_ANALYTICS_BEACON_ORIGIN]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("the failure path: allowing the host is what would make it load", () => {
|
|
182
|
+
const loosened = `${MASTER_LOCK_CSP}; script-src-elem 'self' ${WEB_ANALYTICS_BEACON_ORIGIN}`;
|
|
183
|
+
expect(scriptsRefusedByCsp(loosened, foreignScriptSources(WEB_ANALYTICS_TAG, PAGE), PAGE)).toEqual(
|
|
184
|
+
[],
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("the other half โ a public shell that carries it", () => {
|
|
190
|
+
/**
|
|
191
|
+
* The shell an app ships: inline colour-scheme bootstrap, its own bundle, and the beacon.
|
|
192
|
+
* An app asserts this about ITS OWN `index.html` (rule 3 โ a repo's gate proves that repo);
|
|
193
|
+
* what this package owes it is a helper pair that cannot disagree with the wall's.
|
|
194
|
+
*/
|
|
195
|
+
const SHELL = `<!doctype html><html><head>
|
|
196
|
+
<script>try { document.documentElement.classList.toggle("dark", true); } catch (e) {}</script>
|
|
197
|
+
${WEB_ANALYTICS_TAG}
|
|
198
|
+
</head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>`;
|
|
199
|
+
|
|
200
|
+
test("the helper recognises the tag, and says no when it is missing", () => {
|
|
201
|
+
expect(htmlCarriesWebAnalytics(SHELL)).toBe(true);
|
|
202
|
+
expect(htmlCarriesWebAnalytics(SHELL.replace(WEB_ANALYTICS_TAG, ""))).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("a shell carrying the RIGHT src but the site TAG as its token is not carrying it", () => {
|
|
206
|
+
// The trap `webAnalytics.ts` documents: tag and token are both 32 hex characters, the
|
|
207
|
+
// page still loads, and the data goes nowhere.
|
|
208
|
+
const wrong = SHELL.replace(WEB_ANALYTICS_SITE_TOKEN, "d1bea8562544421fbd2db5f11b74d1cc");
|
|
209
|
+
expect(htmlCarriesWebAnalytics(wrong)).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("with no CSP the beacon is permitted โ the public shells' actual state", () => {
|
|
213
|
+
expect(scriptsRefusedByCsp(null, foreignScriptSources(SHELL, PAGE), PAGE)).toEqual([]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* ๐ด `apps/vault` is the exception, and it is an exception the general rule already gets
|
|
218
|
+
* right without knowing the app's name: vault serves `script-src 'self'` on its PUBLIC
|
|
219
|
+
* shell, so the beacon does not belong there either. Measured live on 2026-09-18 โ vault's
|
|
220
|
+
* shell was the one host on the zone the edge's injection never reached.
|
|
221
|
+
*/
|
|
222
|
+
test("a shell with a strict CSP must NOT carry it, and the check says why", () => {
|
|
223
|
+
const strict = "default-src 'self'; script-src 'self'; frame-ancestors 'none'";
|
|
224
|
+
const refused = scriptsRefusedByCsp(strict, foreignScriptSources(SHELL, PAGE), PAGE);
|
|
225
|
+
expect(refused.map((s) => s.origin)).toEqual([WEB_ANALYTICS_BEACON_ORIGIN]);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -79,8 +79,16 @@ const noStore = { "cache-control": "no-store, no-cache, must-revalidate", pragma
|
|
|
79
79
|
* The lock page's own CSP. Strict enough to stand in front of `apps/vault`'s wall without
|
|
80
80
|
* loosening it: nothing loads from anywhere but this origin, the form posts nowhere (the
|
|
81
81
|
* script does the POST), and the page cannot be framed.
|
|
82
|
+
*
|
|
83
|
+
* ๐ด **EXPORTED so that nothing has to copy it.** `apps/collections` had a hand-typed
|
|
84
|
+
* duplicate of this string in a test until 2026-09-18, which is a policy that can drift
|
|
85
|
+
* without anything going red. It is also what
|
|
86
|
+
* `beaconNeverReachesTheWall.spec.ts` measures the beacon against: the wall refuses
|
|
87
|
+
* `static.cloudflareinsights.com`, so the analytics tag and this response are mutually
|
|
88
|
+
* exclusive BY CONSTRUCTION rather than by anyone remembering. The owner's reason, 2026-09-18:
|
|
89
|
+
* *"I don't want to trust cloudflare with master password data that is typed in."*
|
|
82
90
|
*/
|
|
83
|
-
const
|
|
91
|
+
export const MASTER_LOCK_CSP =
|
|
84
92
|
"default-src 'none'; style-src 'self'; script-src 'self'; connect-src 'self'; " +
|
|
85
93
|
"img-src data:; form-action 'none'; base-uri 'none'; frame-ancestors 'none'";
|
|
86
94
|
|
|
@@ -357,7 +365,7 @@ function lockPage(options: LockPageOptions, lock: MasterLock): Response {
|
|
|
357
365
|
status: 200,
|
|
358
366
|
headers: {
|
|
359
367
|
"content-type": "text/html; charset=utf-8",
|
|
360
|
-
"content-security-policy":
|
|
368
|
+
"content-security-policy": MASTER_LOCK_CSP,
|
|
361
369
|
"x-content-type-options": "nosniff",
|
|
362
370
|
[MASTER_LOCK_STATE_HEADER]: "locked",
|
|
363
371
|
...noStore,
|
|
@@ -377,7 +385,7 @@ function accountsPage(options: LockPageOptions): Response {
|
|
|
377
385
|
status: 200,
|
|
378
386
|
headers: {
|
|
379
387
|
"content-type": "text/html; charset=utf-8",
|
|
380
|
-
"content-security-policy":
|
|
388
|
+
"content-security-policy": MASTER_LOCK_CSP,
|
|
381
389
|
"x-content-type-options": "nosniff",
|
|
382
390
|
...noStore,
|
|
383
391
|
},
|
|
@@ -27,7 +27,12 @@ export {
|
|
|
27
27
|
MASTER_LOCK_MIN_PASSWORD_LENGTH,
|
|
28
28
|
accountsPageHtml,
|
|
29
29
|
} from "./accountsPage";
|
|
30
|
-
export {
|
|
30
|
+
export {
|
|
31
|
+
MASTER_LOCK_CSP,
|
|
32
|
+
type MasterLockGuard,
|
|
33
|
+
type MasterLockGuardOptions,
|
|
34
|
+
createMasterLockGuard,
|
|
35
|
+
} from "./guard";
|
|
31
36
|
export {
|
|
32
37
|
LOCK_SCRIPT,
|
|
33
38
|
LOCK_STYLE,
|