@baukit/csp 0.0.0-stage → 0.19.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/LICENSE +21 -0
- package/README.md +22 -2
- package/dist/headers.d.ts +7 -0
- package/dist/headers.d.ts.map +1 -0
- package/dist/headers.js +11 -0
- package/dist/headers.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/policy.d.ts +42 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +113 -0
- package/dist/policy.js.map +1 -0
- package/package.json +47 -4
- package/src/headers.ts +10 -0
- package/src/index.ts +13 -0
- package/src/policy.test.ts +157 -0
- package/src/policy.ts +162 -0
- package/src/vectors.test.ts +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Patrick Koss
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@baukit/csp`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Content Security Policy that generated web and mobile web exports serve (ADR 0006 section 7).
|
|
4
|
+
Products keep only their origins; the directives live here, so every product gets the same policy.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import { contentSecurityPolicy, originOf, SECURITY_HEADERS } from '@baukit/csp';
|
|
8
|
+
|
|
9
|
+
const policy = contentSecurityPolicy({
|
|
10
|
+
styles: 'files',
|
|
11
|
+
origins: { api: originOf(process.env.VITE_API_URL) },
|
|
12
|
+
scriptHashes: [],
|
|
13
|
+
});
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`contentSecurityPolicy` throws on any script source that is not a `'sha256-...'` hash, and leaves
|
|
17
|
+
out `upgrade-insecure-requests` only when an origin is plain `http:`.
|
|
18
|
+
`developmentContentSecurityPolicy` adds inline styles and the HMR socket for the dev server.
|
|
19
|
+
`unhashedInlineScripts` lists inline scripts a built page carries that the policy does not hash.
|
|
20
|
+
|
|
21
|
+
`SECURITY_HEADERS` holds the headers sent next to the policy: COOP and CORP `same-origin` and
|
|
22
|
+
`nosniff`. COEP stays unset because `require-corp` blocks third-party images and analytics that do
|
|
23
|
+
not opt in.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response headers every page of a built web app carries next to its policy.
|
|
3
|
+
* COEP stays unset: `require-corp` blocks third-party images and analytics
|
|
4
|
+
* that do not send CORP themselves.
|
|
5
|
+
*/
|
|
6
|
+
export declare const SECURITY_HEADERS: Readonly<Record<string, string>>;
|
|
7
|
+
//# sourceMappingURL=headers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"headers.d.ts","sourceRoot":"","sources":["../src/headers.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAI7D,CAAC"}
|
package/dist/headers.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response headers every page of a built web app carries next to its policy.
|
|
3
|
+
* COEP stays unset: `require-corp` blocks third-party images and analytics
|
|
4
|
+
* that do not send CORP themselves.
|
|
5
|
+
*/
|
|
6
|
+
export const SECURITY_HEADERS = {
|
|
7
|
+
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
8
|
+
'Cross-Origin-Resource-Policy': 'same-origin',
|
|
9
|
+
'X-Content-Type-Options': 'nosniff',
|
|
10
|
+
};
|
|
11
|
+
//# sourceMappingURL=headers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"headers.js","sourceRoot":"","sources":["../src/headers.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAqC;IAChE,4BAA4B,EAAE,aAAa;IAC3C,8BAA8B,EAAE,aAAa;IAC7C,wBAAwB,EAAE,SAAS;CACpC,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { SECURITY_HEADERS } from './headers.js';
|
|
2
|
+
export { clerkFrontendApiOrigin, contentSecurityPolicy, developmentContentSecurityPolicy, hashSource, inlineScripts, originOf, unhashedInlineScripts, type PolicyOptions, type PolicyOrigins, type StyleSources, } from './policy.js';
|
|
3
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAChD,OAAO,EACL,sBAAsB,EACtB,qBAAqB,EACrB,gCAAgC,EAChC,UAAU,EACV,aAAa,EACb,QAAQ,EACR,qBAAqB,EACrB,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,YAAY,GAClB,MAAM,aAAa,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAChD,OAAO,EACL,sBAAsB,EACtB,qBAAqB,EACrB,gCAAgC,EAChC,UAAU,EACV,aAAa,EACb,QAAQ,EACR,qBAAqB,GAItB,MAAM,aAAa,CAAC"}
|
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content Security Policy for a built web app (ADR 0006 section 7). Pure, so
|
|
3
|
+
* build plugins, build checks and tests share one definition.
|
|
4
|
+
*/
|
|
5
|
+
/** `files`: CSS ships as files. `runtime-injected`: the UI library writes `<style>` at runtime. */
|
|
6
|
+
export type StyleSources = 'files' | 'runtime-injected';
|
|
7
|
+
export interface PolicyOrigins {
|
|
8
|
+
readonly api?: string | undefined;
|
|
9
|
+
readonly issuer?: string | undefined;
|
|
10
|
+
readonly analytics?: string | undefined;
|
|
11
|
+
readonly media?: string | undefined;
|
|
12
|
+
readonly suiteHub?: string | undefined;
|
|
13
|
+
readonly vendorConnect?: readonly string[];
|
|
14
|
+
readonly vendorImages?: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
export interface PolicyOptions {
|
|
17
|
+
readonly styles: StyleSources;
|
|
18
|
+
readonly origins: PolicyOrigins;
|
|
19
|
+
/** `'sha256-...'` sources for every inline `<script>` the build emits. */
|
|
20
|
+
readonly scriptHashes: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
/** Returns the origin of a configured URL, or `undefined` when it is unset. */
|
|
23
|
+
export declare function originOf(value: string | undefined): string | undefined;
|
|
24
|
+
/** The text of every inline script, in document order. */
|
|
25
|
+
export declare function inlineScripts(html: string): string[];
|
|
26
|
+
/** Formats a base64 SHA-256 digest as a CSP source. */
|
|
27
|
+
export declare function hashSource(base64Digest: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* The production policy. `upgrade-insecure-requests` is left out only when an
|
|
30
|
+
* origin is plain `http:`, which happens for local builds against localhost.
|
|
31
|
+
*/
|
|
32
|
+
export declare function contentSecurityPolicy(options: PolicyOptions): string;
|
|
33
|
+
/**
|
|
34
|
+
* The dev server variant: inline styles and the HMR websocket. Never served
|
|
35
|
+
* for a production build.
|
|
36
|
+
*/
|
|
37
|
+
export declare function developmentContentSecurityPolicy(options: PolicyOptions, hmrOrigins: readonly string[]): string;
|
|
38
|
+
/** Inline scripts whose hash the policy's `script-src` lacks, as CSP hash sources. */
|
|
39
|
+
export declare function unhashedInlineScripts(html: string, policy: string, hash: (script: string) => string): string[];
|
|
40
|
+
/** Decodes Clerk's frontend API host from a publishable key (`pk_<env>_<base64 host$>`). */
|
|
41
|
+
export declare function clerkFrontendApiOrigin(publishableKey: string | undefined): string | undefined;
|
|
42
|
+
//# sourceMappingURL=policy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../src/policy.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,mGAAmG;AACnG,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,kBAAkB,CAAC;AAExD,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACrC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACxC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACpC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACvC,QAAQ,CAAC,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3C,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC3C;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC;IAChC,0EAA0E;IAC1E,QAAQ,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAQD,+EAA+E;AAC/E,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAMtE;AAED,0DAA0D;AAC1D,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAEpD;AAED,uDAAuD;AACvD,wBAAgB,UAAU,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAMvD;AAyDD;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,aAAa,GAAG,MAAM,CAOpE;AAED;;;GAGG;AACH,wBAAgB,gCAAgC,CAC9C,OAAO,EAAE,aAAa,EACtB,UAAU,EAAE,SAAS,MAAM,EAAE,GAC5B,MAAM,CAMR;AAED,sFAAsF;AACtF,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,GAC/B,MAAM,EAAE,CASV;AAED,4FAA4F;AAC5F,wBAAgB,sBAAsB,CAAC,cAAc,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAO7F"}
|
package/dist/policy.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content Security Policy for a built web app (ADR 0006 section 7). Pure, so
|
|
3
|
+
* build plugins, build checks and tests share one definition.
|
|
4
|
+
*/
|
|
5
|
+
const SELF = "'self'";
|
|
6
|
+
const NONE = "'none'";
|
|
7
|
+
const INLINE = "'unsafe-inline'";
|
|
8
|
+
const INLINE_SCRIPT = /<script\b(?![^>]*\bsrc\s*=)[^>]*>([\s\S]*?)<\/script\s*>/giu;
|
|
9
|
+
const HASH_SOURCE = /^'sha256-[A-Za-z0-9+/]+={0,2}'$/u;
|
|
10
|
+
/** Returns the origin of a configured URL, or `undefined` when it is unset. */
|
|
11
|
+
export function originOf(value) {
|
|
12
|
+
if (value === undefined || value.trim() === '') {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
const url = new URL(value.includes('://') ? value : `https://${value}`);
|
|
16
|
+
return url.origin;
|
|
17
|
+
}
|
|
18
|
+
/** The text of every inline script, in document order. */
|
|
19
|
+
export function inlineScripts(html) {
|
|
20
|
+
return [...html.matchAll(INLINE_SCRIPT)].map((match) => match[1] ?? '');
|
|
21
|
+
}
|
|
22
|
+
/** Formats a base64 SHA-256 digest as a CSP source. */
|
|
23
|
+
export function hashSource(base64Digest) {
|
|
24
|
+
const source = `'sha256-${base64Digest}'`;
|
|
25
|
+
if (!HASH_SOURCE.test(source)) {
|
|
26
|
+
throw new Error(`not a base64 SHA-256 digest: ${base64Digest}`);
|
|
27
|
+
}
|
|
28
|
+
return source;
|
|
29
|
+
}
|
|
30
|
+
function sources(...values) {
|
|
31
|
+
return [...new Set(values.filter((value) => value !== undefined))];
|
|
32
|
+
}
|
|
33
|
+
function originList(origins) {
|
|
34
|
+
return [
|
|
35
|
+
origins.api,
|
|
36
|
+
origins.issuer,
|
|
37
|
+
origins.analytics,
|
|
38
|
+
origins.media,
|
|
39
|
+
origins.suiteHub,
|
|
40
|
+
...(origins.vendorConnect ?? []),
|
|
41
|
+
...(origins.vendorImages ?? []),
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
function usesPlainHttp(origins) {
|
|
45
|
+
return originList(origins).some((origin) => origin?.startsWith('http:') === true);
|
|
46
|
+
}
|
|
47
|
+
function directives(options) {
|
|
48
|
+
const { origins } = options;
|
|
49
|
+
const issuer = origins.issuer;
|
|
50
|
+
return [
|
|
51
|
+
['default-src', [SELF]],
|
|
52
|
+
['script-src', sources(SELF, ...options.scriptHashes)],
|
|
53
|
+
['style-src', options.styles === 'files' ? [SELF] : [SELF, INLINE]],
|
|
54
|
+
['img-src', sources(SELF, 'data:', 'blob:', origins.media, ...(origins.vendorImages ?? []))],
|
|
55
|
+
['media-src', sources(SELF, 'blob:', origins.media)],
|
|
56
|
+
[
|
|
57
|
+
'connect-src',
|
|
58
|
+
sources(SELF, origins.api, issuer, origins.analytics, origins.suiteHub, ...(origins.vendorConnect ?? [])),
|
|
59
|
+
],
|
|
60
|
+
['frame-src', issuer === undefined ? [NONE] : [issuer]],
|
|
61
|
+
['worker-src', [SELF]],
|
|
62
|
+
['manifest-src', [SELF]],
|
|
63
|
+
['object-src', [NONE]],
|
|
64
|
+
['base-uri', [NONE]],
|
|
65
|
+
['form-action', sources(SELF, issuer)],
|
|
66
|
+
['frame-ancestors', [NONE]],
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
function serialize(entries, upgrade) {
|
|
70
|
+
const parts = entries.map(([name, values]) => `${name} ${values.join(' ')}`);
|
|
71
|
+
return (upgrade ? [...parts, 'upgrade-insecure-requests'] : parts).join('; ');
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The production policy. `upgrade-insecure-requests` is left out only when an
|
|
75
|
+
* origin is plain `http:`, which happens for local builds against localhost.
|
|
76
|
+
*/
|
|
77
|
+
export function contentSecurityPolicy(options) {
|
|
78
|
+
for (const hash of options.scriptHashes) {
|
|
79
|
+
if (!HASH_SOURCE.test(hash)) {
|
|
80
|
+
throw new Error(`not a CSP hash source: ${hash}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return serialize(directives(options), !usesPlainHttp(options.origins));
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The dev server variant: inline styles and the HMR websocket. Never served
|
|
87
|
+
* for a production build.
|
|
88
|
+
*/
|
|
89
|
+
export function developmentContentSecurityPolicy(options, hmrOrigins) {
|
|
90
|
+
const entries = directives({ ...options, styles: 'runtime-injected' }).map(([name, values]) => name === 'connect-src' ? [name, sources(...values, ...hmrOrigins)] : [name, values]);
|
|
91
|
+
return serialize(entries, false);
|
|
92
|
+
}
|
|
93
|
+
/** Inline scripts whose hash the policy's `script-src` lacks, as CSP hash sources. */
|
|
94
|
+
export function unhashedInlineScripts(html, policy, hash) {
|
|
95
|
+
const scriptSrc = policy
|
|
96
|
+
.split(';')
|
|
97
|
+
.map((directive) => directive.trim().split(/\s+/u))
|
|
98
|
+
.find(([name]) => name === 'script-src');
|
|
99
|
+
const allowed = new Set(scriptSrc?.slice(1) ?? []);
|
|
100
|
+
return inlineScripts(html)
|
|
101
|
+
.map((script) => hashSource(hash(script)))
|
|
102
|
+
.filter((source) => !allowed.has(source));
|
|
103
|
+
}
|
|
104
|
+
/** Decodes Clerk's frontend API host from a publishable key (`pk_<env>_<base64 host$>`). */
|
|
105
|
+
export function clerkFrontendApiOrigin(publishableKey) {
|
|
106
|
+
const encoded = publishableKey?.split('_')[2];
|
|
107
|
+
if (encoded === undefined || encoded === '') {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
const host = atob(encoded).replace(/\$$/u, '');
|
|
111
|
+
return originOf(`https://${host}`);
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=policy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"policy.js","sourceRoot":"","sources":["../src/policy.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@baukit/csp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.19.0",
|
|
4
|
+
"description": "Content Security Policy builder and security headers for Baukit web builds.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Patrick Koss",
|
|
7
|
+
"homepage": "https://github.com/PatrickKoss/baukit#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/PatrickKoss/baukit.git",
|
|
11
|
+
"directory": "typescript/packages/csp"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/PatrickKoss/baukit/issues"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=24.0.0"
|
|
22
|
+
},
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"import": "./dist/index.js",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"src",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc -p tsconfig.json",
|
|
39
|
+
"prepare": "pnpm run build",
|
|
40
|
+
"test": "tsc -p tsconfig.test.json && vitest run --config ../../vitest.config.ts && node ../../scripts/test-packed-exports.mjs",
|
|
41
|
+
"lint": "eslint . --max-warnings=0",
|
|
42
|
+
"format:check": "prettier --check src package.json tsconfig.json tsconfig.test.json README.md"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "26.6.4",
|
|
46
|
+
"typescript": "6.0.3",
|
|
47
|
+
"vitest": "5.0.5"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/headers.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response headers every page of a built web app carries next to its policy.
|
|
3
|
+
* COEP stays unset: `require-corp` blocks third-party images and analytics
|
|
4
|
+
* that do not send CORP themselves.
|
|
5
|
+
*/
|
|
6
|
+
export const SECURITY_HEADERS: Readonly<Record<string, string>> = {
|
|
7
|
+
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
8
|
+
'Cross-Origin-Resource-Policy': 'same-origin',
|
|
9
|
+
'X-Content-Type-Options': 'nosniff',
|
|
10
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { SECURITY_HEADERS } from './headers.js';
|
|
2
|
+
export {
|
|
3
|
+
clerkFrontendApiOrigin,
|
|
4
|
+
contentSecurityPolicy,
|
|
5
|
+
developmentContentSecurityPolicy,
|
|
6
|
+
hashSource,
|
|
7
|
+
inlineScripts,
|
|
8
|
+
originOf,
|
|
9
|
+
unhashedInlineScripts,
|
|
10
|
+
type PolicyOptions,
|
|
11
|
+
type PolicyOrigins,
|
|
12
|
+
type StyleSources,
|
|
13
|
+
} from './policy.js';
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
clerkFrontendApiOrigin,
|
|
7
|
+
contentSecurityPolicy,
|
|
8
|
+
developmentContentSecurityPolicy,
|
|
9
|
+
hashSource,
|
|
10
|
+
inlineScripts,
|
|
11
|
+
originOf,
|
|
12
|
+
SECURITY_HEADERS,
|
|
13
|
+
unhashedInlineScripts,
|
|
14
|
+
} from './index.js';
|
|
15
|
+
|
|
16
|
+
const origins = {
|
|
17
|
+
api: 'https://api.example.test',
|
|
18
|
+
issuer: 'https://login.example.test',
|
|
19
|
+
analytics: 'https://eu.posthog.example.test',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function sha256Base64(text: string): string {
|
|
23
|
+
return createHash('sha256').update(text, 'utf8').digest('base64');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function directive(policy: string, name: string): string[] | undefined {
|
|
27
|
+
return policy
|
|
28
|
+
.split(';')
|
|
29
|
+
.map((part) => part.trim().split(' '))
|
|
30
|
+
.find(([directiveName]) => directiveName === name)
|
|
31
|
+
?.slice(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('content security policy', () => {
|
|
35
|
+
it('locks scripts and styles to the app origin for file-based CSS', () => {
|
|
36
|
+
const policy = contentSecurityPolicy({ styles: 'files', origins, scriptHashes: [] });
|
|
37
|
+
|
|
38
|
+
expect(directive(policy, 'script-src')).toEqual(["'self'"]);
|
|
39
|
+
expect(directive(policy, 'style-src')).toEqual(["'self'"]);
|
|
40
|
+
expect(directive(policy, 'connect-src')).toEqual([
|
|
41
|
+
"'self'",
|
|
42
|
+
'https://api.example.test',
|
|
43
|
+
'https://login.example.test',
|
|
44
|
+
'https://eu.posthog.example.test',
|
|
45
|
+
]);
|
|
46
|
+
expect(directive(policy, 'frame-src')).toEqual(['https://login.example.test']);
|
|
47
|
+
expect(directive(policy, 'form-action')).toEqual(["'self'", 'https://login.example.test']);
|
|
48
|
+
expect(directive(policy, 'object-src')).toEqual(["'none'"]);
|
|
49
|
+
expect(directive(policy, 'base-uri')).toEqual(["'none'"]);
|
|
50
|
+
expect(directive(policy, 'frame-ancestors')).toEqual(["'none'"]);
|
|
51
|
+
expect(policy).toContain('upgrade-insecure-requests');
|
|
52
|
+
expect(policy).not.toContain('unsafe-inline');
|
|
53
|
+
expect(policy).not.toContain('unsafe-eval');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('allows inline styles but never inline scripts for runtime-injected CSS', () => {
|
|
57
|
+
const policy = contentSecurityPolicy({
|
|
58
|
+
styles: 'runtime-injected',
|
|
59
|
+
origins: { api: 'https://api.example.test', media: 'https://media.example.test' },
|
|
60
|
+
scriptHashes: [hashSource('Ym9vdA==')],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(directive(policy, 'style-src')).toEqual(["'self'", "'unsafe-inline'"]);
|
|
64
|
+
expect(directive(policy, 'script-src')).toEqual(["'self'", "'sha256-Ym9vdA=='"]);
|
|
65
|
+
expect(directive(policy, 'img-src')).toContain('https://media.example.test');
|
|
66
|
+
expect(directive(policy, 'frame-src')).toEqual(["'none'"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('rejects anything but hash sources for scripts', () => {
|
|
70
|
+
expect(() => hashSource('not base64!')).toThrow(/SHA-256/u);
|
|
71
|
+
expect(() =>
|
|
72
|
+
contentSecurityPolicy({ styles: 'files', origins, scriptHashes: ["'unsafe-inline'"] }),
|
|
73
|
+
).toThrow(/not a CSP hash source/u);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('deduplicates origins configured twice', () => {
|
|
77
|
+
const policy = contentSecurityPolicy({
|
|
78
|
+
styles: 'files',
|
|
79
|
+
origins: { api: 'https://same.example.test', issuer: 'https://same.example.test' },
|
|
80
|
+
scriptHashes: [],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(directive(policy, 'connect-src')).toEqual(["'self'", 'https://same.example.test']);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('omits upgrade-insecure-requests only for plain http origins', () => {
|
|
87
|
+
const local = contentSecurityPolicy({
|
|
88
|
+
styles: 'files',
|
|
89
|
+
origins: { vendorImages: ['http://localhost:9000'] },
|
|
90
|
+
scriptHashes: [],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(local).not.toContain('upgrade-insecure-requests');
|
|
94
|
+
expect(directive(local, 'frame-src')).toEqual(["'none'"]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('relaxes only the dev variant for styles and the HMR socket', () => {
|
|
98
|
+
const policy = developmentContentSecurityPolicy(
|
|
99
|
+
{ styles: 'files', origins, scriptHashes: [] },
|
|
100
|
+
['ws://localhost:5173'],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
expect(directive(policy, 'style-src')).toEqual(["'self'", "'unsafe-inline'"]);
|
|
104
|
+
expect(directive(policy, 'connect-src')).toContain('ws://localhost:5173');
|
|
105
|
+
expect(directive(policy, 'script-src')).toEqual(["'self'"]);
|
|
106
|
+
expect(policy).not.toContain('upgrade-insecure-requests');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('inline script hashes', () => {
|
|
111
|
+
const html =
|
|
112
|
+
'<script type="module" src="/assets/index.js"></script><script>boot()</script><script type="importmap">{}</script>';
|
|
113
|
+
|
|
114
|
+
it('finds inline scripts and skips external ones', () => {
|
|
115
|
+
expect(inlineScripts(html)).toEqual(['boot()', '{}']);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('reports every inline script the policy does not hash', () => {
|
|
119
|
+
const policy = contentSecurityPolicy({
|
|
120
|
+
styles: 'files',
|
|
121
|
+
origins,
|
|
122
|
+
scriptHashes: [hashSource(sha256Base64('boot()'))],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(unhashedInlineScripts(html, policy, sha256Base64)).toEqual([
|
|
126
|
+
`'sha256-${sha256Base64('{}')}'`,
|
|
127
|
+
]);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('origins', () => {
|
|
132
|
+
it('reduces URLs and bare hosts to origins', () => {
|
|
133
|
+
expect(originOf('https://api.example.test/v1/')).toBe('https://api.example.test');
|
|
134
|
+
expect(originOf('eu.posthog.example.test')).toBe('https://eu.posthog.example.test');
|
|
135
|
+
expect(originOf(' ')).toBeUndefined();
|
|
136
|
+
expect(originOf(undefined)).toBeUndefined();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('decodes the Clerk frontend API host from a publishable key', () => {
|
|
140
|
+
const key = `pk_test_${btoa('clerk.example.test$')}`;
|
|
141
|
+
|
|
142
|
+
expect(clerkFrontendApiOrigin(key)).toBe('https://clerk.example.test');
|
|
143
|
+
expect(clerkFrontendApiOrigin('')).toBeUndefined();
|
|
144
|
+
expect(clerkFrontendApiOrigin(undefined)).toBeUndefined();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('security headers', () => {
|
|
149
|
+
it('isolates the opener and resources without requiring CORP from subresources', () => {
|
|
150
|
+
expect(SECURITY_HEADERS).toEqual({
|
|
151
|
+
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
152
|
+
'Cross-Origin-Resource-Policy': 'same-origin',
|
|
153
|
+
'X-Content-Type-Options': 'nosniff',
|
|
154
|
+
});
|
|
155
|
+
expect(Object.keys(SECURITY_HEADERS)).not.toContain('Cross-Origin-Embedder-Policy');
|
|
156
|
+
});
|
|
157
|
+
});
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content Security Policy for a built web app (ADR 0006 section 7). Pure, so
|
|
3
|
+
* build plugins, build checks and tests share one definition.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** `files`: CSS ships as files. `runtime-injected`: the UI library writes `<style>` at runtime. */
|
|
7
|
+
export type StyleSources = 'files' | 'runtime-injected';
|
|
8
|
+
|
|
9
|
+
export interface PolicyOrigins {
|
|
10
|
+
readonly api?: string | undefined;
|
|
11
|
+
readonly issuer?: string | undefined;
|
|
12
|
+
readonly analytics?: string | undefined;
|
|
13
|
+
readonly media?: string | undefined;
|
|
14
|
+
readonly suiteHub?: string | undefined;
|
|
15
|
+
readonly vendorConnect?: readonly string[];
|
|
16
|
+
readonly vendorImages?: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PolicyOptions {
|
|
20
|
+
readonly styles: StyleSources;
|
|
21
|
+
readonly origins: PolicyOrigins;
|
|
22
|
+
/** `'sha256-...'` sources for every inline `<script>` the build emits. */
|
|
23
|
+
readonly scriptHashes: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SELF = "'self'";
|
|
27
|
+
const NONE = "'none'";
|
|
28
|
+
const INLINE = "'unsafe-inline'";
|
|
29
|
+
const INLINE_SCRIPT = /<script\b(?![^>]*\bsrc\s*=)[^>]*>([\s\S]*?)<\/script\s*>/giu;
|
|
30
|
+
const HASH_SOURCE = /^'sha256-[A-Za-z0-9+/]+={0,2}'$/u;
|
|
31
|
+
|
|
32
|
+
/** Returns the origin of a configured URL, or `undefined` when it is unset. */
|
|
33
|
+
export function originOf(value: string | undefined): string | undefined {
|
|
34
|
+
if (value === undefined || value.trim() === '') {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
const url = new URL(value.includes('://') ? value : `https://${value}`);
|
|
38
|
+
return url.origin;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The text of every inline script, in document order. */
|
|
42
|
+
export function inlineScripts(html: string): string[] {
|
|
43
|
+
return [...html.matchAll(INLINE_SCRIPT)].map((match) => match[1] ?? '');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Formats a base64 SHA-256 digest as a CSP source. */
|
|
47
|
+
export function hashSource(base64Digest: string): string {
|
|
48
|
+
const source = `'sha256-${base64Digest}'`;
|
|
49
|
+
if (!HASH_SOURCE.test(source)) {
|
|
50
|
+
throw new Error(`not a base64 SHA-256 digest: ${base64Digest}`);
|
|
51
|
+
}
|
|
52
|
+
return source;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sources(...values: readonly (string | undefined)[]): string[] {
|
|
56
|
+
return [...new Set(values.filter((value): value is string => value !== undefined))];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function originList(origins: PolicyOrigins): (string | undefined)[] {
|
|
60
|
+
return [
|
|
61
|
+
origins.api,
|
|
62
|
+
origins.issuer,
|
|
63
|
+
origins.analytics,
|
|
64
|
+
origins.media,
|
|
65
|
+
origins.suiteHub,
|
|
66
|
+
...(origins.vendorConnect ?? []),
|
|
67
|
+
...(origins.vendorImages ?? []),
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function usesPlainHttp(origins: PolicyOrigins): boolean {
|
|
72
|
+
return originList(origins).some((origin) => origin?.startsWith('http:') === true);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function directives(options: PolicyOptions): [string, string[]][] {
|
|
76
|
+
const { origins } = options;
|
|
77
|
+
const issuer = origins.issuer;
|
|
78
|
+
return [
|
|
79
|
+
['default-src', [SELF]],
|
|
80
|
+
['script-src', sources(SELF, ...options.scriptHashes)],
|
|
81
|
+
['style-src', options.styles === 'files' ? [SELF] : [SELF, INLINE]],
|
|
82
|
+
['img-src', sources(SELF, 'data:', 'blob:', origins.media, ...(origins.vendorImages ?? []))],
|
|
83
|
+
['media-src', sources(SELF, 'blob:', origins.media)],
|
|
84
|
+
[
|
|
85
|
+
'connect-src',
|
|
86
|
+
sources(
|
|
87
|
+
SELF,
|
|
88
|
+
origins.api,
|
|
89
|
+
issuer,
|
|
90
|
+
origins.analytics,
|
|
91
|
+
origins.suiteHub,
|
|
92
|
+
...(origins.vendorConnect ?? []),
|
|
93
|
+
),
|
|
94
|
+
],
|
|
95
|
+
['frame-src', issuer === undefined ? [NONE] : [issuer]],
|
|
96
|
+
['worker-src', [SELF]],
|
|
97
|
+
['manifest-src', [SELF]],
|
|
98
|
+
['object-src', [NONE]],
|
|
99
|
+
['base-uri', [NONE]],
|
|
100
|
+
['form-action', sources(SELF, issuer)],
|
|
101
|
+
['frame-ancestors', [NONE]],
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function serialize(entries: readonly [string, readonly string[]][], upgrade: boolean): string {
|
|
106
|
+
const parts = entries.map(([name, values]) => `${name} ${values.join(' ')}`);
|
|
107
|
+
return (upgrade ? [...parts, 'upgrade-insecure-requests'] : parts).join('; ');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The production policy. `upgrade-insecure-requests` is left out only when an
|
|
112
|
+
* origin is plain `http:`, which happens for local builds against localhost.
|
|
113
|
+
*/
|
|
114
|
+
export function contentSecurityPolicy(options: PolicyOptions): string {
|
|
115
|
+
for (const hash of options.scriptHashes) {
|
|
116
|
+
if (!HASH_SOURCE.test(hash)) {
|
|
117
|
+
throw new Error(`not a CSP hash source: ${hash}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return serialize(directives(options), !usesPlainHttp(options.origins));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The dev server variant: inline styles and the HMR websocket. Never served
|
|
125
|
+
* for a production build.
|
|
126
|
+
*/
|
|
127
|
+
export function developmentContentSecurityPolicy(
|
|
128
|
+
options: PolicyOptions,
|
|
129
|
+
hmrOrigins: readonly string[],
|
|
130
|
+
): string {
|
|
131
|
+
const entries = directives({ ...options, styles: 'runtime-injected' }).map(
|
|
132
|
+
([name, values]): [string, string[]] =>
|
|
133
|
+
name === 'connect-src' ? [name, sources(...values, ...hmrOrigins)] : [name, values],
|
|
134
|
+
);
|
|
135
|
+
return serialize(entries, false);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Inline scripts whose hash the policy's `script-src` lacks, as CSP hash sources. */
|
|
139
|
+
export function unhashedInlineScripts(
|
|
140
|
+
html: string,
|
|
141
|
+
policy: string,
|
|
142
|
+
hash: (script: string) => string,
|
|
143
|
+
): string[] {
|
|
144
|
+
const scriptSrc = policy
|
|
145
|
+
.split(';')
|
|
146
|
+
.map((directive) => directive.trim().split(/\s+/u))
|
|
147
|
+
.find(([name]) => name === 'script-src');
|
|
148
|
+
const allowed = new Set(scriptSrc?.slice(1) ?? []);
|
|
149
|
+
return inlineScripts(html)
|
|
150
|
+
.map((script) => hashSource(hash(script)))
|
|
151
|
+
.filter((source) => !allowed.has(source));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Decodes Clerk's frontend API host from a publishable key (`pk_<env>_<base64 host$>`). */
|
|
155
|
+
export function clerkFrontendApiOrigin(publishableKey: string | undefined): string | undefined {
|
|
156
|
+
const encoded = publishableKey?.split('_')[2];
|
|
157
|
+
if (encoded === undefined || encoded === '') {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
const host = atob(encoded).replace(/\$$/u, '');
|
|
161
|
+
return originOf(`https://${host}`);
|
|
162
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { contentSecurityPolicy, developmentContentSecurityPolicy } from './index.js';
|
|
4
|
+
|
|
5
|
+
const everyOrigin = {
|
|
6
|
+
api: 'https://api.example.test',
|
|
7
|
+
issuer: 'https://login.example.test',
|
|
8
|
+
analytics: 'https://eu.posthog.example.test',
|
|
9
|
+
media: 'https://media.example.test',
|
|
10
|
+
suiteHub: 'https://hub.example.test',
|
|
11
|
+
vendorConnect: ['https://clerk.example.test'],
|
|
12
|
+
vendorImages: ['https://img.clerk.com'],
|
|
13
|
+
};
|
|
14
|
+
const bootHash = ["'sha256-Ym9vdA=='"];
|
|
15
|
+
|
|
16
|
+
// Output of the template copies of policy.ts before they moved into this package.
|
|
17
|
+
describe('policy vectors', () => {
|
|
18
|
+
it('keeps the production policy with every origin byte-equal', () => {
|
|
19
|
+
expect(
|
|
20
|
+
contentSecurityPolicy({ styles: 'files', origins: everyOrigin, scriptHashes: bootHash }),
|
|
21
|
+
).toBe(
|
|
22
|
+
"default-src 'self'; script-src 'self' 'sha256-Ym9vdA=='; style-src 'self'; img-src 'self' data: blob: https://media.example.test https://img.clerk.com; media-src 'self' blob: https://media.example.test; connect-src 'self' https://api.example.test https://login.example.test https://eu.posthog.example.test https://hub.example.test https://clerk.example.test; frame-src https://login.example.test; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self' https://login.example.test; frame-ancestors 'none'; upgrade-insecure-requests",
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('keeps the local http policy with runtime-injected styles byte-equal', () => {
|
|
27
|
+
expect(
|
|
28
|
+
contentSecurityPolicy({
|
|
29
|
+
styles: 'runtime-injected',
|
|
30
|
+
origins: { api: 'http://localhost:8080', issuer: 'http://localhost:8081' },
|
|
31
|
+
scriptHashes: [],
|
|
32
|
+
}),
|
|
33
|
+
).toBe(
|
|
34
|
+
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self' http://localhost:8080 http://localhost:8081; frame-src http://localhost:8081; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self' http://localhost:8081; frame-ancestors 'none'",
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('keeps the policy without origins byte-equal', () => {
|
|
39
|
+
expect(contentSecurityPolicy({ styles: 'files', origins: {}, scriptHashes: [] })).toBe(
|
|
40
|
+
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; frame-src 'none'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests",
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('keeps the development policy byte-equal', () => {
|
|
45
|
+
expect(
|
|
46
|
+
developmentContentSecurityPolicy(
|
|
47
|
+
{ styles: 'files', origins: everyOrigin, scriptHashes: bootHash },
|
|
48
|
+
['ws://localhost:5173', 'ws://127.0.0.1:5173'],
|
|
49
|
+
),
|
|
50
|
+
).toBe(
|
|
51
|
+
"default-src 'self'; script-src 'self' 'sha256-Ym9vdA=='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://media.example.test https://img.clerk.com; media-src 'self' blob: https://media.example.test; connect-src 'self' https://api.example.test https://login.example.test https://eu.posthog.example.test https://hub.example.test https://clerk.example.test ws://localhost:5173 ws://127.0.0.1:5173; frame-src https://login.example.test; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self' https://login.example.test; frame-ancestors 'none'",
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
});
|