@jk2908/solas 0.15.1 → 0.16.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/CHANGELOG.md +15 -0
- package/README.md +46 -13
- package/dist/adapters/cloudflare/vite.d.ts +1 -0
- package/dist/adapters/cloudflare/vite.js +11 -14
- package/dist/adapters/static.js +32 -0
- package/dist/config.js +32 -3
- package/dist/index.js +8 -2
- package/dist/internal/browser-router/router.js +9 -3
- package/dist/internal/csp.d.ts +37 -0
- package/dist/internal/csp.js +145 -0
- package/dist/internal/env/browser.js +11 -4
- package/dist/internal/env/request-context.d.ts +2 -0
- package/dist/internal/env/rsc.js +95 -15
- package/dist/internal/env/ssr.js +8 -2
- package/dist/internal/hash-inline-assets.d.ts +11 -0
- package/dist/internal/hash-inline-assets.js +103 -0
- package/dist/internal/http-router/router.d.ts +1 -1
- package/dist/internal/http-router/router.js +18 -6
- package/dist/internal/postbuild.d.ts +14 -8
- package/dist/internal/postbuild.js +41 -23
- package/dist/internal/prerender.d.ts +3 -1
- package/dist/internal/prerender.js +7 -2
- package/dist/internal/server/headers.d.ts +8 -0
- package/dist/internal/server/headers.js +29 -1
- package/dist/manifest.d.ts +6 -0
- package/dist/manifest.js +56 -13
- package/dist/types.d.ts +17 -5
- package/dist/utils/get-document-nonce.d.ts +11 -0
- package/dist/utils/get-document-nonce.js +16 -0
- package/dist/utils/validation.d.ts +2 -0
- package/dist/utils/validation.js +6 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.0
|
|
4
|
+
|
|
5
|
+
### Breaking
|
|
6
|
+
|
|
7
|
+
- Raised `engines.node` to `^22.23.4 || >=24.22.1`. Node 20 is end of life and does not receive the fix for CVE-2026-111497, a Brotli decoder flaw in Node that allows memory disclosure or code execution when a process decodes attacker-controlled Brotli data. Solas never decodes Brotli itself, but apps on `@jk2908/solas/runtime/node` should run a patched Node, particularly when they fetch responses an attacker can influence, since Node's `fetch` decodes `content-encoding: br` automatically.
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- Middleware can pass a replacement request to `next(request)`, so it can add headers for downstream middleware and rendering.
|
|
12
|
+
- A structured `csp` config generates nonce and hash-based policies for dynamic, full, and PPR routes. CSP keywords can be written without their surrounding quotes. It cannot be combined with a `Content-Security-Policy` header in the `headers` config; a request CSP still takes precedence. The static adapter writes the generated policy as a meta tag in each prerendered page.
|
|
13
|
+
- Nonce-based CSP is supported for dynamically rendered HTML. Solas reads the nonce from the effective `Content-Security-Policy` set by middleware, an upstream proxy, or the `headers` config, applies it to the scripts it generates, and exposes it as `x-nonce` from `headers()`. Full routes do not receive nonces; PPR uses a nonce only for its resumed holes.
|
|
14
|
+
- Fully prerendered routes served by Solas receive build-time SHA-256 hashes for their inline scripts and styles. Solas appends the hashes to the effective CSP, so the HTML can stay cacheable without a nonce.
|
|
15
|
+
- PPR responses use hashes for the cached prelude and a request nonce for the resumed holes. Solas puts the nonce on the prelude bootstrap, so `strict-dynamic` and client-side nonce access keep working for the dynamic parts.
|
|
16
|
+
- Client navigations, prefetches, and server actions reuse the document nonce, so inline styles and scripts rendered after the initial load keep matching the document CSP. Hoistable `<style href precedence>` tags, including `@jk2908/cxx`, receive the nonce when the app passes it to the tag.
|
|
17
|
+
|
|
3
18
|
## 0.15.1
|
|
4
19
|
|
|
5
20
|
### Fixes
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Solas is experimental and under active development, so expect rough edges.
|
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
|
-
Solas requires **Vite 8** (which uses Rolldown as the bundler).
|
|
9
|
+
Solas requires **Vite 8** (which uses Rolldown as the bundler) and **Node.js 22.23.4, 24.22.1 or newer** when using the Node runtime. Node 20 is end of life, and these are the first releases with the fix for [CVE-2026-111497](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases), a Brotli decoder flaw in Node. Solas does not decode Brotli, but your app might, including through `fetch`, which decodes `content-encoding: br` for you.
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
12
|
npm install @jk2908/solas react react-dom react-server-dom-webpack vite@^8
|
|
@@ -71,7 +71,7 @@ Use these filename conventions:
|
|
|
71
71
|
- `+layout.tsx`: shared layout for a route branch.
|
|
72
72
|
- `+page.tsx`: page component for a route.
|
|
73
73
|
- `+endpoint.ts`: request handler. Can be placed in any folder and responds to all HTTP methods for its route path.
|
|
74
|
-
- `+middleware.ts`: middleware that runs for the current route branch and is inherited by child routes. Parent and child middleware stack together.
|
|
74
|
+
- `+middleware.ts`: middleware that runs for the current route branch and is inherited by child routes. Parent and child middleware stack together. Call `next()` to continue, or `next(request)` to pass a replacement request with different headers downstream.
|
|
75
75
|
- `+loading.tsx`: loading fallback inherited by child routes.
|
|
76
76
|
- `+401.tsx`: boundary for unauthorised responses in the current route branch and its children.
|
|
77
77
|
- `+403.tsx`: boundary for forbidden responses in the current route branch and its children.
|
|
@@ -478,17 +478,7 @@ solas({
|
|
|
478
478
|
})
|
|
479
479
|
```
|
|
480
480
|
|
|
481
|
-
Pass a function to vary the headers per request,
|
|
482
|
-
|
|
483
|
-
```ts
|
|
484
|
-
solas({
|
|
485
|
-
headers: req => ({
|
|
486
|
-
'Content-Security-Policy': `default-src 'self'; script-src 'nonce-${nonce(req)}'`,
|
|
487
|
-
}),
|
|
488
|
-
})
|
|
489
|
-
```
|
|
490
|
-
|
|
491
|
-
The function can be async. Configured headers win over any header Solas sets.
|
|
481
|
+
Pass a function to vary the headers per request. The function can be async. Configured headers win over any header Solas sets, except that a `Content-Security-Policy` on the request takes precedence over a configured one. See [Security > CSP](#csp) for the nonce pattern.
|
|
492
482
|
|
|
493
483
|
### `sitemap`
|
|
494
484
|
|
|
@@ -814,6 +804,49 @@ If you run Vite through Bun, use the Bun-backed commands instead:
|
|
|
814
804
|
|
|
815
805
|
## Security
|
|
816
806
|
|
|
807
|
+
### CSP
|
|
808
|
+
|
|
809
|
+
Use the `csp` config to generate a policy:
|
|
810
|
+
|
|
811
|
+
```ts
|
|
812
|
+
solas({
|
|
813
|
+
csp: {
|
|
814
|
+
directives: {
|
|
815
|
+
'default-src': ['self'],
|
|
816
|
+
'script-src': ['self'],
|
|
817
|
+
'style-src': ['self'],
|
|
818
|
+
'img-src': ['self', 'data:'],
|
|
819
|
+
'object-src': ['none'],
|
|
820
|
+
'base-uri': ['self'],
|
|
821
|
+
},
|
|
822
|
+
},
|
|
823
|
+
})
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
CSP keywords such as `self` and `none` can be written without their surrounding quotes. Solas adds a per-request nonce to dynamic responses, build-time hashes to full prerenders, and both to PPR. It also keeps client navigations aligned with the document policy.
|
|
827
|
+
|
|
828
|
+
Read the nonce in a server component for any inline `<style>` or `<script>` you render:
|
|
829
|
+
|
|
830
|
+
```tsx
|
|
831
|
+
const nonce = (await headers()).get('x-nonce')
|
|
832
|
+
|
|
833
|
+
return <style nonce={nonce}>{`body { margin: 0 }`}</style>
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
Hoistable `<style href precedence>` tags, including `@jk2908/cxx`, need the same nonce prop. Add `'unsafe-eval'` in development for React's error stacks.
|
|
837
|
+
|
|
838
|
+
Full hash-only pages should use `script-src 'self'` rather than `'strict-dynamic'`. Browsers do not propagate hash trust from the bootstrap to its dynamic `import()`. Dynamic and PPR responses can use `'strict-dynamic'` because their bootstrap has a nonce.
|
|
839
|
+
|
|
840
|
+
To use a completely custom policy, set CSP in middleware or a `headers` function. A CSP on the request wins. When `csp` is configured, it owns the generated HTML policy; a CSP returned from a `headers` function is not used for HTML. Use one source.
|
|
841
|
+
|
|
842
|
+
With the static adapter, the generated policy is written as a `<meta http-equiv="Content-Security-Policy">` tag in each prerendered page. `frame-ancestors`, `report-uri`, and `sandbox` are ignored in meta; use host-level headers if you need them.
|
|
843
|
+
|
|
844
|
+
Solas expects one effective CSP policy. If middleware or a proxy sets multiple policies, combine them into a single header before Solas sees the response. The Fetch API merges multiple headers, and Solas cannot safely add hashes to that merged value.
|
|
845
|
+
|
|
846
|
+
The build warns when the generated CSP header for a route exceeds 8 KB because of the prerendered style hash union. Reduce hash-bearing prerendered styles or raise the server header limit in that case.
|
|
847
|
+
|
|
848
|
+
Hashes cover inline `<script>` and `<style>` elements, not `style` attributes. If full static pages can insert inline styles during client navigation, keep `style-src 'unsafe-inline'` for those pages.
|
|
849
|
+
|
|
817
850
|
### CSRF
|
|
818
851
|
|
|
819
852
|
Solas protects server actions and `+endpoint` handlers against CSRF.
|
|
@@ -20,5 +20,6 @@ export type SolasCloudflareOptions = {
|
|
|
20
20
|
export declare function adapter(options?: SolasCloudflareOptions): {
|
|
21
21
|
name: string;
|
|
22
22
|
plugins: PluginOption[];
|
|
23
|
+
postbuild(config: import("vite").ResolvedConfig): import("../../internal/postbuild.js").PrerenderImport | undefined;
|
|
23
24
|
};
|
|
24
25
|
export {};
|
|
@@ -3,7 +3,6 @@ import path from 'node:path';
|
|
|
3
3
|
import { cloudflare as cloudflareVitePlugin } from '@cloudflare/vite-plugin';
|
|
4
4
|
import * as Config from '../../config.js';
|
|
5
5
|
import { writeCloudflareEntry } from '../../internal/codegen/environments.js';
|
|
6
|
-
import { setPrerenderImport, setRscEntry } from '../../internal/postbuild.js';
|
|
7
6
|
import { NAMESPACE_PREFIX } from '../../runtime/cloudflare/assets.js';
|
|
8
7
|
import { withBindingsShim } from './bindings.js';
|
|
9
8
|
const VIRTUAL_ENTRY = 'virtual:solas-cloudflare-entry';
|
|
@@ -120,18 +119,6 @@ export function adapter(options = {}) {
|
|
|
120
119
|
const adapter = {
|
|
121
120
|
name: 'solas-cloudflare',
|
|
122
121
|
enforce: 'post',
|
|
123
|
-
configResolved(config) {
|
|
124
|
-
// the Cloudflare plugin points the `rsc` environment's build output at
|
|
125
|
-
// the Worker bundle during its `config` hook, so by now we know where
|
|
126
|
-
// Solas's prerender step should import the Worker from
|
|
127
|
-
const rscOutDir = config.environments.rsc?.build.outDir;
|
|
128
|
-
if (rscOutDir) {
|
|
129
|
-
setRscEntry(path.resolve(cwd, rscOutDir, 'index.js'));
|
|
130
|
-
}
|
|
131
|
-
// the Worker bundle imports `cloudflare:workers`, which only exists in
|
|
132
|
-
// workerd, so shim it around the Node import
|
|
133
|
-
setPrerenderImport(withBindingsShim);
|
|
134
|
-
},
|
|
135
122
|
configEnvironment(name, environment) {
|
|
136
123
|
// Pre-bundle the Worker environments so workerd never has to re-optimize
|
|
137
124
|
// dependencies in the middle of a request. Scanning the app directory
|
|
@@ -168,5 +155,15 @@ export function adapter(options = {}) {
|
|
|
168
155
|
},
|
|
169
156
|
};
|
|
170
157
|
const plugins = [adapter, ...cfPlugins];
|
|
171
|
-
return {
|
|
158
|
+
return {
|
|
159
|
+
name: 'cloudflare',
|
|
160
|
+
plugins,
|
|
161
|
+
postbuild(config) {
|
|
162
|
+
const rscOutDir = config.environments.rsc?.build.outDir;
|
|
163
|
+
if (!rscOutDir)
|
|
164
|
+
return;
|
|
165
|
+
const entry = path.resolve(cwd, rscOutDir, 'index.js');
|
|
166
|
+
return withBindingsShim(() => import(/* @vite-ignore */ entry));
|
|
167
|
+
},
|
|
168
|
+
};
|
|
172
169
|
}
|
package/dist/adapters/static.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import * as Config from '../config.js';
|
|
4
|
+
import { buildPolicy, injectMetaTag } from '../internal/csp.js';
|
|
4
5
|
import { Features, guards } from '../internal/guards.js';
|
|
6
|
+
import { staticRoutePath } from '../internal/prerender.js';
|
|
7
|
+
import { loadManifest } from '../manifest.js';
|
|
5
8
|
const ROOT_FILES = ['sitemap.xml', 'robots.txt'];
|
|
6
9
|
/**
|
|
7
10
|
* Static site adapter for Solas.
|
|
@@ -41,6 +44,35 @@ function adapter(options = {}) {
|
|
|
41
44
|
await fs.copyFile(source, path.join(target, file));
|
|
42
45
|
}
|
|
43
46
|
}
|
|
47
|
+
const manifest = await loadManifest();
|
|
48
|
+
const csp = manifest?.csp;
|
|
49
|
+
if (!csp)
|
|
50
|
+
return;
|
|
51
|
+
const mode = manifest.trailingSlash === 'always' ? 'always' : 'never';
|
|
52
|
+
const styleHashes = [
|
|
53
|
+
...new Set(Object.values(manifest.artifacts).flatMap(artifact => artifact.csp?.style ?? [])),
|
|
54
|
+
];
|
|
55
|
+
await Promise.all(Object.entries(manifest.artifacts).map(async ([route, artifact]) => {
|
|
56
|
+
if (artifact.mode !== 'full')
|
|
57
|
+
return;
|
|
58
|
+
const file = path.join(target, staticRoutePath(route, mode));
|
|
59
|
+
let html;
|
|
60
|
+
try {
|
|
61
|
+
html = await fs.readFile(file, 'utf-8');
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const policy = buildPolicy(csp.directives, {
|
|
67
|
+
hashes: {
|
|
68
|
+
script: artifact.csp?.script ?? [],
|
|
69
|
+
style: [...new Set([...(artifact.csp?.style ?? []), ...styleHashes])],
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
if (!policy)
|
|
73
|
+
return;
|
|
74
|
+
await fs.writeFile(file, injectMetaTag(html, policy));
|
|
75
|
+
}));
|
|
44
76
|
},
|
|
45
77
|
},
|
|
46
78
|
};
|
package/dist/config.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isRecord, isStringArray } from './utils/validation.js';
|
|
1
2
|
export const NAME = 'Solas';
|
|
2
3
|
export const SLUG = NAME.toLowerCase();
|
|
3
4
|
export const PKG_NAME = `@jk2908/${SLUG}`;
|
|
@@ -18,6 +19,7 @@ export const TRAILING_SLASH_MODES = ['always', 'never', 'ignore'];
|
|
|
18
19
|
export const RUNTIME_MANIFEST = 'runtime-manifest.json';
|
|
19
20
|
const CONFIG_KEYS = new Set([
|
|
20
21
|
'adapter',
|
|
22
|
+
'csp',
|
|
21
23
|
'headers',
|
|
22
24
|
'logger',
|
|
23
25
|
'metadata',
|
|
@@ -85,6 +87,36 @@ export function validate(input, envUrl) {
|
|
|
85
87
|
errors.push('headers must be an object or a function that returns one');
|
|
86
88
|
}
|
|
87
89
|
}
|
|
90
|
+
if ('csp' in input && input.csp !== undefined) {
|
|
91
|
+
const csp = input.csp;
|
|
92
|
+
if (!isRecord(csp)) {
|
|
93
|
+
errors.push('csp must be an object');
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
for (const key of Object.keys(csp)) {
|
|
97
|
+
if (key !== 'directives') {
|
|
98
|
+
errors.push(`Unknown csp key: ${key}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!isRecord(csp.directives)) {
|
|
102
|
+
errors.push('csp.directives must be an object of string arrays');
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
for (const [name, values] of Object.entries(csp.directives)) {
|
|
106
|
+
if (!isStringArray(values)) {
|
|
107
|
+
errors.push(`csp.directives.${name} must be an array of strings`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (isRecord(input.headers)) {
|
|
113
|
+
for (const key of Object.keys(input.headers)) {
|
|
114
|
+
if (key.toLowerCase() === 'content-security-policy') {
|
|
115
|
+
errors.push('csp cannot be combined with a Content-Security-Policy header');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
88
120
|
if ('trustedOrigins' in input && input.trustedOrigins !== undefined) {
|
|
89
121
|
if (!Array.isArray(input.trustedOrigins)) {
|
|
90
122
|
errors.push('trustedOrigins must be an array of origins');
|
|
@@ -159,6 +191,3 @@ export function validate(input, envUrl) {
|
|
|
159
191
|
}
|
|
160
192
|
return input;
|
|
161
193
|
}
|
|
162
|
-
function isRecord(value) {
|
|
163
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
164
|
-
}
|
package/dist/index.js
CHANGED
|
@@ -28,7 +28,7 @@ const DEFAULT_CONFIG = {
|
|
|
28
28
|
*/
|
|
29
29
|
function normaliseOrigin(value) {
|
|
30
30
|
if (!value)
|
|
31
|
-
return
|
|
31
|
+
return;
|
|
32
32
|
return new URL(value).origin;
|
|
33
33
|
}
|
|
34
34
|
function solas(c) {
|
|
@@ -356,7 +356,13 @@ function solas(c) {
|
|
|
356
356
|
buildApp: {
|
|
357
357
|
order: 'post',
|
|
358
358
|
async handler() {
|
|
359
|
-
|
|
359
|
+
if (!resolvedViteConfig)
|
|
360
|
+
throw new Error('Vite config not resolved yet');
|
|
361
|
+
const load = await config.adapter?.postbuild?.(resolvedViteConfig);
|
|
362
|
+
await postbuild(resolvedViteConfig.root, {
|
|
363
|
+
csp: config.csp,
|
|
364
|
+
load,
|
|
365
|
+
});
|
|
360
366
|
},
|
|
361
367
|
},
|
|
362
368
|
};
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { createContext, useCallback, useEffect, useMemo, useRef } from 'react';
|
|
4
4
|
import { createFromFetch } from '@vitejs/plugin-rsc/browser';
|
|
5
|
+
import { getDocumentNonce } from '../../utils/get-document-nonce.js';
|
|
5
6
|
import { Logger } from '../../utils/logger.js';
|
|
6
7
|
import * as Events from '../../events.js';
|
|
7
8
|
import { BrowserRouterHistory } from './history.js';
|
|
@@ -43,9 +44,14 @@ function getPayloadUrl(path) {
|
|
|
43
44
|
return url;
|
|
44
45
|
}
|
|
45
46
|
function performNavigationFetch(path, init) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
if (STATIC)
|
|
48
|
+
return fetch(getPayloadUrl(path), init);
|
|
49
|
+
const headers = new Headers(init?.headers);
|
|
50
|
+
headers.set('accept', 'text/x-component');
|
|
51
|
+
const nonce = getDocumentNonce();
|
|
52
|
+
if (nonce)
|
|
53
|
+
headers.set('x-solas-nonce', nonce);
|
|
54
|
+
return fetch(path, { ...init, headers });
|
|
49
55
|
}
|
|
50
56
|
const MAX_HASH_ATTEMPTS = 20;
|
|
51
57
|
function nextFrame(callback) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { CspDirectives } from '../types.js';
|
|
2
|
+
type PolicyInput = {
|
|
3
|
+
nonce?: string;
|
|
4
|
+
hashes?: CspHashes;
|
|
5
|
+
};
|
|
6
|
+
export type CspHashes = {
|
|
7
|
+
script: string[];
|
|
8
|
+
style: string[];
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Resolve the nonce for a request.
|
|
12
|
+
*
|
|
13
|
+
* On RSC and action requests a document nonce supplied by the client wins.
|
|
14
|
+
* Otherwise the request CSP is authoritative, even when it has no nonce, and
|
|
15
|
+
* configured headers are the fallback.
|
|
16
|
+
*/
|
|
17
|
+
export declare function resolveNonce(requestCsp: string | null | undefined, configuredHeaders: Record<string, string>, clientNonce?: string | null): string | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Build a policy from structured directives, adding a nonce and any prerender
|
|
20
|
+
* hashes that apply to the response.
|
|
21
|
+
*/
|
|
22
|
+
export declare function buildPolicy(directives: CspDirectives, { nonce, hashes }?: PolicyInput): string;
|
|
23
|
+
/**
|
|
24
|
+
* Add hash sources to the directives that control inline scripts and styles.
|
|
25
|
+
* A policy that already allows everything through `'unsafe-inline'` or
|
|
26
|
+
* `'none'` is left alone, so adding a hash cannot silently tighten it.
|
|
27
|
+
*/
|
|
28
|
+
export declare function addHashesToPolicy(policy: string, hashes: CspHashes): string;
|
|
29
|
+
/**
|
|
30
|
+
* Add the request nonce to the PPR bootstrap script.
|
|
31
|
+
*/
|
|
32
|
+
export declare function addNonceToBootstrap(html: string, nonce: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* Add a CSP meta tag to a static document.
|
|
35
|
+
*/
|
|
36
|
+
export declare function injectMetaTag(html: string, policy: string): string;
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
const KEYWORDS = new Set([
|
|
2
|
+
'self',
|
|
3
|
+
'none',
|
|
4
|
+
'unsafe-inline',
|
|
5
|
+
'unsafe-eval',
|
|
6
|
+
'unsafe-hashes',
|
|
7
|
+
'strict-dynamic',
|
|
8
|
+
'report-sample',
|
|
9
|
+
'wasm-unsafe-eval',
|
|
10
|
+
'inline-speculation-rules',
|
|
11
|
+
'unsafe-allow-redirects',
|
|
12
|
+
]);
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the nonce for a request.
|
|
15
|
+
*
|
|
16
|
+
* On RSC and action requests a document nonce supplied by the client wins.
|
|
17
|
+
* Otherwise the request CSP is authoritative, even when it has no nonce, and
|
|
18
|
+
* configured headers are the fallback.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveNonce(requestCsp, configuredHeaders, clientNonce) {
|
|
21
|
+
if (clientNonce)
|
|
22
|
+
return clientNonce;
|
|
23
|
+
if (requestCsp)
|
|
24
|
+
return extractNonce(requestCsp);
|
|
25
|
+
return extractNonce(new Headers(configuredHeaders).get('content-security-policy'));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Build a policy from structured directives, adding a nonce and any prerender
|
|
29
|
+
* hashes that apply to the response.
|
|
30
|
+
*/
|
|
31
|
+
export function buildPolicy(directives, { nonce, hashes } = {}) {
|
|
32
|
+
const parsed = Object.entries(directives).map(([name, values]) => ({
|
|
33
|
+
name: name.toLowerCase(),
|
|
34
|
+
// keywords such as `self` can be written without quotes; hosts and schemes
|
|
35
|
+
// stay as written.
|
|
36
|
+
tokens: values.map(value => {
|
|
37
|
+
const token = value.trim();
|
|
38
|
+
if (token.length >= 2 && token.startsWith("'") && token.endsWith("'")) {
|
|
39
|
+
return token;
|
|
40
|
+
}
|
|
41
|
+
if (KEYWORDS.has(token.toLowerCase()))
|
|
42
|
+
return `'${token.toLowerCase()}'`;
|
|
43
|
+
if (/^(sha256|sha384|sha512|nonce)-/.test(token))
|
|
44
|
+
return `'${token}'`;
|
|
45
|
+
return token;
|
|
46
|
+
}),
|
|
47
|
+
}));
|
|
48
|
+
if (nonce) {
|
|
49
|
+
const token = `'nonce-${nonce}'`;
|
|
50
|
+
addNonceToDirective(parsed, ['script-src-elem', 'script-src', 'default-src'], token);
|
|
51
|
+
addNonceToDirective(parsed, ['style-src-elem', 'style-src', 'default-src'], token);
|
|
52
|
+
}
|
|
53
|
+
const policy = parsed.map(formatDirective).join('; ');
|
|
54
|
+
return hashes ? addHashesToPolicy(policy, hashes) : policy;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Add hash sources to the directives that control inline scripts and styles.
|
|
58
|
+
* A policy that already allows everything through `'unsafe-inline'` or
|
|
59
|
+
* `'none'` is left alone, so adding a hash cannot silently tighten it.
|
|
60
|
+
*/
|
|
61
|
+
export function addHashesToPolicy(policy, hashes) {
|
|
62
|
+
const directives = policy
|
|
63
|
+
.split(';')
|
|
64
|
+
.map(part => part.trim())
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.map(parseDirective);
|
|
67
|
+
addHashesToDirective(directives, 'script', hashes);
|
|
68
|
+
addHashesToDirective(directives, 'style', hashes);
|
|
69
|
+
return directives.map(formatDirective).join('; ');
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Add the request nonce to the PPR bootstrap script.
|
|
73
|
+
*/
|
|
74
|
+
export function addNonceToBootstrap(html, nonce) {
|
|
75
|
+
const match = /<script\b[^>]*\bid="_R_"[^>]*>/i.exec(html);
|
|
76
|
+
if (!match || /\bnonce\s*=/i.test(match[0]))
|
|
77
|
+
return html;
|
|
78
|
+
const tag = match[0].replace(/>$/, ` nonce="${escapeAttribute(nonce)}">`);
|
|
79
|
+
return html.slice(0, match.index) + tag + html.slice(match.index + match[0].length);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Add a CSP meta tag to a static document.
|
|
83
|
+
*/
|
|
84
|
+
export function injectMetaTag(html, policy) {
|
|
85
|
+
if (/<meta\b[^>]*http-equiv=["']?content-security-policy/i.test(html)) {
|
|
86
|
+
return html;
|
|
87
|
+
}
|
|
88
|
+
const head = /<head\b[^>]*>/i.exec(html);
|
|
89
|
+
if (!head)
|
|
90
|
+
return html;
|
|
91
|
+
const headEnd = head.index + head[0].length;
|
|
92
|
+
const charset = /<meta\b[^>]*charset=[^>]*>/i.exec(html.slice(headEnd));
|
|
93
|
+
const tag = `<meta http-equiv="Content-Security-Policy" content="${escapeAttribute(policy)}">`;
|
|
94
|
+
const at = charset ? headEnd + charset.index + charset[0].length : headEnd;
|
|
95
|
+
return html.slice(0, at) + tag + html.slice(at);
|
|
96
|
+
}
|
|
97
|
+
function extractNonce(csp) {
|
|
98
|
+
return csp?.match(/'nonce-([^']+)'/)?.[1] || undefined;
|
|
99
|
+
}
|
|
100
|
+
function addNonceToDirective(directives, names, token) {
|
|
101
|
+
const directive = names
|
|
102
|
+
.map(name => directives.find(candidate => candidate.name === name))
|
|
103
|
+
.find(Boolean);
|
|
104
|
+
if (!directive)
|
|
105
|
+
return;
|
|
106
|
+
if (directive.tokens.some(value => value.toLowerCase() === "'unsafe-inline'" || value.toLowerCase() === "'none'")) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (!directive.tokens.includes(token))
|
|
110
|
+
directive.tokens.push(token);
|
|
111
|
+
}
|
|
112
|
+
function addHashesToDirective(directives, kind, hashes) {
|
|
113
|
+
const values = hashes[kind];
|
|
114
|
+
if (values.length === 0)
|
|
115
|
+
return;
|
|
116
|
+
const names = kind === 'script'
|
|
117
|
+
? ['script-src-elem', 'script-src', 'default-src']
|
|
118
|
+
: ['style-src-elem', 'style-src', 'default-src'];
|
|
119
|
+
const directive = names
|
|
120
|
+
.map(name => directives.find(candidate => candidate.name === name))
|
|
121
|
+
.find(Boolean);
|
|
122
|
+
if (!directive)
|
|
123
|
+
return;
|
|
124
|
+
if (directive.tokens.some(token => token.toLowerCase() === "'unsafe-inline'" || token.toLowerCase() === "'none'")) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
for (const value of values) {
|
|
128
|
+
if (!directive.tokens.includes(value))
|
|
129
|
+
directive.tokens.push(value);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function parseDirective(value) {
|
|
133
|
+
const [name, ...tokens] = value.split(/\s+/);
|
|
134
|
+
return { name: name.toLowerCase(), tokens };
|
|
135
|
+
}
|
|
136
|
+
function formatDirective(directive) {
|
|
137
|
+
return [directive.name, ...directive.tokens].join(' ');
|
|
138
|
+
}
|
|
139
|
+
function escapeAttribute(value) {
|
|
140
|
+
return value
|
|
141
|
+
.replace(/&/g, '&')
|
|
142
|
+
.replace(/"/g, '"')
|
|
143
|
+
.replace(/</g, '<')
|
|
144
|
+
.replace(/>/g, '>');
|
|
145
|
+
}
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { StrictMode, Suspense, useCallback, useState, useTransition } from 'react';
|
|
3
3
|
import { hydrateRoot } from 'react-dom/client';
|
|
4
4
|
import { createFromFetch, createFromReadableStream, createTemporaryReferenceSet, encodeReply, setServerCallback, } from '@vitejs/plugin-rsc/browser';
|
|
5
|
+
import { getDocumentNonce } from '../../utils/get-document-nonce.js';
|
|
5
6
|
import { BrowserRouterProvider } from '../browser-router/router.js';
|
|
6
7
|
import { RedirectBoundary } from '../navigation/redirect-boundary.js';
|
|
7
8
|
import { Head } from '../render/head.js';
|
|
@@ -34,12 +35,14 @@ export async function browser() {
|
|
|
34
35
|
setServerCallback(async (id, args) => {
|
|
35
36
|
const url = new URL(window.location.href);
|
|
36
37
|
const temporaryReferences = createTemporaryReferenceSet();
|
|
38
|
+
const headers = new Headers({ 'x-rsc-action-id': id });
|
|
39
|
+
const nonce = getDocumentNonce();
|
|
40
|
+
if (nonce)
|
|
41
|
+
headers.set('x-solas-nonce', nonce);
|
|
37
42
|
const payload = await createFromFetch(fetch(url, {
|
|
38
43
|
method: 'POST',
|
|
39
44
|
body: await encodeReply(args, { temporaryReferences }),
|
|
40
|
-
headers
|
|
41
|
-
'x-rsc-action-id': id,
|
|
42
|
-
},
|
|
45
|
+
headers,
|
|
43
46
|
}), { temporaryReferences });
|
|
44
47
|
payloadSetter.current(payload);
|
|
45
48
|
const { ok, data } = payload.returnValue ?? {};
|
|
@@ -53,7 +56,11 @@ export async function browser() {
|
|
|
53
56
|
if (import.meta.hot) {
|
|
54
57
|
import.meta.hot.on?.('rsc:update', async () => {
|
|
55
58
|
try {
|
|
56
|
-
const
|
|
59
|
+
const headers = new Headers({ Accept: 'text/x-component' });
|
|
60
|
+
const nonce = getDocumentNonce();
|
|
61
|
+
if (nonce)
|
|
62
|
+
headers.set('x-solas-nonce', nonce);
|
|
63
|
+
const p = await createFromFetch(fetch(window.location.href, { headers }));
|
|
57
64
|
payloadSetter.current(p);
|
|
58
65
|
}
|
|
59
66
|
catch (err) {
|
|
@@ -10,10 +10,12 @@ export declare const RequestContext: {
|
|
|
10
10
|
req: SolasRequest;
|
|
11
11
|
prerender: "full" | "ppr" | null;
|
|
12
12
|
cache: RequestCache;
|
|
13
|
+
nonce?: string | undefined;
|
|
13
14
|
};
|
|
14
15
|
write<R>(value: {
|
|
15
16
|
req: SolasRequest;
|
|
16
17
|
prerender: "full" | "ppr" | null;
|
|
17
18
|
cache: RequestCache;
|
|
19
|
+
nonce?: string | undefined;
|
|
18
20
|
}, fn: () => R | Promise<R>): R | Promise<R>;
|
|
19
21
|
};
|