proteum 2.6.0 → 2.7.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/README.md +3 -0
- package/cli/compiler/client/index.ts +10 -5
- package/cli/compiler/client/outputNames.ts +28 -0
- package/cli/compiler/common/files/autres.ts +3 -1
- package/cli/scaffold/templates.ts +4 -0
- package/client/app/staleBuild.ts +80 -0
- package/client/services/router/index.tsx +10 -0
- package/docs/HISTORY.md +8 -0
- package/docs/client-assets.md +37 -0
- package/docs/http-security-headers.md +114 -0
- package/package.json +2 -1
- package/server/services/router/http/cache.ts +4 -1
- package/server/services/router/http/index.ts +26 -25
- package/server/services/router/http/securityHeaders.ts +343 -0
- package/tests/app-shaped-typecheck.test.cjs +74 -0
- package/tests/client-output-names.test.cjs +114 -0
- package/tests/http-server-security-headers.test.cjs +669 -0
- package/tests/router-cache-config.test.cjs +32 -0
- package/tests/stale-build-recovery.test.cjs +206 -0
- package/types/global/vendors.d.ts +9 -0
- package/types/vendors.d.ts +9 -0
package/README.md
CHANGED
|
@@ -276,13 +276,16 @@ my-app/
|
|
|
276
276
|
| Topic | Guide |
|
|
277
277
|
| --- | --- |
|
|
278
278
|
| Client navigation (blocking, deferred, prefetch) | [docs/client-navigation.md](docs/client-navigation.md) |
|
|
279
|
+
| Client assets (hashed names, cache headers, stale builds) | [docs/client-assets.md](docs/client-assets.md) |
|
|
279
280
|
| Diagnostics & explainability | [docs/diagnostics.md](docs/diagnostics.md) |
|
|
280
281
|
| Model Context Protocol (MCP) | [docs/mcp.md](docs/mcp.md) |
|
|
281
282
|
| Request tracing & perf | [docs/request-tracing.md](docs/request-tracing.md) |
|
|
283
|
+
| HTTP security headers & CSP | [docs/http-security-headers.md](docs/http-security-headers.md) |
|
|
282
284
|
| Agent routing & token efficiency | [docs/agent-routing.md](docs/agent-routing.md) |
|
|
283
285
|
| Dev commands | [docs/dev-commands.md](docs/dev-commands.md) |
|
|
284
286
|
| Dev sessions | [docs/dev-sessions.md](docs/dev-sessions.md) |
|
|
285
287
|
| Migrating to 2.5 | [docs/migration-2.5.md](docs/migration-2.5.md) |
|
|
288
|
+
| Release history | [docs/HISTORY.md](docs/HISTORY.md) |
|
|
286
289
|
|
|
287
290
|
## 🧭 Philosophy
|
|
288
291
|
|
|
@@ -15,6 +15,7 @@ import CssMinimizerPlugin from '../common/cssMinimizer';
|
|
|
15
15
|
import { toRspackAliases } from '../common/rspackAliases';
|
|
16
16
|
import { resolveUiSingletonAliases } from '../common/uiSingletons';
|
|
17
17
|
import identityAssets from './identite';
|
|
18
|
+
import { getClientOutputNames } from './outputNames';
|
|
18
19
|
import cli from '../..';
|
|
19
20
|
import { logVerbose } from '../../runtime/verbose';
|
|
20
21
|
|
|
@@ -126,6 +127,8 @@ export default function createCompiler(
|
|
|
126
127
|
const transpileModuleDirectories = app.transpileModuleDirectories;
|
|
127
128
|
const lightningCssTargets = resolveLightningCssTargets(app);
|
|
128
129
|
const hmrClientEntry = path.join(frameworkSourceRoot, 'client', 'dev', 'hmr.ts');
|
|
130
|
+
// Content-hashed in production, entries included: see outputNames.ts.
|
|
131
|
+
const outputNames = getClientOutputNames(dev);
|
|
129
132
|
|
|
130
133
|
const commonConfig = createCommonConfig(app, 'client', mode, outputTarget);
|
|
131
134
|
|
|
@@ -172,7 +175,7 @@ export default function createCompiler(
|
|
|
172
175
|
output: {
|
|
173
176
|
pathinfo: dev,
|
|
174
177
|
path: outputPath + '/public',
|
|
175
|
-
filename:
|
|
178
|
+
filename: outputNames.entry,
|
|
176
179
|
assetModuleFilename: '[hash][ext]',
|
|
177
180
|
environment: {
|
|
178
181
|
arrowFunction: true,
|
|
@@ -185,9 +188,9 @@ export default function createCompiler(
|
|
|
185
188
|
optionalChaining: true,
|
|
186
189
|
templateLiteral: true,
|
|
187
190
|
},
|
|
188
|
-
cssFilename:
|
|
189
|
-
cssChunkFilename:
|
|
190
|
-
chunkFilename:
|
|
191
|
+
cssFilename: outputNames.css,
|
|
192
|
+
cssChunkFilename: outputNames.css,
|
|
193
|
+
chunkFilename: outputNames.chunk,
|
|
191
194
|
},
|
|
192
195
|
|
|
193
196
|
resolve: {
|
|
@@ -290,7 +293,9 @@ export default function createCompiler(
|
|
|
290
293
|
]),
|
|
291
294
|
|
|
292
295
|
// Extract CSS in dev too so SSR emits the same stylesheet links as production.
|
|
293
|
-
|
|
296
|
+
// The extract plugin names its files itself (its default is `[name].css`); `output.cssFilename`
|
|
297
|
+
// only applies to native CSS, so the hashed name is passed here.
|
|
298
|
+
new rspack.CssExtractRspackPlugin({ filename: outputNames.css, chunkFilename: outputNames.css }),
|
|
294
299
|
|
|
295
300
|
...createClientBundleAnalysisPlugins(app, outputTarget),
|
|
296
301
|
],
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/*----------------------------------
|
|
2
|
+
- CLIENT OUTPUT NAMES
|
|
3
|
+
----------------------------------*/
|
|
4
|
+
|
|
5
|
+
export type TClientOutputNames = {
|
|
6
|
+
// Entry files: `runtime`, `framework`, `vendors`, `client`
|
|
7
|
+
entry: string;
|
|
8
|
+
// Async JS chunks
|
|
9
|
+
chunk: string;
|
|
10
|
+
// Entry and page CSS
|
|
11
|
+
css: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* Production files carry a content hash, entries included. An unhashed `runtime.js` that a
|
|
16
|
+
* browser or CDN keeps after a deploy still maps chunk ids to files that no longer exist
|
|
17
|
+
* (21 Oct 2026: a 4 h CDN browser TTL served the old runtime, its chunks 404ed). The same holds
|
|
18
|
+
* for CSS: a page chunk keeps its name across builds, so new HTML could pair with a stylesheet
|
|
19
|
+
* from the previous build. The HTML reads every name from `client-manifest.json`, so nothing
|
|
20
|
+
* names these files, and the router's public cache classifies `name.<hash>.ext` as versioned.
|
|
21
|
+
*
|
|
22
|
+
* Dev keeps stable names: HMR and the dev middleware rely on them, and dev serves `no-store`.
|
|
23
|
+
* `[name]` stays before the hash so the network panel and bundle analysis stay readable.
|
|
24
|
+
*/
|
|
25
|
+
export const getClientOutputNames = (dev: boolean): TClientOutputNames =>
|
|
26
|
+
dev
|
|
27
|
+
? { entry: '[name].js', chunk: '[name].js', css: '[name].css' }
|
|
28
|
+
: { entry: '[name].[contenthash:8].js', chunk: '[id].[contenthash:8].js', css: '[name].[contenthash:8].css' };
|
|
@@ -23,6 +23,8 @@ module.exports = (app: App, dev: boolean, client: boolean) => [
|
|
|
23
23
|
test: /\.(woff(2)?|ttf|eot)(\?v=\d+\.\d+\.\d+)?$/,
|
|
24
24
|
//loader: 'file-loader',
|
|
25
25
|
type: 'asset/resource',
|
|
26
|
-
|
|
26
|
+
// Hashed so the router serves fonts immutable. `[ext]` carries its own dot: the former
|
|
27
|
+
// `[name].[ext]` emitted `figtree-latin..woff2`, unhashed and revalidated on every load.
|
|
28
|
+
generator: { filename: 'fonts/[name].[contenthash:8][ext]' },
|
|
27
29
|
},
|
|
28
30
|
];
|
|
@@ -218,6 +218,10 @@ export const routerBaseConfig = {
|
|
|
218
218
|
upload: {
|
|
219
219
|
maxSize: '10mb',
|
|
220
220
|
},
|
|
221
|
+
// Content-Security-Policy for router answers (HTML only under /api): see Proteum's docs/http-security-headers.md.
|
|
222
|
+
csp: {
|
|
223
|
+
scripts: [],
|
|
224
|
+
},
|
|
221
225
|
},
|
|
222
226
|
context: () => ({}),
|
|
223
227
|
} satisfies RouterBaseConfig;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/*----------------------------------
|
|
2
|
+
- STALE BUILD RECOVERY
|
|
3
|
+
----------------------------------*/
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* A page running an older build asks for chunk files that a deploy removed. The only cure is a
|
|
7
|
+
* fresh document: the new HTML names the new, content-hashed entry files, so a reload loads the
|
|
8
|
+
* new build. One reload per build id, kept in sessionStorage, so a chunk that is missing in the
|
|
9
|
+
* new build too (a deploy race, a CDN that kept a 404) ends on the error instead of a loop.
|
|
10
|
+
*
|
|
11
|
+
* - Every build id that reloaded is kept, not only the last one: during a rollout two builds can
|
|
12
|
+
* answer at once, and a single slot let the tab bounce between them for as long as it lasted.
|
|
13
|
+
* - A route chunk that loads clears the list, so a reload spent on a network blip does not take
|
|
14
|
+
* the recovery away from the real deploy that comes later in the same tab.
|
|
15
|
+
* - Offline, a chunk fails because there is no network, not because the build is gone: no reload,
|
|
16
|
+
* which would only swap the page for the browser's offline screen.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const STALE_BUILD_RELOAD_KEY = 'proteum:stale-build-reload';
|
|
20
|
+
|
|
21
|
+
// A rollout overlaps two builds at most; the cap only bounds what a long-lived tab can store
|
|
22
|
+
const MAX_RELOADED_BUILDS = 5;
|
|
23
|
+
|
|
24
|
+
type TReloadStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
|
25
|
+
|
|
26
|
+
export type TStaleBuildReloadOptions = {
|
|
27
|
+
storage?: () => TReloadStorage;
|
|
28
|
+
reload?: () => void;
|
|
29
|
+
isOnline?: () => boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const defaultStorage = () => window.sessionStorage;
|
|
33
|
+
|
|
34
|
+
const readReloadedBuilds = (store: TReloadStorage) => (store.getItem(STALE_BUILD_RELOAD_KEY) || '').split(',').filter(Boolean);
|
|
35
|
+
|
|
36
|
+
// The shapes rspack's runtime rejects with: `ChunkLoadError` for a JS chunk, the code for a CSS chunk.
|
|
37
|
+
export const isChunkLoadError = (error: Error) =>
|
|
38
|
+
error.name === 'ChunkLoadError' || ('code' in error && error.code === 'CSS_CHUNK_LOAD_FAILED');
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Reloads the page once for `buildId` and returns true, or returns false when this build already
|
|
42
|
+
* reloaded in this tab, when the browser is offline, or when sessionStorage is unavailable (no
|
|
43
|
+
* guard means no reload).
|
|
44
|
+
*/
|
|
45
|
+
export const reloadOnceForStaleBuild = (
|
|
46
|
+
buildId: string,
|
|
47
|
+
{
|
|
48
|
+
storage = defaultStorage,
|
|
49
|
+
reload = () => window.location.reload(),
|
|
50
|
+
isOnline = () => window.navigator.onLine !== false,
|
|
51
|
+
}: TStaleBuildReloadOptions = {},
|
|
52
|
+
): boolean => {
|
|
53
|
+
if (!isOnline()) return false;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const store = storage();
|
|
57
|
+
const reloaded = readReloadedBuilds(store);
|
|
58
|
+
if (reloaded.includes(buildId)) return false;
|
|
59
|
+
|
|
60
|
+
store.setItem(STALE_BUILD_RELOAD_KEY, [...reloaded, buildId].slice(-MAX_RELOADED_BUILDS).join(','));
|
|
61
|
+
} catch (error) {
|
|
62
|
+
// Storage blocked (private mode, sandboxed frame, quota): without the guard a reload could loop
|
|
63
|
+
console.warn('Stale build recovery skipped: sessionStorage is unavailable.', error);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
reload();
|
|
68
|
+
return true;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** A route chunk loaded: the running build is whole, so a later missing chunk gets its reload again. */
|
|
72
|
+
export const clearStaleBuildReloads = (storage: () => TReloadStorage = defaultStorage) => {
|
|
73
|
+
try {
|
|
74
|
+
const store = storage();
|
|
75
|
+
if (store.getItem(STALE_BUILD_RELOAD_KEY) !== null) store.removeItem(STALE_BUILD_RELOAD_KEY);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
// Nothing to clear when storage is blocked: the reload was never granted either
|
|
78
|
+
console.warn('Stale build recovery could not reset its guard.', error);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
@@ -36,6 +36,7 @@ import type { TFrontRenderer, TPageDataProvider, TPageRenderer } from '@common/r
|
|
|
36
36
|
import App from '@client/app/component';
|
|
37
37
|
import type ClientApplication from '@client/app';
|
|
38
38
|
import Service from '@client/app/service';
|
|
39
|
+
import { clearStaleBuildReloads, isChunkLoadError, reloadOnceForStaleBuild } from '@client/app/staleBuild';
|
|
39
40
|
|
|
40
41
|
// Specific
|
|
41
42
|
import ClientRequest, { isClientRequest } from './request';
|
|
@@ -381,6 +382,8 @@ export default class ClientRouter<
|
|
|
381
382
|
|
|
382
383
|
debug && console.log(`Route fetched: ${route.chunk}`, fetched);
|
|
383
384
|
withProfiler((runtime) => runtime.finishStep(stepId));
|
|
385
|
+
// The running build is whole: a later missing chunk (the next deploy) gets its reload again
|
|
386
|
+
clearStaleBuildReloads();
|
|
384
387
|
|
|
385
388
|
return fetched;
|
|
386
389
|
} catch (e) {
|
|
@@ -388,6 +391,13 @@ export default class ClientRouter<
|
|
|
388
391
|
runtime.finishStep(stepId, 'error', e instanceof Error ? e.message : String(e)),
|
|
389
392
|
);
|
|
390
393
|
console.error(`Failed to fetch the route ${route.chunk}`, e);
|
|
394
|
+
|
|
395
|
+
// A missing chunk means this page runs a build that a deploy replaced: one reload loads
|
|
396
|
+
// the new one. Settling would render the error page for a frame before the reload, so the
|
|
397
|
+
// navigation waits for the document to be replaced. See client/app/staleBuild.ts.
|
|
398
|
+
if (e instanceof Error && isChunkLoadError(e) && reloadOnceForStaleBuild(String(BUILD_ID)))
|
|
399
|
+
return new Promise<never>(() => {});
|
|
400
|
+
|
|
391
401
|
try {
|
|
392
402
|
this.app.handleUpdate();
|
|
393
403
|
} catch (error) {}
|
package/docs/HISTORY.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# History
|
|
2
|
+
|
|
3
|
+
Notable framework changes, newest first. Commit messages hold the rest.
|
|
4
|
+
|
|
5
|
+
## 2.7.0 (2026-11-12)
|
|
6
|
+
|
|
7
|
+
- Security headers on every answer of Proteum's stack: helmet now runs first in `HttpServer.start()`, before the `/api` fast path, so answers under `/api` carry the base headers and no `X-Powered-By`. Under `/api`, HTML gets the app policy, other answers helmet's default policy, and a 304 none; outside `/api` every router answer keeps the app policy as before, and a route's own policy still wins. `http.csp` gains `directives`, `policy` and `reportOnly` (sent wherever the app policy goes), and a malformed policy now fails at boot. Nothing to change in apps to keep today's policy. See [http-security-headers.md](http-security-headers.md).
|
|
8
|
+
- Client assets (the changes committed as 2.6.1, never published on their own): production entry files, entry and page CSS and fonts are named with a content hash and served immutable, responsive images are served immutable, a `/public` 404 answers `Cache-Control: no-store`, and a page reloads once per build when a deploy removed a chunk it needs. Code or tests that name `client.js`, `client.css`, `runtime.js`, `framework.js` or `vendors.js` must read the names from the manifest. See [client-assets.md](client-assets.md).
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Client assets: names, caching and stale builds
|
|
2
|
+
|
|
3
|
+
## File names
|
|
4
|
+
|
|
5
|
+
| Output | Dev | Production |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| Entry JS (`runtime`, `framework`, `vendors`, `client`) | `[name].js` | `[name].[contenthash:8].js` |
|
|
8
|
+
| Async JS chunks | `[name].js` | `[id].[contenthash:8].js` |
|
|
9
|
+
| Entry and page CSS | `[name].css` | `[name].[contenthash:8].css` |
|
|
10
|
+
| Fonts | `fonts/[name].[contenthash:8][ext]` | `fonts/[name].[contenthash:8][ext]` |
|
|
11
|
+
| Images (responsive-loader, production) | | `<hash>-<width>.<ext>` |
|
|
12
|
+
| Other modules | `[hash][ext]` | `[hash][ext]` |
|
|
13
|
+
|
|
14
|
+
Sources: `cli/compiler/client/outputNames.ts` and `cli/compiler/common/files/*.ts`. Entries, CSS and fonts carry a hash since 2.6.1.
|
|
15
|
+
|
|
16
|
+
Nothing may name these files by hand. The server reads every entry and page asset from `client-manifest.json` (written after each client build by `cli/compiler/common/clientManifest.ts`), and the document renders the tags from it.
|
|
17
|
+
|
|
18
|
+
## Cache headers
|
|
19
|
+
|
|
20
|
+
`/public` files are served by the router with `cache.publicAssets` (`server/services/router/http/cache.ts`):
|
|
21
|
+
|
|
22
|
+
- `versioned` (default `public, max-age=31536000, immutable`): a file whose name ends in `.<hex hash>.<ext>` or `<hex hash>-<width>.<ext>`, or a request with `?v=`. In production this covers every entry, chunk, stylesheet, font and responsive image the build emits.
|
|
23
|
+
- `unversioned` (default `public, max-age=0, must-revalidate`): any other file, mostly what the app ships in its own `public/` folder (icons, Open Graph images, videos).
|
|
24
|
+
- `dev` (default `no-store`).
|
|
25
|
+
- A `/public` 404 is always `no-store`, so a CDN never keeps a 404 for a file that a newer instance serves.
|
|
26
|
+
|
|
27
|
+
A CDN can rewrite these headers. Cloudflare's zone setting Browser Cache TTL, for example, raises any lower `max-age` to its value (4 hours by default) unless it is set to "Respect Existing Headers". Hashed names make that harmless for the files the HTML names; an app that wants the unversioned files never cached can set `unversioned` to `no-store`.
|
|
28
|
+
|
|
29
|
+
## Stale builds
|
|
30
|
+
|
|
31
|
+
A tab opened before a deploy runs the previous build. When it navigates to a page whose chunk the deploy removed, the chunk request fails with a `ChunkLoadError` (or a CSS chunk error). The client router then reloads the page once (`client/app/staleBuild.ts`): the new HTML names the new hashed files, so the reload runs the new build.
|
|
32
|
+
|
|
33
|
+
- One reload per build id, remembered in `sessionStorage` under `proteum:stale-build-reload` (every build id that reloaded, so two builds answering during a rollout reload once each). If the same build misses a chunk again, or `sessionStorage` is unavailable, the router calls the app's `handleUpdate()` and throws "A new version of the website is available. Please refresh the page.", as it does for any other route load failure.
|
|
34
|
+
- A route chunk that loads clears the list, so a reload spent on a network blip does not take the recovery away from a real deploy later in the same tab.
|
|
35
|
+
- Offline (`navigator.onLine === false`) a failed chunk is the network: no reload, the error shows.
|
|
36
|
+
- A route module that loaded but failed for another reason never reloads; it goes straight to `handleUpdate()`.
|
|
37
|
+
- `handleUpdate()` should not reload on its own without a guard: the router has already made the one reload, and an unguarded second one can loop.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# HTTP Security Headers
|
|
2
|
+
|
|
3
|
+
Proteum's HTTP server sends the same security headers on every answer of its own stack, whatever the path, and picks the Content-Security-Policy by the path and, under `/api`, by the kind of answer at the moment the headers are written. Middleware an app mounts on the Express app before `HttpServer.start()` (a maintenance page, for example) answers before that stack and carries none of these headers, as in 2.6.
|
|
4
|
+
|
|
5
|
+
Source: `server/services/router/http/index.ts` (`HttpServer.start()`) and `server/services/router/http/securityHeaders.ts`. Tests: `tests/http-server-security-headers.test.cjs`.
|
|
6
|
+
|
|
7
|
+
## The Contract
|
|
8
|
+
|
|
9
|
+
The rule in one sentence: outside `/api` no router answer changes its policy (every one carries the app policy, as in 2.6); under `/api`, HTML gets the app policy and everything else gets helmet's default.
|
|
10
|
+
|
|
11
|
+
| Answer | Base headers | `Content-Security-Policy` | Report-only policy | `X-Powered-By` |
|
|
12
|
+
|---|---|---|---|---|
|
|
13
|
+
| Any answer from the router outside `/api` (pages, their 404, the error page, a cached static page, JSON, text, Markdown, feeds, a worker script a route serves) | yes | the app policy | the app's, when configured | absent |
|
|
14
|
+
| HTML from the router under `/api` (pages, their 404, the error page, a cached static page) | yes | the app policy | the app's, when configured | absent |
|
|
15
|
+
| Any other answer from the router under `/api` (JSON, Markdown, text, scripts, the router's redirects) | yes | helmet's default policy | none | absent |
|
|
16
|
+
| A 304 Not Modified from the router under `/api` | yes | none: the browser keeps the policy its stored copy came with | none | absent |
|
|
17
|
+
| A route or controller that set its own `Content-Security-Policy` | yes | the route's own, untouched | none added | absent |
|
|
18
|
+
| A body-parser error under `/api`, before the router (for example a malformed JSON body) | yes | Express's own `default-src 'none'` | none | absent |
|
|
19
|
+
| Static files (`/public/*`, `/robots.txt` and `/favicon.ico` when the file exists), `/ping` | yes | helmet's default policy | none | absent |
|
|
20
|
+
|
|
21
|
+
When the app ships no `robots.txt` or `favicon.ico`, the request falls through to the router and its answer carries the app policy, as in 2.6. A redirect under `/api` that is typed `text/html` (one sent from the router's static page cache branch, or Express's `res.redirect` answered to a browser) counts as HTML and gets the app policy; the router's own redirects carry no `Content-Type` and keep helmet's default.
|
|
22
|
+
|
|
23
|
+
"HTML" means a `Content-Type` of `text/html` or `application/xhtml+xml`. Outside `/api` the type does not matter, on purpose: a worker takes its policy from its own script's answer, so a worker served by a route (for example a map library's worker that fetches tiles from another host) must keep the app policy.
|
|
24
|
+
|
|
25
|
+
"Base headers" are helmet 4.6's defaults: `Strict-Transport-Security: max-age=15552000; includeSubDomains`, `X-Frame-Options: SAMEORIGIN`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `X-DNS-Prefetch-Control: off`, `Expect-CT: max-age=0`, `X-Download-Options: noopen`, `X-Permitted-Cross-Domain-Policies: none`, `X-XSS-Protection: 0`. `http.helmet` changes them (for example `referrerPolicy`). helmet 4.6 sends no `Cross-Origin-*` header, and the tests pin that.
|
|
26
|
+
|
|
27
|
+
helmet's default policy is `default-src 'self';base-uri 'self';block-all-mixed-content;font-src 'self' https: data:;frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests` (256 bytes). `http.helmet.contentSecurityPolicy` replaces it; with `false`, the fallback answers carry no policy, and the others still get the app policy.
|
|
28
|
+
|
|
29
|
+
How it is wired:
|
|
30
|
+
|
|
31
|
+
- `x-powered-by` is disabled on the Express app, and `helmet(http.helmet)` is mounted once, first in `start()`, so every answer of Proteum's stack starts with the base headers and helmet's default policy.
|
|
32
|
+
- Under `/api`, a policy writer is mounted before the API fast path (the short middleware stack `/api` requests take, ahead of the static files). It runs when the headers are about to be written, so it knows the final status and `Content-Type`:
|
|
33
|
+
- when the `Content-Security-Policy` value is no longer the one helmet wrote, a route chose its own: everything is left as it is;
|
|
34
|
+
- when the status is 304: the policy header is removed, so the browser keeps the policy of the copy it stored (helmet's default there would replace a page's policy);
|
|
35
|
+
- when the answer is HTML: the app policy, plus the report-only policy when one is configured and the route did not set one;
|
|
36
|
+
- otherwise: helmet's default stays.
|
|
37
|
+
- Outside `/api`, after the static files and before the router, the app policy replaces helmet's default at the start of every request, as in 2.6. When a report-only policy is configured, it is added when the headers are written, if the answer still carries the app policy.
|
|
38
|
+
- Static files are mounted before that, so they keep helmet's headers byte for byte, an `.html` or `.js` file under `/public` included.
|
|
39
|
+
|
|
40
|
+
Rule: a route's own policy wins. A route or controller that sets `Content-Security-Policy` itself (for example a strict policy with a nonce) keeps it, under `/api` or anywhere else, and no report-only header is added to it. A route that sets its own report-only header keeps it too.
|
|
41
|
+
|
|
42
|
+
The writers tell a route's own policy by its value, which leaves two edge cases no app has today: under `/api`, a route whose policy equals helmet's default byte for byte is treated as untouched, so an HTML answer gets the app policy; and with `http.helmet.contentSecurityPolicy: false`, a route under `/api` that removed the header gets the app policy back on HTML. Outside `/api`, a route whose policy equals the app policy byte for byte also gets the report-only header.
|
|
43
|
+
|
|
44
|
+
## Config
|
|
45
|
+
|
|
46
|
+
`http.csp` in the app's router config, required. All header strings are built once, at boot: a config the checks below refuse fails the boot with an `[http.csp]` error, never a broken header on a response.
|
|
47
|
+
|
|
48
|
+
The list form (`scripts` is required, an empty array is fine):
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
csp: {
|
|
52
|
+
// The four historical lists, unchanged. Proteum adds `'unsafe-inline' 'self' 'unsafe-eval'` to the scripts,
|
|
53
|
+
// and sends no `default-src` when `default` is empty.
|
|
54
|
+
scripts: ['cdn.example.com'],
|
|
55
|
+
styles: ["'self'", "'unsafe-inline'"],
|
|
56
|
+
images: ["'self'", 'data:', 'https:'],
|
|
57
|
+
default: [],
|
|
58
|
+
|
|
59
|
+
// More enforced directives, by their real names, appended after the lists.
|
|
60
|
+
directives: {
|
|
61
|
+
'frame-ancestors': ["'self'"],
|
|
62
|
+
'object-src': ["'none'"],
|
|
63
|
+
'base-uri': ["'self'"],
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
// A second complete policy, sent as Content-Security-Policy-Report-Only wherever the app policy goes.
|
|
67
|
+
reportOnly: {
|
|
68
|
+
policy: { 'default-src': ["'self'"], 'connect-src': ["'self'", 'api.example.com'] },
|
|
69
|
+
reportUri: '/csp-report',
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Or a complete enforced policy, written in full by the app (Proteum adds nothing, so no silent `'unsafe-eval'`):
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
csp: {
|
|
78
|
+
policy: {
|
|
79
|
+
'default-src': ["'self'"],
|
|
80
|
+
'script-src': ["'self'", "'unsafe-inline'"],
|
|
81
|
+
'style-src': ["'self'", "'unsafe-inline'"],
|
|
82
|
+
'frame-ancestors': ["'self'"],
|
|
83
|
+
},
|
|
84
|
+
reportOnly: { policy: { 'default-src': ["'self'"] }, reportUri: '/csp-report' },
|
|
85
|
+
},
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`reportUri` is appended to the report-only policy only, as `report-uri <value>`. The enforced policy gets a report address only from a `report-uri` entry in `directives` or `policy`.
|
|
89
|
+
|
|
90
|
+
In dev, Proteum's client build uses eval source maps and its reload channel listens on another port: when you write `policy`, or a `default-src` or `connect-src`, add `'unsafe-eval'` to `script-src` and the dev event address (`http://localhost:<port>`) to `connect-src` in the dev profile.
|
|
91
|
+
|
|
92
|
+
Boot errors:
|
|
93
|
+
|
|
94
|
+
- `csp` absent or not an object; the list form without `scripts`; a key `csp` or `csp.reportOnly` does not know (`reportonly`, `directive`, `reporturi`), which would otherwise boot and send nothing;
|
|
95
|
+
- `policy` together with `scripts`, `styles`, `images`, `default` or `directives`;
|
|
96
|
+
- `directives` naming `default-src`, `script-src`, `style-src` or `img-src` (the lists own them, filled or not);
|
|
97
|
+
- a directive name that is not a CSP 3 directive (`frameAncestors` and `frame-ancestor` are refused, write `'frame-ancestors'`);
|
|
98
|
+
- a directive with no source, except `upgrade-insecure-requests` and `block-all-mixed-content` (which take none and refuse any), `sandbox` and `trusted-types` (which may be empty);
|
|
99
|
+
- a source that is not a string, is empty, or holds anything but printable ASCII with no whitespace, `;` or `,` (write international hosts in punycode; Latin-1 is refused too, though Node would send it, because browsers drop it);
|
|
100
|
+
- in `directives`, `policy` and `reportOnly.policy`: a bare keyword (`self`, `none`, `unsafe-inline`, `unsafe-eval`, `unsafe-hashes`, `strict-dynamic`, `report-sample`, `wasm-unsafe-eval`) that would name a host, and a source with unbalanced single quotes;
|
|
101
|
+
- an empty `policy`, a `reportOnly` without `policy` or with an empty one, a `reportUri` that is empty or not printable ASCII, and `reportUri` together with a `report-uri` entry in `reportOnly.policy`.
|
|
102
|
+
|
|
103
|
+
The four lists keep most of their 2.6 leniency: an entry holding several sources separated by spaces is kept exactly as written (each source is still checked), so it writes the same bytes; and bare keywords are not checked there. An empty entry (`''` or only spaces) is refused: 2.6 sent it as an empty source list, which blocks everything.
|
|
104
|
+
|
|
105
|
+
## Migration From 2.6
|
|
106
|
+
|
|
107
|
+
Nothing to change to keep today's policy: for every config the checks accept, the four lists produce the same header as 2.6, byte for byte, and every router answer outside `/api` keeps it. What changes by itself:
|
|
108
|
+
|
|
109
|
+
- answers under `/api` now carry the base headers and no `X-Powered-By`; HTML there gets the app policy, other answers helmet's default policy (256 bytes on each API answer), and a 304 there carries none;
|
|
110
|
+
- connected-project answers (`/api/__proteum/connected/*`) are under `/api` and follow the same rule;
|
|
111
|
+
- in the four lists, these entries booted in 2.6 and now stop the boot: an entry that is not a string (`undefined`, `null`, a number); an empty entry; any character outside printable ASCII (a typographic quote, a non-ASCII or Latin-1 host, a tab), which either failed each response or was dropped by browsers;
|
|
112
|
+
- a key `csp` does not know now stops the boot.
|
|
113
|
+
|
|
114
|
+
To add `frame-ancestors`, `object-src` and `base-uri`, add `csp.directives`. To trial a stricter policy, add `csp.reportOnly`; to enforce it, move it to `csp.policy`.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "proteum",
|
|
3
3
|
"description": "LLM-first Opinionated Typescript Framework for web applications.",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.7.0",
|
|
5
5
|
"author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
|
|
6
6
|
"repository": "git://github.com/gaetanlegac/proteum.git",
|
|
7
7
|
"license": "MIT",
|
|
@@ -77,6 +77,7 @@
|
|
|
77
77
|
"mysql2": "^2.3.0",
|
|
78
78
|
"node-cmd": "^5.0.0",
|
|
79
79
|
"null-loader": "^4.0.1",
|
|
80
|
+
"on-headers": "^1.1.0",
|
|
80
81
|
"path-to-regexp": "^6.2.0",
|
|
81
82
|
"pg": "^8.21.0",
|
|
82
83
|
"postcss-loader": "^8.2.0",
|
|
@@ -69,7 +69,10 @@ type TPublicAssetResponse = {
|
|
|
69
69
|
req?: TPublicAssetRequest;
|
|
70
70
|
};
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
// `name.<hex hash>.ext`: async chunks, and since 2.6.1 the production entry files, CSS and fonts too
|
|
73
|
+
// (cli/compiler/client/outputNames.ts), so every file the HTML names is served immutable.
|
|
74
|
+
// `<hex hash>-<width>.ext` is responsive-loader's default name for each image size: hashed as well.
|
|
75
|
+
const hashedPublicAssetPattern = /(^|[-_.])[a-f0-9]{6,}(-\d+)?(?=(\.[^.]+)+$)/i;
|
|
73
76
|
|
|
74
77
|
export const resolveHttpCacheConfig = (config?: THttpCacheConfig): TResolvedHttpCacheConfig => ({
|
|
75
78
|
html: {
|
|
@@ -56,6 +56,12 @@ import {
|
|
|
56
56
|
resolvePublicAssetCacheControl,
|
|
57
57
|
type THttpCacheConfig,
|
|
58
58
|
} from './cache';
|
|
59
|
+
import {
|
|
60
|
+
buildContentSecurityPolicyHeaders,
|
|
61
|
+
createApiContentSecurityPolicyWriter,
|
|
62
|
+
createAppContentSecurityPolicy,
|
|
63
|
+
type TCspConfig,
|
|
64
|
+
} from './securityHeaders';
|
|
59
65
|
|
|
60
66
|
/*----------------------------------
|
|
61
67
|
- CONFIG
|
|
@@ -73,7 +79,8 @@ export type Config = {
|
|
|
73
79
|
upload: {
|
|
74
80
|
maxSize: string; // Expression package bytes
|
|
75
81
|
};
|
|
76
|
-
|
|
82
|
+
/** See docs/http-security-headers.md: on every router answer outside `/api`, and on HTML under `/api`. */
|
|
83
|
+
csp: TCspConfig;
|
|
77
84
|
cache?: THttpCacheConfig;
|
|
78
85
|
cors?: CorsOptions;
|
|
79
86
|
helmet?: Parameters<typeof helmet>[0];
|
|
@@ -82,9 +89,8 @@ export type Config = {
|
|
|
82
89
|
export type Hooks = {};
|
|
83
90
|
|
|
84
91
|
export { resolveHttpCacheConfig, type THttpCacheConfig } from './cache';
|
|
92
|
+
export type { TCspConfig, TCspDirectives, TCspReportOnly } from './securityHeaders';
|
|
85
93
|
|
|
86
|
-
type TContentSecurityPolicyOptions = NonNullable<Parameters<typeof helmet.contentSecurityPolicy>[0]>;
|
|
87
|
-
type TContentSecurityPolicyDirectives = NonNullable<TContentSecurityPolicyOptions['directives']>;
|
|
88
94
|
type TDevSessionAuthService = {
|
|
89
95
|
config: {
|
|
90
96
|
jwt: {
|
|
@@ -105,24 +111,6 @@ type TDevSessionResult =
|
|
|
105
111
|
statusCode: number;
|
|
106
112
|
};
|
|
107
113
|
|
|
108
|
-
const createContentSecurityPolicy = (config: Config['csp']): TContentSecurityPolicyOptions => {
|
|
109
|
-
const directives: TContentSecurityPolicyDirectives = {
|
|
110
|
-
defaultSrc:
|
|
111
|
-
config.default && config.default.length > 0
|
|
112
|
-
? [...config.default]
|
|
113
|
-
: helmet.contentSecurityPolicy.dangerouslyDisableDefaultSrc,
|
|
114
|
-
scriptSrc: ["'unsafe-inline'", "'self'", "'unsafe-eval'", ...config.scripts],
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
if (config.styles && config.styles.length > 0) directives.styleSrc = [...config.styles];
|
|
118
|
-
if (config.images && config.images.length > 0) directives.imgSrc = [...config.images];
|
|
119
|
-
|
|
120
|
-
return {
|
|
121
|
-
useDefaults: false,
|
|
122
|
-
directives,
|
|
123
|
-
};
|
|
124
|
-
};
|
|
125
|
-
|
|
126
114
|
const connectedProjectBootRetryCount = 10;
|
|
127
115
|
const connectedProjectBootRetryDelayMs = 5_000;
|
|
128
116
|
/**
|
|
@@ -452,10 +440,21 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
|
|
|
452
440
|
|
|
453
441
|
// Config
|
|
454
442
|
routes.set('trust proxy', 1); // Indique qu'on est sous le proxy apache
|
|
443
|
+
routes.disable('x-powered-by');
|
|
444
|
+
|
|
445
|
+
// Built once: a malformed policy fails the boot, never a response.
|
|
446
|
+
const contentSecurityPolicyHeaders = buildContentSecurityPolicyHeaders(this.config.csp);
|
|
447
|
+
|
|
448
|
+
// Base headers on every answer, `/api` included (docs/http-security-headers.md).
|
|
449
|
+
routes.use(helmet(this.config.helmet));
|
|
450
|
+
|
|
455
451
|
/*----------------------------------
|
|
456
452
|
- FAST PATH: API
|
|
457
453
|
----------------------------------*/
|
|
458
454
|
|
|
455
|
+
// HTML under /api (pages, their 404) gets the app policy; other /api answers keep helmet's default.
|
|
456
|
+
routes.use(apiOnly(createApiContentSecurityPolicyWriter(contentSecurityPolicyHeaders)));
|
|
457
|
+
|
|
459
458
|
// Keep /api requests off the heavier page middleware stack below.
|
|
460
459
|
routes.use(apiOnly(hpp()));
|
|
461
460
|
routes.use(apiOnly(cookieParser()));
|
|
@@ -473,9 +472,6 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
|
|
|
473
472
|
this.registerConnectedProjectRoutes(routes);
|
|
474
473
|
routes.use(apiOnly(routeRequest));
|
|
475
474
|
|
|
476
|
-
// Diverses protections (dont le disable x-powered-by)
|
|
477
|
-
routes.use(helmet(this.config.helmet));
|
|
478
|
-
|
|
479
475
|
/*----------------------------------
|
|
480
476
|
- FICHIERS STATIQUES
|
|
481
477
|
----------------------------------*/
|
|
@@ -513,6 +509,10 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
|
|
|
513
509
|
},
|
|
514
510
|
}),
|
|
515
511
|
(req, res) => {
|
|
512
|
+
// Never cacheable: during a deploy an instance of the previous build can answer for a
|
|
513
|
+
// file the new build serves, and a CDN that kept that 404 broke every page naming the
|
|
514
|
+
// file until its TTL ran out (21 Oct 2026, a Cloudflare HIT on a chunk 404).
|
|
515
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
516
516
|
res.status(404).send();
|
|
517
517
|
},
|
|
518
518
|
);
|
|
@@ -570,7 +570,8 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
|
|
|
570
570
|
|
|
571
571
|
if (this.config.cors !== undefined) routes.use(cors(this.config.cors));
|
|
572
572
|
|
|
573
|
-
|
|
573
|
+
// Every router answer outside /api keeps the app policy, as in 2.6; static files above keep helmet's.
|
|
574
|
+
routes.use(createAppContentSecurityPolicy(contentSecurityPolicyHeaders));
|
|
574
575
|
|
|
575
576
|
this.registerDevTraceRoutes(routes);
|
|
576
577
|
routes.use(routeRequest);
|