cursedbelt-server 4.4.0 → 4.5.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 +73 -0
- package/dist/server/analytics/webAnalytics.js +80 -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 +30 -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 +68 -0
- package/src/server/analytics/webAnalytics.ts +92 -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,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt-server/analytics` — where the fleet's web-analytics beacon is DEFINED, and the
|
|
3
|
+
* invariant that decides which responses may carry it.
|
|
4
|
+
*
|
|
5
|
+
* Two files, and the pairing is the point:
|
|
6
|
+
*
|
|
7
|
+
* · `webAnalytics.ts` is the one copy of the tag. An app's public shell pastes it; nothing
|
|
8
|
+
* generates ten of them.
|
|
9
|
+
* · `injectedScripts.ts` is the general rule — nothing that arrives in a response's HTML may
|
|
10
|
+
* be refused by the CSP on that same response — which is what makes "the wall never carries
|
|
11
|
+
* the beacon" a CHECK rather than a sentence. `../master-lock/beaconNeverReachesTheWall.spec.ts`
|
|
12
|
+
* is that check.
|
|
13
|
+
*
|
|
14
|
+
* Both are pure string work with no imports at all, so this subpath costs a consumer nothing
|
|
15
|
+
* and drags no peer.
|
|
16
|
+
*/
|
|
17
|
+
export { type InjectedScript, foreignScriptSources, scriptsRefusedByCsp, } from "./injectedScripts";
|
|
18
|
+
export { WEB_ANALYTICS_BEACON_ORIGIN, WEB_ANALYTICS_BEACON_SRC, WEB_ANALYTICS_SITE_TAG, WEB_ANALYTICS_SITE_TOKEN, WEB_ANALYTICS_TAG, htmlCarriesWebAnalytics, webAnalyticsTag, } from "./webAnalytics";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt-server/analytics` — where the fleet's web-analytics beacon is DEFINED, and the
|
|
3
|
+
* invariant that decides which responses may carry it.
|
|
4
|
+
*
|
|
5
|
+
* Two files, and the pairing is the point:
|
|
6
|
+
*
|
|
7
|
+
* · `webAnalytics.ts` is the one copy of the tag. An app's public shell pastes it; nothing
|
|
8
|
+
* generates ten of them.
|
|
9
|
+
* · `injectedScripts.ts` is the general rule — nothing that arrives in a response's HTML may
|
|
10
|
+
* be refused by the CSP on that same response — which is what makes "the wall never carries
|
|
11
|
+
* the beacon" a CHECK rather than a sentence. `../master-lock/beaconNeverReachesTheWall.spec.ts`
|
|
12
|
+
* is that check.
|
|
13
|
+
*
|
|
14
|
+
* Both are pure string work with no imports at all, so this subpath costs a consumer nothing
|
|
15
|
+
* and drags no peer.
|
|
16
|
+
*/
|
|
17
|
+
export { foreignScriptSources, scriptsRefusedByCsp, } from "./injectedScripts";
|
|
18
|
+
export { WEB_ANALYTICS_BEACON_ORIGIN, WEB_ANALYTICS_BEACON_SRC, WEB_ANALYTICS_SITE_TAG, WEB_ANALYTICS_SITE_TOKEN, WEB_ANALYTICS_TAG, htmlCarriesWebAnalytics, webAnalyticsTag, } from "./webAnalytics";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does a page's own CSP refuse a script that arrived in that page's own HTML?
|
|
3
|
+
*
|
|
4
|
+
* ## The invariant, which names no vendor
|
|
5
|
+
*
|
|
6
|
+
* > No script that reaches the browser inside a response's HTML may be refused by the CSP on
|
|
7
|
+
* > that same response.
|
|
8
|
+
*
|
|
9
|
+
* Every part of the arrangement may move and this stays right. Turn an edge injection off and
|
|
10
|
+
* the foreign scripts disappear — green. Decide a third-party script is wanted and allow its
|
|
11
|
+
* host — green. Harden a page with `script-src 'self'` while something is still injecting into
|
|
12
|
+
* it — RED, and it names the origin, which is the console line the owner would otherwise find
|
|
13
|
+
* himself during an incident.
|
|
14
|
+
*
|
|
15
|
+
* ## 🔴 Why it lives in this package
|
|
16
|
+
*
|
|
17
|
+
* It was written in `apps/collections` on 2026-09-18 and it was right there, but it could only
|
|
18
|
+
* ever see one app — and the response it was written about, the master-lock wall, is served by
|
|
19
|
+
* THIS package to every app that mounts the guard. One copy here is what makes the two halves
|
|
20
|
+
* of `webAnalytics.ts`'s decision checkable in the same place: the wall refuses the beacon, and
|
|
21
|
+
* a public shell carries it.
|
|
22
|
+
*
|
|
23
|
+
* ## 🔴 Conservative on purpose
|
|
24
|
+
*
|
|
25
|
+
* Every ambiguity resolves to PERMITTED. A source expression this file cannot parse, a
|
|
26
|
+
* `'strict-dynamic'` that changes what host lists even mean, a CSP with no script directive at
|
|
27
|
+
* all — all of them pass. A deployed smoke decides whether an app's `scripts/deploy.ts` rolls
|
|
28
|
+
* back; a false red there discards good work over a CSP grammar corner, so the only thing that
|
|
29
|
+
* fails here is a script with NO source expression that could admit it.
|
|
30
|
+
*/
|
|
31
|
+
/** An absolute script URL found in the HTML, with the origin already resolved. */
|
|
32
|
+
export interface InjectedScript {
|
|
33
|
+
/** Exactly as it appeared in the markup, for the failure message. */
|
|
34
|
+
src: string;
|
|
35
|
+
/** `https://static.cloudflareinsights.com`, resolved against the page's own URL. */
|
|
36
|
+
origin: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Every `<script src=…>` in `html` whose origin is NOT `pageUrl`'s.
|
|
40
|
+
*
|
|
41
|
+
* Inline scripts are ignored: they are the page's own (most shells here ship one, a pre-paint
|
|
42
|
+
* colour-scheme script) and a CSP that refuses them is a different finding from this one.
|
|
43
|
+
* `data:` and `blob:` are ignored for the same reason — neither is a host anything could have
|
|
44
|
+
* injected.
|
|
45
|
+
*/
|
|
46
|
+
export declare function foreignScriptSources(html: string, pageUrl: string): InjectedScript[];
|
|
47
|
+
/**
|
|
48
|
+
* Which of `scripts` the `content-security-policy` header would refuse.
|
|
49
|
+
*
|
|
50
|
+
* `null`/absent/empty header means no policy, which refuses nothing. The directive consulted
|
|
51
|
+
* is `script-src-elem`, then `script-src`, then `default-src` — the browser's own fallback
|
|
52
|
+
* order for a `<script src>` element.
|
|
53
|
+
*/
|
|
54
|
+
export declare function scriptsRefusedByCsp(csp: string | null | undefined, scripts: InjectedScript[], pageUrl: string): InjectedScript[];
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does a page's own CSP refuse a script that arrived in that page's own HTML?
|
|
3
|
+
*
|
|
4
|
+
* ## The invariant, which names no vendor
|
|
5
|
+
*
|
|
6
|
+
* > No script that reaches the browser inside a response's HTML may be refused by the CSP on
|
|
7
|
+
* > that same response.
|
|
8
|
+
*
|
|
9
|
+
* Every part of the arrangement may move and this stays right. Turn an edge injection off and
|
|
10
|
+
* the foreign scripts disappear — green. Decide a third-party script is wanted and allow its
|
|
11
|
+
* host — green. Harden a page with `script-src 'self'` while something is still injecting into
|
|
12
|
+
* it — RED, and it names the origin, which is the console line the owner would otherwise find
|
|
13
|
+
* himself during an incident.
|
|
14
|
+
*
|
|
15
|
+
* ## 🔴 Why it lives in this package
|
|
16
|
+
*
|
|
17
|
+
* It was written in `apps/collections` on 2026-09-18 and it was right there, but it could only
|
|
18
|
+
* ever see one app — and the response it was written about, the master-lock wall, is served by
|
|
19
|
+
* THIS package to every app that mounts the guard. One copy here is what makes the two halves
|
|
20
|
+
* of `webAnalytics.ts`'s decision checkable in the same place: the wall refuses the beacon, and
|
|
21
|
+
* a public shell carries it.
|
|
22
|
+
*
|
|
23
|
+
* ## 🔴 Conservative on purpose
|
|
24
|
+
*
|
|
25
|
+
* Every ambiguity resolves to PERMITTED. A source expression this file cannot parse, a
|
|
26
|
+
* `'strict-dynamic'` that changes what host lists even mean, a CSP with no script directive at
|
|
27
|
+
* all — all of them pass. A deployed smoke decides whether an app's `scripts/deploy.ts` rolls
|
|
28
|
+
* back; a false red there discards good work over a CSP grammar corner, so the only thing that
|
|
29
|
+
* fails here is a script with NO source expression that could admit it.
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* Every `<script src=…>` in `html` whose origin is NOT `pageUrl`'s.
|
|
33
|
+
*
|
|
34
|
+
* Inline scripts are ignored: they are the page's own (most shells here ship one, a pre-paint
|
|
35
|
+
* colour-scheme script) and a CSP that refuses them is a different finding from this one.
|
|
36
|
+
* `data:` and `blob:` are ignored for the same reason — neither is a host anything could have
|
|
37
|
+
* injected.
|
|
38
|
+
*/
|
|
39
|
+
export function foreignScriptSources(html, pageUrl) {
|
|
40
|
+
const page = new URL(pageUrl);
|
|
41
|
+
const found = [];
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
const tag = /<script\b[^>]*?\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/gi;
|
|
44
|
+
for (const match of html.matchAll(tag)) {
|
|
45
|
+
const raw = (match[1] ?? match[2] ?? match[3] ?? "").trim();
|
|
46
|
+
if (!raw)
|
|
47
|
+
continue;
|
|
48
|
+
let origin;
|
|
49
|
+
try {
|
|
50
|
+
const url = new URL(raw, page);
|
|
51
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
52
|
+
continue;
|
|
53
|
+
origin = url.origin;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
continue; // not a URL this check can reason about — see the conservative note
|
|
57
|
+
}
|
|
58
|
+
if (origin === page.origin)
|
|
59
|
+
continue;
|
|
60
|
+
if (seen.has(origin + raw))
|
|
61
|
+
continue;
|
|
62
|
+
seen.add(origin + raw);
|
|
63
|
+
found.push({ src: raw, origin });
|
|
64
|
+
}
|
|
65
|
+
return found;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Which of `scripts` the `content-security-policy` header would refuse.
|
|
69
|
+
*
|
|
70
|
+
* `null`/absent/empty header means no policy, which refuses nothing. The directive consulted
|
|
71
|
+
* is `script-src-elem`, then `script-src`, then `default-src` — the browser's own fallback
|
|
72
|
+
* order for a `<script src>` element.
|
|
73
|
+
*/
|
|
74
|
+
export function scriptsRefusedByCsp(csp, scripts, pageUrl) {
|
|
75
|
+
if (!csp?.trim() || scripts.length === 0)
|
|
76
|
+
return [];
|
|
77
|
+
const directives = new Map();
|
|
78
|
+
for (const part of csp.split(";")) {
|
|
79
|
+
const tokens = part.trim().split(/\s+/).filter(Boolean);
|
|
80
|
+
const name = tokens.shift()?.toLowerCase();
|
|
81
|
+
if (name && !directives.has(name))
|
|
82
|
+
directives.set(name, tokens);
|
|
83
|
+
}
|
|
84
|
+
const sources = directives.get("script-src-elem") ??
|
|
85
|
+
directives.get("script-src") ??
|
|
86
|
+
directives.get("default-src");
|
|
87
|
+
if (!sources)
|
|
88
|
+
return [];
|
|
89
|
+
// `'strict-dynamic'` makes host allowlists inert in ways that depend on how the script was
|
|
90
|
+
// reached. Not decidable from the markup alone, so it is permitted — see the header.
|
|
91
|
+
if (sources.some((s) => s.toLowerCase() === "'strict-dynamic'"))
|
|
92
|
+
return [];
|
|
93
|
+
const self = new URL(pageUrl).origin;
|
|
94
|
+
return scripts.filter((script) => !sources.some((source) => sourceAdmits(source, script.origin, self)));
|
|
95
|
+
}
|
|
96
|
+
/** Does one CSP source expression admit `origin`? Unparseable ⇒ yes, deliberately. */
|
|
97
|
+
function sourceAdmits(source, origin, self) {
|
|
98
|
+
const value = source.trim();
|
|
99
|
+
if (!value)
|
|
100
|
+
return false;
|
|
101
|
+
if (value === "*")
|
|
102
|
+
return true;
|
|
103
|
+
if (value.startsWith("'")) {
|
|
104
|
+
const keyword = value.toLowerCase();
|
|
105
|
+
if (keyword === "'self'")
|
|
106
|
+
return origin === self;
|
|
107
|
+
// `'none'`, `'unsafe-inline'`, `'nonce-…'`, `'sha256-…'`, `'report-sample'`: none of
|
|
108
|
+
// them admits a host, and `'none'` in particular must not.
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
let target;
|
|
112
|
+
try {
|
|
113
|
+
target = new URL(origin);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
// A bare scheme source — `https:`, `data:`.
|
|
119
|
+
if (/^[a-z][a-z0-9+.-]*:$/i.test(value))
|
|
120
|
+
return value.toLowerCase() === target.protocol;
|
|
121
|
+
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
|
|
122
|
+
let rest = value;
|
|
123
|
+
if (withScheme) {
|
|
124
|
+
if (withScheme[0].toLowerCase() !== `${target.protocol}//`)
|
|
125
|
+
return false;
|
|
126
|
+
rest = value.slice(withScheme[0].length);
|
|
127
|
+
}
|
|
128
|
+
// Port and path narrow a source; ignoring them can only ever ADMIT more, which is the
|
|
129
|
+
// direction this file errs in.
|
|
130
|
+
const host = rest.split("/")[0]?.split(":")[0]?.toLowerCase() ?? "";
|
|
131
|
+
if (!host)
|
|
132
|
+
return true;
|
|
133
|
+
if (host === "*")
|
|
134
|
+
return true;
|
|
135
|
+
if (host.startsWith("*."))
|
|
136
|
+
return target.hostname.toLowerCase().endsWith(host.slice(1));
|
|
137
|
+
return target.hostname.toLowerCase() === host;
|
|
138
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Web Analytics — ONE definition of the beacon, for every app on the fleet.
|
|
3
|
+
*
|
|
4
|
+
* ## 🔴 Why this is here and not in each app's `index.html` alone
|
|
5
|
+
*
|
|
6
|
+
* Until 2026-09-18 the zone carried **Automatic Setup**: Cloudflare rewrote every HTML
|
|
7
|
+
* response leaving `cursedalchemy.com` and appended the beacon tag. Nothing in any repo
|
|
8
|
+
* asked for it, nothing in any repo could see it locally, and it arrived on pages that
|
|
9
|
+
* deliberately refuse third-party script — including the master-lock wall, where a master
|
|
10
|
+
* password is typed. The owner read a console during an outage on 2026-09-17 and the FIRST
|
|
11
|
+
* line was that tag being refused; it broke nothing and cost an agent the first minutes of
|
|
12
|
+
* the incident.
|
|
13
|
+
*
|
|
14
|
+
* His decision, 2026-09-18, in his words: *"do your recommendation with option c but not on
|
|
15
|
+
* the wall. If we can stop the console error that would be good since we are purposely
|
|
16
|
+
* blocking on that page and it makes it look like there is a mistake when it shows."* — and,
|
|
17
|
+
* on why the wall's CSP may not simply be loosened: *"I don't want to trust cloudflare with
|
|
18
|
+
* master password data that is typed in."*
|
|
19
|
+
*
|
|
20
|
+
* So the install method moves from automatic to MANUAL. Same site, same dashboard, same
|
|
21
|
+
* data; the difference is that the fleet now decides *which pages* carry it. Public shells
|
|
22
|
+
* do. {@link "../master-lock/guard".MASTER_LOCK_CSP the wall} does not — and under manual
|
|
23
|
+
* setup it is not injected into at all, so there is no refused script and no console line.
|
|
24
|
+
*
|
|
25
|
+
* ## 🔴 `token` is the site TOKEN, not the site TAG
|
|
26
|
+
*
|
|
27
|
+
* They are both 32 hex characters and they are not interchangeable. `site_tag`
|
|
28
|
+
* ({@link WEB_ANALYTICS_SITE_TAG}) is the id the RUM API addresses the site by;
|
|
29
|
+
* `site_token` ({@link WEB_ANALYTICS_SITE_TOKEN}) is what goes in `data-cf-beacon`. Putting
|
|
30
|
+
* the tag in the snippet does not error anywhere — the page loads, the beacon loads, and the
|
|
31
|
+
* data goes nowhere. Measured 2026-09-18 against
|
|
32
|
+
* `GET /accounts/<acct>/rum/site_info/list`, which returns both fields **and** the ready-made
|
|
33
|
+
* `snippet`; {@link WEB_ANALYTICS_TAG} is that snippet byte-for-byte, and the token in it is
|
|
34
|
+
* the same one the edge was injecting.
|
|
35
|
+
*
|
|
36
|
+
* Neither value is a secret: the token is served to every visitor inside every page's HTML,
|
|
37
|
+
* which is the whole mechanism. It is an identifier, not a credential.
|
|
38
|
+
*/
|
|
39
|
+
/** The host the beacon is fetched from — the origin a CSP would have to admit. */
|
|
40
|
+
export declare const WEB_ANALYTICS_BEACON_ORIGIN = "https://static.cloudflareinsights.com";
|
|
41
|
+
/** The beacon itself. Manual setup serves the unversioned path; the edge served a pinned one. */
|
|
42
|
+
export declare const WEB_ANALYTICS_BEACON_SRC = "https://static.cloudflareinsights.com/beacon.min.js";
|
|
43
|
+
/**
|
|
44
|
+
* The `cursedalchemy.com` site's id in the RUM API. **Not** what goes in the tag — see the
|
|
45
|
+
* module note. Kept beside the token so the next reader cannot pick the wrong one by accident.
|
|
46
|
+
*/
|
|
47
|
+
export declare const WEB_ANALYTICS_SITE_TAG = "d1bea8562544421fbd2db5f11b74d1cc";
|
|
48
|
+
/** The `cursedalchemy.com` site's `data-cf-beacon` token. This is what the tag carries. */
|
|
49
|
+
export declare const WEB_ANALYTICS_SITE_TOKEN = "bd88d63678b64bc89f9702f48846bc88";
|
|
50
|
+
/**
|
|
51
|
+
* The snippet, exactly as Cloudflare's own `site_info` endpoint spells it.
|
|
52
|
+
*
|
|
53
|
+
* 🔴 The single-quoted attributes and the space after `"token":` are Cloudflare's, not a
|
|
54
|
+
* style choice, and they are kept because this string is COMPARED against what an app's
|
|
55
|
+
* shell contains. `data-cf-beacon`'s value is JSON containing double quotes, so the attribute
|
|
56
|
+
* quoting cannot be flipped without escaping; leaving it identical to the vendor's text means
|
|
57
|
+
* a future reader can diff it against the dashboard in one glance.
|
|
58
|
+
*
|
|
59
|
+
* `type='module'` rather than `defer`: that is what the endpoint returns today and what the
|
|
60
|
+
* edge was injecting, so switching install methods changes nothing about how the page loads.
|
|
61
|
+
*/
|
|
62
|
+
export declare function webAnalyticsTag(token?: string): string;
|
|
63
|
+
/** The fleet's tag, for the one site this generation serves. */
|
|
64
|
+
export declare const WEB_ANALYTICS_TAG: string;
|
|
65
|
+
/**
|
|
66
|
+
* Does `html` carry the beacon for this site?
|
|
67
|
+
*
|
|
68
|
+
* Deliberately NOT a substring test against {@link WEB_ANALYTICS_TAG}: a shell is authored by
|
|
69
|
+
* hand and a formatter is allowed to re-wrap the tag across lines or re-order its attributes.
|
|
70
|
+
* What must be true is that the beacon's source is there AND that it is carrying the right
|
|
71
|
+
* token — the two halves that decide whether a pageview is recorded at all.
|
|
72
|
+
*/
|
|
73
|
+
export declare function htmlCarriesWebAnalytics(html: string, token?: string): boolean;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Web Analytics — ONE definition of the beacon, for every app on the fleet.
|
|
3
|
+
*
|
|
4
|
+
* ## 🔴 Why this is here and not in each app's `index.html` alone
|
|
5
|
+
*
|
|
6
|
+
* Until 2026-09-18 the zone carried **Automatic Setup**: Cloudflare rewrote every HTML
|
|
7
|
+
* response leaving `cursedalchemy.com` and appended the beacon tag. Nothing in any repo
|
|
8
|
+
* asked for it, nothing in any repo could see it locally, and it arrived on pages that
|
|
9
|
+
* deliberately refuse third-party script — including the master-lock wall, where a master
|
|
10
|
+
* password is typed. The owner read a console during an outage on 2026-09-17 and the FIRST
|
|
11
|
+
* line was that tag being refused; it broke nothing and cost an agent the first minutes of
|
|
12
|
+
* the incident.
|
|
13
|
+
*
|
|
14
|
+
* His decision, 2026-09-18, in his words: *"do your recommendation with option c but not on
|
|
15
|
+
* the wall. If we can stop the console error that would be good since we are purposely
|
|
16
|
+
* blocking on that page and it makes it look like there is a mistake when it shows."* — and,
|
|
17
|
+
* on why the wall's CSP may not simply be loosened: *"I don't want to trust cloudflare with
|
|
18
|
+
* master password data that is typed in."*
|
|
19
|
+
*
|
|
20
|
+
* So the install method moves from automatic to MANUAL. Same site, same dashboard, same
|
|
21
|
+
* data; the difference is that the fleet now decides *which pages* carry it. Public shells
|
|
22
|
+
* do. {@link "../master-lock/guard".MASTER_LOCK_CSP the wall} does not — and under manual
|
|
23
|
+
* setup it is not injected into at all, so there is no refused script and no console line.
|
|
24
|
+
*
|
|
25
|
+
* ## 🔴 `token` is the site TOKEN, not the site TAG
|
|
26
|
+
*
|
|
27
|
+
* They are both 32 hex characters and they are not interchangeable. `site_tag`
|
|
28
|
+
* ({@link WEB_ANALYTICS_SITE_TAG}) is the id the RUM API addresses the site by;
|
|
29
|
+
* `site_token` ({@link WEB_ANALYTICS_SITE_TOKEN}) is what goes in `data-cf-beacon`. Putting
|
|
30
|
+
* the tag in the snippet does not error anywhere — the page loads, the beacon loads, and the
|
|
31
|
+
* data goes nowhere. Measured 2026-09-18 against
|
|
32
|
+
* `GET /accounts/<acct>/rum/site_info/list`, which returns both fields **and** the ready-made
|
|
33
|
+
* `snippet`; {@link WEB_ANALYTICS_TAG} is that snippet byte-for-byte, and the token in it is
|
|
34
|
+
* the same one the edge was injecting.
|
|
35
|
+
*
|
|
36
|
+
* Neither value is a secret: the token is served to every visitor inside every page's HTML,
|
|
37
|
+
* which is the whole mechanism. It is an identifier, not a credential.
|
|
38
|
+
*/
|
|
39
|
+
/** The host the beacon is fetched from — the origin a CSP would have to admit. */
|
|
40
|
+
export const WEB_ANALYTICS_BEACON_ORIGIN = "https://static.cloudflareinsights.com";
|
|
41
|
+
/** The beacon itself. Manual setup serves the unversioned path; the edge served a pinned one. */
|
|
42
|
+
export const WEB_ANALYTICS_BEACON_SRC = `${WEB_ANALYTICS_BEACON_ORIGIN}/beacon.min.js`;
|
|
43
|
+
/**
|
|
44
|
+
* The `cursedalchemy.com` site's id in the RUM API. **Not** what goes in the tag — see the
|
|
45
|
+
* module note. Kept beside the token so the next reader cannot pick the wrong one by accident.
|
|
46
|
+
*/
|
|
47
|
+
export const WEB_ANALYTICS_SITE_TAG = "d1bea8562544421fbd2db5f11b74d1cc";
|
|
48
|
+
/** The `cursedalchemy.com` site's `data-cf-beacon` token. This is what the tag carries. */
|
|
49
|
+
export const WEB_ANALYTICS_SITE_TOKEN = "bd88d63678b64bc89f9702f48846bc88";
|
|
50
|
+
/**
|
|
51
|
+
* The snippet, exactly as Cloudflare's own `site_info` endpoint spells it.
|
|
52
|
+
*
|
|
53
|
+
* 🔴 The single-quoted attributes and the space after `"token":` are Cloudflare's, not a
|
|
54
|
+
* style choice, and they are kept because this string is COMPARED against what an app's
|
|
55
|
+
* shell contains. `data-cf-beacon`'s value is JSON containing double quotes, so the attribute
|
|
56
|
+
* quoting cannot be flipped without escaping; leaving it identical to the vendor's text means
|
|
57
|
+
* a future reader can diff it against the dashboard in one glance.
|
|
58
|
+
*
|
|
59
|
+
* `type='module'` rather than `defer`: that is what the endpoint returns today and what the
|
|
60
|
+
* edge was injecting, so switching install methods changes nothing about how the page loads.
|
|
61
|
+
*/
|
|
62
|
+
export function webAnalyticsTag(token = WEB_ANALYTICS_SITE_TOKEN) {
|
|
63
|
+
return ("<!-- Cloudflare Web Analytics -->" +
|
|
64
|
+
`<script type='module' src='${WEB_ANALYTICS_BEACON_SRC}' ` +
|
|
65
|
+
`data-cf-beacon='{"token": "${token}"}'></script>` +
|
|
66
|
+
"<!-- End Cloudflare Web Analytics -->");
|
|
67
|
+
}
|
|
68
|
+
/** The fleet's tag, for the one site this generation serves. */
|
|
69
|
+
export const WEB_ANALYTICS_TAG = webAnalyticsTag();
|
|
70
|
+
/**
|
|
71
|
+
* Does `html` carry the beacon for this site?
|
|
72
|
+
*
|
|
73
|
+
* Deliberately NOT a substring test against {@link WEB_ANALYTICS_TAG}: a shell is authored by
|
|
74
|
+
* hand and a formatter is allowed to re-wrap the tag across lines or re-order its attributes.
|
|
75
|
+
* What must be true is that the beacon's source is there AND that it is carrying the right
|
|
76
|
+
* token — the two halves that decide whether a pageview is recorded at all.
|
|
77
|
+
*/
|
|
78
|
+
export function htmlCarriesWebAnalytics(html, token = WEB_ANALYTICS_SITE_TOKEN) {
|
|
79
|
+
return html.includes(WEB_ANALYTICS_BEACON_SRC) && html.includes(token);
|
|
80
|
+
}
|
|
@@ -46,6 +46,20 @@ export interface MasterLockGuardOptions extends LockPageOptions {
|
|
|
46
46
|
*/
|
|
47
47
|
secure?: (url: URL, req: Request) => boolean;
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* The lock page's own CSP. Strict enough to stand in front of `apps/vault`'s wall without
|
|
51
|
+
* loosening it: nothing loads from anywhere but this origin, the form posts nowhere (the
|
|
52
|
+
* script does the POST), and the page cannot be framed.
|
|
53
|
+
*
|
|
54
|
+
* 🔴 **EXPORTED so that nothing has to copy it.** `apps/collections` had a hand-typed
|
|
55
|
+
* duplicate of this string in a test until 2026-09-18, which is a policy that can drift
|
|
56
|
+
* without anything going red. It is also what
|
|
57
|
+
* `beaconNeverReachesTheWall.spec.ts` measures the beacon against: the wall refuses
|
|
58
|
+
* `static.cloudflareinsights.com`, so the analytics tag and this response are mutually
|
|
59
|
+
* exclusive BY CONSTRUCTION rather than by anyone remembering. The owner's reason, 2026-09-18:
|
|
60
|
+
* *"I don't want to trust cloudflare with master password data that is typed in."*
|
|
61
|
+
*/
|
|
62
|
+
export declare const MASTER_LOCK_CSP: string;
|
|
49
63
|
export interface MasterLockGuard {
|
|
50
64
|
/** `Response` when handled, `null` when the app should serve the request. */
|
|
51
65
|
handle(req: Request): Promise<Response | null>;
|
|
@@ -19,8 +19,16 @@ const noStore = { "cache-control": "no-store, no-cache, must-revalidate", pragma
|
|
|
19
19
|
* The lock page's own CSP. Strict enough to stand in front of `apps/vault`'s wall without
|
|
20
20
|
* loosening it: nothing loads from anywhere but this origin, the form posts nowhere (the
|
|
21
21
|
* script does the POST), and the page cannot be framed.
|
|
22
|
+
*
|
|
23
|
+
* 🔴 **EXPORTED so that nothing has to copy it.** `apps/collections` had a hand-typed
|
|
24
|
+
* duplicate of this string in a test until 2026-09-18, which is a policy that can drift
|
|
25
|
+
* without anything going red. It is also what
|
|
26
|
+
* `beaconNeverReachesTheWall.spec.ts` measures the beacon against: the wall refuses
|
|
27
|
+
* `static.cloudflareinsights.com`, so the analytics tag and this response are mutually
|
|
28
|
+
* exclusive BY CONSTRUCTION rather than by anyone remembering. The owner's reason, 2026-09-18:
|
|
29
|
+
* *"I don't want to trust cloudflare with master password data that is typed in."*
|
|
22
30
|
*/
|
|
23
|
-
const
|
|
31
|
+
export const MASTER_LOCK_CSP = "default-src 'none'; style-src 'self'; script-src 'self'; connect-src 'self'; " +
|
|
24
32
|
"img-src data:; form-action 'none'; base-uri 'none'; frame-ancestors 'none'";
|
|
25
33
|
export function createMasterLockGuard(options) {
|
|
26
34
|
const resolve = typeof options.lock === "function" ? options.lock : () => options.lock;
|
|
@@ -256,7 +264,7 @@ function lockPage(options, lock) {
|
|
|
256
264
|
status: 200,
|
|
257
265
|
headers: {
|
|
258
266
|
"content-type": "text/html; charset=utf-8",
|
|
259
|
-
"content-security-policy":
|
|
267
|
+
"content-security-policy": MASTER_LOCK_CSP,
|
|
260
268
|
"x-content-type-options": "nosniff",
|
|
261
269
|
[MASTER_LOCK_STATE_HEADER]: "locked",
|
|
262
270
|
...noStore,
|
|
@@ -275,7 +283,7 @@ function accountsPage(options) {
|
|
|
275
283
|
status: 200,
|
|
276
284
|
headers: {
|
|
277
285
|
"content-type": "text/html; charset=utf-8",
|
|
278
|
-
"content-security-policy":
|
|
286
|
+
"content-security-policy": MASTER_LOCK_CSP,
|
|
279
287
|
"x-content-type-options": "nosniff",
|
|
280
288
|
...noStore,
|
|
281
289
|
},
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* bundle, no framework version to migrate, and no app-local copy to drift.
|
|
22
22
|
*/
|
|
23
23
|
export { MASTER_LOCK_ACCOUNTS_PAGE_PATHS, MASTER_LOCK_ACCOUNTS_SCRIPT, MASTER_LOCK_ACCOUNTS_STYLE, MASTER_LOCK_MIN_PASSWORD_LENGTH, accountsPageHtml, } from "./accountsPage";
|
|
24
|
-
export { type MasterLockGuard, type MasterLockGuardOptions, createMasterLockGuard } from "./guard";
|
|
24
|
+
export { MASTER_LOCK_CSP, type MasterLockGuard, type MasterLockGuardOptions, createMasterLockGuard, } from "./guard";
|
|
25
25
|
export { LOCK_SCRIPT, LOCK_STYLE, MASTER_LOCK_DERIVE_SOURCE, type LockPageOptions, lockPageHtml, } from "./lockPage";
|
|
26
26
|
export { MASTER_LOCK_PRINCIPAL_KEY_PREFIX, MasterLockDirectory, type MasterLockDirectoryOptions, masterLockPrincipalKey, } from "./principals";
|
|
27
27
|
export { FIRST_ACCOUNT_ID, MasterLock, type MasterLockAccount, type MasterLockAddAccountResult, type MasterLockEnrollResult, type MasterLockOptions, type MasterLockRecord, type MasterLockStore, type MasterLockUnlockResult, readRecord, } from "./masterLock";
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* bundle, no framework version to migrate, and no app-local copy to drift.
|
|
22
22
|
*/
|
|
23
23
|
export { MASTER_LOCK_ACCOUNTS_PAGE_PATHS, MASTER_LOCK_ACCOUNTS_SCRIPT, MASTER_LOCK_ACCOUNTS_STYLE, MASTER_LOCK_MIN_PASSWORD_LENGTH, accountsPageHtml, } from "./accountsPage";
|
|
24
|
-
export { createMasterLockGuard } from "./guard";
|
|
24
|
+
export { MASTER_LOCK_CSP, createMasterLockGuard, } from "./guard";
|
|
25
25
|
export { LOCK_SCRIPT, LOCK_STYLE, MASTER_LOCK_DERIVE_SOURCE, lockPageHtml, } from "./lockPage";
|
|
26
26
|
export { MASTER_LOCK_PRINCIPAL_KEY_PREFIX, MasterLockDirectory, masterLockPrincipalKey, } from "./principals";
|
|
27
27
|
export { FIRST_ACCOUNT_ID, MasterLock, readRecord, } from "./masterLock";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The app-facing Bun/Hono server tier of the cursedbelt split \u2014 storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
|
|
@@ -48,6 +48,12 @@
|
|
|
48
48
|
"source": "./src/server/context.ts",
|
|
49
49
|
"import": "./dist/server/context.js"
|
|
50
50
|
},
|
|
51
|
+
"./analytics": {
|
|
52
|
+
"types": "./dist/server/analytics/index.d.ts",
|
|
53
|
+
"bun": "./src/server/analytics/index.ts",
|
|
54
|
+
"source": "./src/server/analytics/index.ts",
|
|
55
|
+
"import": "./dist/server/analytics/index.js"
|
|
56
|
+
},
|
|
51
57
|
"./bench": {
|
|
52
58
|
"types": "./dist/server/bench/index.d.ts",
|
|
53
59
|
"bun": "./src/server/bench/index.ts",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt-server/analytics` — where the fleet's web-analytics beacon is DEFINED, and the
|
|
3
|
+
* invariant that decides which responses may carry it.
|
|
4
|
+
*
|
|
5
|
+
* Two files, and the pairing is the point:
|
|
6
|
+
*
|
|
7
|
+
* · `webAnalytics.ts` is the one copy of the tag. An app's public shell pastes it; nothing
|
|
8
|
+
* generates ten of them.
|
|
9
|
+
* · `injectedScripts.ts` is the general rule — nothing that arrives in a response's HTML may
|
|
10
|
+
* be refused by the CSP on that same response — which is what makes "the wall never carries
|
|
11
|
+
* the beacon" a CHECK rather than a sentence. `../master-lock/beaconNeverReachesTheWall.spec.ts`
|
|
12
|
+
* is that check.
|
|
13
|
+
*
|
|
14
|
+
* Both are pure string work with no imports at all, so this subpath costs a consumer nothing
|
|
15
|
+
* and drags no peer.
|
|
16
|
+
*/
|
|
17
|
+
export {
|
|
18
|
+
type InjectedScript,
|
|
19
|
+
foreignScriptSources,
|
|
20
|
+
scriptsRefusedByCsp,
|
|
21
|
+
} from "./injectedScripts";
|
|
22
|
+
export {
|
|
23
|
+
WEB_ANALYTICS_BEACON_ORIGIN,
|
|
24
|
+
WEB_ANALYTICS_BEACON_SRC,
|
|
25
|
+
WEB_ANALYTICS_SITE_TAG,
|
|
26
|
+
WEB_ANALYTICS_SITE_TOKEN,
|
|
27
|
+
WEB_ANALYTICS_TAG,
|
|
28
|
+
htmlCarriesWebAnalytics,
|
|
29
|
+
webAnalyticsTag,
|
|
30
|
+
} from "./webAnalytics";
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { foreignScriptSources, scriptsRefusedByCsp } from "./injectedScripts";
|
|
3
|
+
import { WEB_ANALYTICS_BEACON_ORIGIN, WEB_ANALYTICS_TAG } from "./webAnalytics";
|
|
4
|
+
|
|
5
|
+
const PAGE = "https://collections.cursedalchemy.com/";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The tag Cloudflare's Automatic Setup appended, byte-for-byte as the zone emitted it on
|
|
9
|
+
* 2026-09-18 — pinned path, integrity hash, `crossorigin` and all. It is kept in its
|
|
10
|
+
* AUTOMATIC spelling on purpose: this is the shape that was arriving unasked-for, and a
|
|
11
|
+
* checker that only recognised the manual snippet would go quiet the moment somebody turned
|
|
12
|
+
* auto-install back on.
|
|
13
|
+
*/
|
|
14
|
+
const BEACON =
|
|
15
|
+
`<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js/v31edd6df95cf4e85bb4c19e7a9bdbcba1788362987495"` +
|
|
16
|
+
` integrity="sha512-iIg7k2xntmwu6/uSb5tpc/hySgZc4eoL31yB29W6tJFo2akwjPWcEqnCEdJvGexCL0KEQwVYv5BlowfhVz26hg=="` +
|
|
17
|
+
` data-cf-beacon='{"version":"2024.11.0","token":"bd88d63678b64bc89f9702f48846bc88","r":1,"spa":2}' crossorigin="anonymous"></script>`;
|
|
18
|
+
|
|
19
|
+
/** A real app shell: one inline script, one same-origin module. Neither is foreign. */
|
|
20
|
+
const SHELL = `<!doctype html><html><head>
|
|
21
|
+
<script>try { document.documentElement.classList.toggle("dark", true); } catch (e) {}</script>
|
|
22
|
+
<script type="module" crossorigin src="/assets/index-edXXbfEe.js"></script>
|
|
23
|
+
</head><body><div id="root"></div></body></html>`;
|
|
24
|
+
|
|
25
|
+
describe("foreignScriptSources", () => {
|
|
26
|
+
test("an app's own shell has none — inline and same-origin are not findings", () => {
|
|
27
|
+
expect(foreignScriptSources(SHELL, PAGE)).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("finds the beacon the edge appended, single-quoted attribute and all", () => {
|
|
31
|
+
const found = foreignScriptSources(SHELL.replace("</head>", `${BEACON}</head>`), PAGE);
|
|
32
|
+
expect(found).toHaveLength(1);
|
|
33
|
+
expect(found[0]?.origin).toBe(WEB_ANALYTICS_BEACON_ORIGIN);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("the MANUAL snippet is found too, and names the same origin", () => {
|
|
37
|
+
const found = foreignScriptSources(SHELL.replace("</head>", `${WEB_ANALYTICS_TAG}</head>`), PAGE);
|
|
38
|
+
expect(found).toHaveLength(1);
|
|
39
|
+
expect(found[0]?.origin).toBe(WEB_ANALYTICS_BEACON_ORIGIN);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("an absolute URL back to this app's own origin is not foreign", () => {
|
|
43
|
+
const html = `<script src="https://collections.cursedalchemy.com/assets/x.js"></script>`;
|
|
44
|
+
expect(foreignScriptSources(html, PAGE)).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("data: and blob: sources are skipped — no host was injected", () => {
|
|
48
|
+
const html = `<script src="data:text/javascript,void 0"></script><script src=blob:https://x/y></script>`;
|
|
49
|
+
expect(foreignScriptSources(html, PAGE)).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("two tags naming the same foreign origin are reported once per src", () => {
|
|
53
|
+
const html = `${BEACON}${BEACON}<script src="https://static.cloudflareinsights.com/other.js"></script>`;
|
|
54
|
+
expect(foreignScriptSources(html, PAGE)).toHaveLength(2);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("scriptsRefusedByCsp", () => {
|
|
59
|
+
const beacon = foreignScriptSources(BEACON, PAGE);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 🔴 The failure path this whole file exists for — the owner's console line, turned into a
|
|
63
|
+
* refusal. `script-src 'self'` with something injecting is exactly what he pasted.
|
|
64
|
+
*/
|
|
65
|
+
test("script-src 'self' refuses the injected beacon and names its origin", () => {
|
|
66
|
+
const refused = scriptsRefusedByCsp(
|
|
67
|
+
"default-src 'none'; style-src 'self'; script-src 'self'; connect-src 'self'; " +
|
|
68
|
+
"img-src data:; form-action 'none'; base-uri 'none'; frame-ancestors 'none'",
|
|
69
|
+
beacon,
|
|
70
|
+
PAGE,
|
|
71
|
+
);
|
|
72
|
+
expect(refused.map((s) => s.origin)).toEqual([WEB_ANALYTICS_BEACON_ORIGIN]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("allowing the host in script-src clears it", () => {
|
|
76
|
+
const csp = "default-src 'none'; script-src 'self' https://static.cloudflareinsights.com";
|
|
77
|
+
expect(scriptsRefusedByCsp(csp, beacon, PAGE)).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a wildcard subdomain source clears it", () => {
|
|
81
|
+
expect(scriptsRefusedByCsp("script-src 'self' *.cloudflareinsights.com", beacon, PAGE)).toEqual(
|
|
82
|
+
[],
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("no CSP header at all refuses nothing — today's public shell", () => {
|
|
87
|
+
expect(scriptsRefusedByCsp(null, beacon, PAGE)).toEqual([]);
|
|
88
|
+
expect(scriptsRefusedByCsp("", beacon, PAGE)).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a CSP with no script directive and no default-src refuses nothing", () => {
|
|
92
|
+
expect(scriptsRefusedByCsp("frame-ancestors 'none'; base-uri 'none'", beacon, PAGE)).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("default-src is the fallback when script-src is absent", () => {
|
|
96
|
+
expect(scriptsRefusedByCsp("default-src 'self'", beacon, PAGE)).toHaveLength(1);
|
|
97
|
+
expect(scriptsRefusedByCsp("default-src https:", beacon, PAGE)).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("script-src-elem wins over script-src, which wins over default-src", () => {
|
|
101
|
+
const csp =
|
|
102
|
+
"default-src 'none'; script-src 'self'; script-src-elem https://static.cloudflareinsights.com";
|
|
103
|
+
expect(scriptsRefusedByCsp(csp, beacon, PAGE)).toEqual([]);
|
|
104
|
+
expect(scriptsRefusedByCsp("default-src https:; script-src 'self'", beacon, PAGE)).toHaveLength(
|
|
105
|
+
1,
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("'none' admits nothing, even alongside a scheme in another directive", () => {
|
|
110
|
+
expect(scriptsRefusedByCsp("script-src 'none'", beacon, PAGE)).toHaveLength(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("a scheme source only matches its own scheme", () => {
|
|
114
|
+
expect(scriptsRefusedByCsp("script-src http:", beacon, PAGE)).toHaveLength(1);
|
|
115
|
+
expect(scriptsRefusedByCsp("script-src https:", beacon, PAGE)).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("an http:// host source does not admit an https origin", () => {
|
|
119
|
+
expect(
|
|
120
|
+
scriptsRefusedByCsp("script-src http://static.cloudflareinsights.com", beacon, PAGE),
|
|
121
|
+
).toHaveLength(1);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("a port or path on the source narrows nothing — the conservative direction", () => {
|
|
125
|
+
const csp = "script-src https://static.cloudflareinsights.com:443/beacon.min.js";
|
|
126
|
+
expect(scriptsRefusedByCsp(csp, beacon, PAGE)).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("'strict-dynamic' is undecidable from markup, so it permits", () => {
|
|
130
|
+
expect(scriptsRefusedByCsp("script-src 'self' 'strict-dynamic' 'nonce-abc'", beacon, PAGE)).toEqual(
|
|
131
|
+
[],
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("* permits, and 'self' still matches this app's own origin", () => {
|
|
136
|
+
expect(scriptsRefusedByCsp("script-src *", beacon, PAGE)).toEqual([]);
|
|
137
|
+
const own = [{ src: "/assets/x.js", origin: "https://collections.cursedalchemy.com" }];
|
|
138
|
+
expect(scriptsRefusedByCsp("script-src 'self'", own, PAGE)).toEqual([]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("nothing injected means nothing refused, however strict the policy", () => {
|
|
142
|
+
expect(scriptsRefusedByCsp("script-src 'none'", [], PAGE)).toEqual([]);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does a page's own CSP refuse a script that arrived in that page's own HTML?
|
|
3
|
+
*
|
|
4
|
+
* ## The invariant, which names no vendor
|
|
5
|
+
*
|
|
6
|
+
* > No script that reaches the browser inside a response's HTML may be refused by the CSP on
|
|
7
|
+
* > that same response.
|
|
8
|
+
*
|
|
9
|
+
* Every part of the arrangement may move and this stays right. Turn an edge injection off and
|
|
10
|
+
* the foreign scripts disappear — green. Decide a third-party script is wanted and allow its
|
|
11
|
+
* host — green. Harden a page with `script-src 'self'` while something is still injecting into
|
|
12
|
+
* it — RED, and it names the origin, which is the console line the owner would otherwise find
|
|
13
|
+
* himself during an incident.
|
|
14
|
+
*
|
|
15
|
+
* ## 🔴 Why it lives in this package
|
|
16
|
+
*
|
|
17
|
+
* It was written in `apps/collections` on 2026-09-18 and it was right there, but it could only
|
|
18
|
+
* ever see one app — and the response it was written about, the master-lock wall, is served by
|
|
19
|
+
* THIS package to every app that mounts the guard. One copy here is what makes the two halves
|
|
20
|
+
* of `webAnalytics.ts`'s decision checkable in the same place: the wall refuses the beacon, and
|
|
21
|
+
* a public shell carries it.
|
|
22
|
+
*
|
|
23
|
+
* ## 🔴 Conservative on purpose
|
|
24
|
+
*
|
|
25
|
+
* Every ambiguity resolves to PERMITTED. A source expression this file cannot parse, a
|
|
26
|
+
* `'strict-dynamic'` that changes what host lists even mean, a CSP with no script directive at
|
|
27
|
+
* all — all of them pass. A deployed smoke decides whether an app's `scripts/deploy.ts` rolls
|
|
28
|
+
* back; a false red there discards good work over a CSP grammar corner, so the only thing that
|
|
29
|
+
* fails here is a script with NO source expression that could admit it.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** An absolute script URL found in the HTML, with the origin already resolved. */
|
|
33
|
+
export interface InjectedScript {
|
|
34
|
+
/** Exactly as it appeared in the markup, for the failure message. */
|
|
35
|
+
src: string;
|
|
36
|
+
/** `https://static.cloudflareinsights.com`, resolved against the page's own URL. */
|
|
37
|
+
origin: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Every `<script src=…>` in `html` whose origin is NOT `pageUrl`'s.
|
|
42
|
+
*
|
|
43
|
+
* Inline scripts are ignored: they are the page's own (most shells here ship one, a pre-paint
|
|
44
|
+
* colour-scheme script) and a CSP that refuses them is a different finding from this one.
|
|
45
|
+
* `data:` and `blob:` are ignored for the same reason — neither is a host anything could have
|
|
46
|
+
* injected.
|
|
47
|
+
*/
|
|
48
|
+
export function foreignScriptSources(html: string, pageUrl: string): InjectedScript[] {
|
|
49
|
+
const page = new URL(pageUrl);
|
|
50
|
+
const found: InjectedScript[] = [];
|
|
51
|
+
const seen = new Set<string>();
|
|
52
|
+
const tag = /<script\b[^>]*?\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/gi;
|
|
53
|
+
for (const match of html.matchAll(tag)) {
|
|
54
|
+
const raw = (match[1] ?? match[2] ?? match[3] ?? "").trim();
|
|
55
|
+
if (!raw) continue;
|
|
56
|
+
let origin: string;
|
|
57
|
+
try {
|
|
58
|
+
const url = new URL(raw, page);
|
|
59
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") continue;
|
|
60
|
+
origin = url.origin;
|
|
61
|
+
} catch {
|
|
62
|
+
continue; // not a URL this check can reason about — see the conservative note
|
|
63
|
+
}
|
|
64
|
+
if (origin === page.origin) continue;
|
|
65
|
+
if (seen.has(origin + raw)) continue;
|
|
66
|
+
seen.add(origin + raw);
|
|
67
|
+
found.push({ src: raw, origin });
|
|
68
|
+
}
|
|
69
|
+
return found;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Which of `scripts` the `content-security-policy` header would refuse.
|
|
74
|
+
*
|
|
75
|
+
* `null`/absent/empty header means no policy, which refuses nothing. The directive consulted
|
|
76
|
+
* is `script-src-elem`, then `script-src`, then `default-src` — the browser's own fallback
|
|
77
|
+
* order for a `<script src>` element.
|
|
78
|
+
*/
|
|
79
|
+
export function scriptsRefusedByCsp(
|
|
80
|
+
csp: string | null | undefined,
|
|
81
|
+
scripts: InjectedScript[],
|
|
82
|
+
pageUrl: string,
|
|
83
|
+
): InjectedScript[] {
|
|
84
|
+
if (!csp?.trim() || scripts.length === 0) return [];
|
|
85
|
+
const directives = new Map<string, string[]>();
|
|
86
|
+
for (const part of csp.split(";")) {
|
|
87
|
+
const tokens = part.trim().split(/\s+/).filter(Boolean);
|
|
88
|
+
const name = tokens.shift()?.toLowerCase();
|
|
89
|
+
if (name && !directives.has(name)) directives.set(name, tokens);
|
|
90
|
+
}
|
|
91
|
+
const sources =
|
|
92
|
+
directives.get("script-src-elem") ??
|
|
93
|
+
directives.get("script-src") ??
|
|
94
|
+
directives.get("default-src");
|
|
95
|
+
if (!sources) return [];
|
|
96
|
+
// `'strict-dynamic'` makes host allowlists inert in ways that depend on how the script was
|
|
97
|
+
// reached. Not decidable from the markup alone, so it is permitted — see the header.
|
|
98
|
+
if (sources.some((s) => s.toLowerCase() === "'strict-dynamic'")) return [];
|
|
99
|
+
const self = new URL(pageUrl).origin;
|
|
100
|
+
return scripts.filter(
|
|
101
|
+
(script) => !sources.some((source) => sourceAdmits(source, script.origin, self)),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Does one CSP source expression admit `origin`? Unparseable ⇒ yes, deliberately. */
|
|
106
|
+
function sourceAdmits(source: string, origin: string, self: string): boolean {
|
|
107
|
+
const value = source.trim();
|
|
108
|
+
if (!value) return false;
|
|
109
|
+
if (value === "*") return true;
|
|
110
|
+
if (value.startsWith("'")) {
|
|
111
|
+
const keyword = value.toLowerCase();
|
|
112
|
+
if (keyword === "'self'") return origin === self;
|
|
113
|
+
// `'none'`, `'unsafe-inline'`, `'nonce-…'`, `'sha256-…'`, `'report-sample'`: none of
|
|
114
|
+
// them admits a host, and `'none'` in particular must not.
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
let target: URL;
|
|
118
|
+
try {
|
|
119
|
+
target = new URL(origin);
|
|
120
|
+
} catch {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
// A bare scheme source — `https:`, `data:`.
|
|
124
|
+
if (/^[a-z][a-z0-9+.-]*:$/i.test(value)) return value.toLowerCase() === target.protocol;
|
|
125
|
+
|
|
126
|
+
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
|
|
127
|
+
let rest = value;
|
|
128
|
+
if (withScheme) {
|
|
129
|
+
if (withScheme[0].toLowerCase() !== `${target.protocol}//`) return false;
|
|
130
|
+
rest = value.slice(withScheme[0].length);
|
|
131
|
+
}
|
|
132
|
+
// Port and path narrow a source; ignoring them can only ever ADMIT more, which is the
|
|
133
|
+
// direction this file errs in.
|
|
134
|
+
const host = rest.split("/")[0]?.split(":")[0]?.toLowerCase() ?? "";
|
|
135
|
+
if (!host) return true;
|
|
136
|
+
if (host === "*") return true;
|
|
137
|
+
if (host.startsWith("*.")) return target.hostname.toLowerCase().endsWith(host.slice(1));
|
|
138
|
+
return target.hostname.toLowerCase() === host;
|
|
139
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
WEB_ANALYTICS_BEACON_ORIGIN,
|
|
4
|
+
WEB_ANALYTICS_BEACON_SRC,
|
|
5
|
+
WEB_ANALYTICS_SITE_TAG,
|
|
6
|
+
WEB_ANALYTICS_SITE_TOKEN,
|
|
7
|
+
WEB_ANALYTICS_TAG,
|
|
8
|
+
htmlCarriesWebAnalytics,
|
|
9
|
+
webAnalyticsTag,
|
|
10
|
+
} from "./webAnalytics";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 🔴 The snippet, as Cloudflare's own `GET /accounts/<acct>/rum/site_info/list` returned it on
|
|
14
|
+
* 2026-09-18 for `cursedalchemy.com`, unescaped. Pinning it byte-for-byte is the point: this
|
|
15
|
+
* is the thing a shell pastes, and the day it drifts from the vendor's text is a day nobody
|
|
16
|
+
* would otherwise notice until a dashboard stopped filling.
|
|
17
|
+
*/
|
|
18
|
+
const FROM_CLOUDFLARE =
|
|
19
|
+
`<!-- Cloudflare Web Analytics --><script type='module' src='https://static.cloudflareinsights.com/beacon.min.js' ` +
|
|
20
|
+
`data-cf-beacon='{"token": "bd88d63678b64bc89f9702f48846bc88"}'></script><!-- End Cloudflare Web Analytics -->`;
|
|
21
|
+
|
|
22
|
+
describe("the fleet's beacon", () => {
|
|
23
|
+
test("is exactly the snippet Cloudflare hands out for this site", () => {
|
|
24
|
+
expect(WEB_ANALYTICS_TAG).toBe(FROM_CLOUDFLARE);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("carries the site TOKEN, never the site TAG", () => {
|
|
28
|
+
// Both are 32 hex characters and swapping them errors nowhere — the page loads, the
|
|
29
|
+
// beacon loads, and the pageview is recorded against nothing.
|
|
30
|
+
expect(WEB_ANALYTICS_TAG).toContain(WEB_ANALYTICS_SITE_TOKEN);
|
|
31
|
+
expect(WEB_ANALYTICS_TAG).not.toContain(WEB_ANALYTICS_SITE_TAG);
|
|
32
|
+
expect(WEB_ANALYTICS_SITE_TAG).not.toBe(WEB_ANALYTICS_SITE_TOKEN);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("the src is under the origin a CSP would have to admit", () => {
|
|
36
|
+
expect(WEB_ANALYTICS_BEACON_SRC.startsWith(`${WEB_ANALYTICS_BEACON_ORIGIN}/`)).toBe(true);
|
|
37
|
+
expect(new URL(WEB_ANALYTICS_BEACON_SRC).origin).toBe(WEB_ANALYTICS_BEACON_ORIGIN);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("another site's token produces that site's tag and nothing else changes", () => {
|
|
41
|
+
const other = webAnalyticsTag("0".repeat(32));
|
|
42
|
+
expect(other).toContain(WEB_ANALYTICS_BEACON_SRC);
|
|
43
|
+
expect(other).not.toContain(WEB_ANALYTICS_SITE_TOKEN);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("htmlCarriesWebAnalytics", () => {
|
|
48
|
+
test("true for the tag as authored, and for a re-wrapped copy of it", () => {
|
|
49
|
+
expect(htmlCarriesWebAnalytics(`<head>${WEB_ANALYTICS_TAG}</head>`)).toBe(true);
|
|
50
|
+
// A formatter is allowed to break the attributes across lines; the check must survive it.
|
|
51
|
+
const rewrapped =
|
|
52
|
+
`<script\n\ttype="module"\n\tsrc="${WEB_ANALYTICS_BEACON_SRC}"\n` +
|
|
53
|
+
`\tdata-cf-beacon='{"token": "${WEB_ANALYTICS_SITE_TOKEN}"}'\n></script>`;
|
|
54
|
+
expect(htmlCarriesWebAnalytics(rewrapped)).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("false for a shell with no beacon at all", () => {
|
|
58
|
+
expect(htmlCarriesWebAnalytics("<head><title>Vault</title></head>")).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("false when the src is right but the token is somebody else's", () => {
|
|
62
|
+
expect(htmlCarriesWebAnalytics(webAnalyticsTag("0".repeat(32)))).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("false when the token is right but the src is not the beacon", () => {
|
|
66
|
+
expect(htmlCarriesWebAnalytics(`<!-- ${WEB_ANALYTICS_SITE_TOKEN} -->`)).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Web Analytics — ONE definition of the beacon, for every app on the fleet.
|
|
3
|
+
*
|
|
4
|
+
* ## 🔴 Why this is here and not in each app's `index.html` alone
|
|
5
|
+
*
|
|
6
|
+
* Until 2026-09-18 the zone carried **Automatic Setup**: Cloudflare rewrote every HTML
|
|
7
|
+
* response leaving `cursedalchemy.com` and appended the beacon tag. Nothing in any repo
|
|
8
|
+
* asked for it, nothing in any repo could see it locally, and it arrived on pages that
|
|
9
|
+
* deliberately refuse third-party script — including the master-lock wall, where a master
|
|
10
|
+
* password is typed. The owner read a console during an outage on 2026-09-17 and the FIRST
|
|
11
|
+
* line was that tag being refused; it broke nothing and cost an agent the first minutes of
|
|
12
|
+
* the incident.
|
|
13
|
+
*
|
|
14
|
+
* His decision, 2026-09-18, in his words: *"do your recommendation with option c but not on
|
|
15
|
+
* the wall. If we can stop the console error that would be good since we are purposely
|
|
16
|
+
* blocking on that page and it makes it look like there is a mistake when it shows."* — and,
|
|
17
|
+
* on why the wall's CSP may not simply be loosened: *"I don't want to trust cloudflare with
|
|
18
|
+
* master password data that is typed in."*
|
|
19
|
+
*
|
|
20
|
+
* So the install method moves from automatic to MANUAL. Same site, same dashboard, same
|
|
21
|
+
* data; the difference is that the fleet now decides *which pages* carry it. Public shells
|
|
22
|
+
* do. {@link "../master-lock/guard".MASTER_LOCK_CSP the wall} does not — and under manual
|
|
23
|
+
* setup it is not injected into at all, so there is no refused script and no console line.
|
|
24
|
+
*
|
|
25
|
+
* ## 🔴 `token` is the site TOKEN, not the site TAG
|
|
26
|
+
*
|
|
27
|
+
* They are both 32 hex characters and they are not interchangeable. `site_tag`
|
|
28
|
+
* ({@link WEB_ANALYTICS_SITE_TAG}) is the id the RUM API addresses the site by;
|
|
29
|
+
* `site_token` ({@link WEB_ANALYTICS_SITE_TOKEN}) is what goes in `data-cf-beacon`. Putting
|
|
30
|
+
* the tag in the snippet does not error anywhere — the page loads, the beacon loads, and the
|
|
31
|
+
* data goes nowhere. Measured 2026-09-18 against
|
|
32
|
+
* `GET /accounts/<acct>/rum/site_info/list`, which returns both fields **and** the ready-made
|
|
33
|
+
* `snippet`; {@link WEB_ANALYTICS_TAG} is that snippet byte-for-byte, and the token in it is
|
|
34
|
+
* the same one the edge was injecting.
|
|
35
|
+
*
|
|
36
|
+
* Neither value is a secret: the token is served to every visitor inside every page's HTML,
|
|
37
|
+
* which is the whole mechanism. It is an identifier, not a credential.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** The host the beacon is fetched from — the origin a CSP would have to admit. */
|
|
41
|
+
export const WEB_ANALYTICS_BEACON_ORIGIN = "https://static.cloudflareinsights.com";
|
|
42
|
+
|
|
43
|
+
/** The beacon itself. Manual setup serves the unversioned path; the edge served a pinned one. */
|
|
44
|
+
export const WEB_ANALYTICS_BEACON_SRC = `${WEB_ANALYTICS_BEACON_ORIGIN}/beacon.min.js`;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The `cursedalchemy.com` site's id in the RUM API. **Not** what goes in the tag — see the
|
|
48
|
+
* module note. Kept beside the token so the next reader cannot pick the wrong one by accident.
|
|
49
|
+
*/
|
|
50
|
+
export const WEB_ANALYTICS_SITE_TAG = "d1bea8562544421fbd2db5f11b74d1cc";
|
|
51
|
+
|
|
52
|
+
/** The `cursedalchemy.com` site's `data-cf-beacon` token. This is what the tag carries. */
|
|
53
|
+
export const WEB_ANALYTICS_SITE_TOKEN = "bd88d63678b64bc89f9702f48846bc88";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The snippet, exactly as Cloudflare's own `site_info` endpoint spells it.
|
|
57
|
+
*
|
|
58
|
+
* 🔴 The single-quoted attributes and the space after `"token":` are Cloudflare's, not a
|
|
59
|
+
* style choice, and they are kept because this string is COMPARED against what an app's
|
|
60
|
+
* shell contains. `data-cf-beacon`'s value is JSON containing double quotes, so the attribute
|
|
61
|
+
* quoting cannot be flipped without escaping; leaving it identical to the vendor's text means
|
|
62
|
+
* a future reader can diff it against the dashboard in one glance.
|
|
63
|
+
*
|
|
64
|
+
* `type='module'` rather than `defer`: that is what the endpoint returns today and what the
|
|
65
|
+
* edge was injecting, so switching install methods changes nothing about how the page loads.
|
|
66
|
+
*/
|
|
67
|
+
export function webAnalyticsTag(token: string = WEB_ANALYTICS_SITE_TOKEN): string {
|
|
68
|
+
return (
|
|
69
|
+
"<!-- Cloudflare Web Analytics -->" +
|
|
70
|
+
`<script type='module' src='${WEB_ANALYTICS_BEACON_SRC}' ` +
|
|
71
|
+
`data-cf-beacon='{"token": "${token}"}'></script>` +
|
|
72
|
+
"<!-- End Cloudflare Web Analytics -->"
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The fleet's tag, for the one site this generation serves. */
|
|
77
|
+
export const WEB_ANALYTICS_TAG = webAnalyticsTag();
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Does `html` carry the beacon for this site?
|
|
81
|
+
*
|
|
82
|
+
* Deliberately NOT a substring test against {@link WEB_ANALYTICS_TAG}: a shell is authored by
|
|
83
|
+
* hand and a formatter is allowed to re-wrap the tag across lines or re-order its attributes.
|
|
84
|
+
* What must be true is that the beacon's source is there AND that it is carrying the right
|
|
85
|
+
* token — the two halves that decide whether a pageview is recorded at all.
|
|
86
|
+
*/
|
|
87
|
+
export function htmlCarriesWebAnalytics(
|
|
88
|
+
html: string,
|
|
89
|
+
token: string = WEB_ANALYTICS_SITE_TOKEN,
|
|
90
|
+
): boolean {
|
|
91
|
+
return html.includes(WEB_ANALYTICS_BEACON_SRC) && html.includes(token);
|
|
92
|
+
}
|
|
@@ -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,
|