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.
@@ -0,0 +1,669 @@
1
+ const assert = require('node:assert/strict');
2
+ const fs = require('node:fs');
3
+ const http = require('node:http');
4
+ const Module = require('node:module');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+
8
+ const coreRoot = path.join(__dirname, '..');
9
+ require('module-alias').addAliases({
10
+ '@client': path.join(coreRoot, 'client'),
11
+ '@common': path.join(coreRoot, 'common'),
12
+ '@server': path.join(coreRoot, 'server'),
13
+ });
14
+ process.env.TS_NODE_PROJECT = path.join(coreRoot, 'cli', 'tsconfig.json');
15
+ process.env.TS_NODE_TRANSPILE_ONLY = '1';
16
+ require('ts-node/register/transpile-only');
17
+
18
+ const helmet = require('helmet');
19
+
20
+ // A fake app root holding the built public files the static middleware serves.
21
+ const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-security-headers-'));
22
+ fs.mkdirSync(path.join(appRoot, 'bin', 'public', 'app'), { recursive: true });
23
+ fs.writeFileSync(path.join(appRoot, 'bin', 'public', 'worker.js'), 'self.onmessage = () => {};');
24
+ fs.writeFileSync(path.join(appRoot, 'bin', 'public', 'static.html'), '<!doctype html><title>static</title>');
25
+ fs.writeFileSync(path.join(appRoot, 'bin', 'public', 'app', 'favicon.ico'), Buffer.from([0, 0, 1, 0]));
26
+ globalThis.APP_OUTPUT_DIR = 'bin';
27
+ globalThis.__DEV__ = false;
28
+
29
+ // `/robots.txt` is served from beside the HTTP module. Created for this file only when absent, removed after.
30
+ const robotsDir = path.join(coreRoot, 'server/services/router/http/public');
31
+ const robotsFile = path.join(robotsDir, 'robots.txt');
32
+ const createdRobotsDir = !fs.existsSync(robotsDir);
33
+ const createdRobotsFile = !fs.existsSync(robotsFile);
34
+ if (createdRobotsFile) {
35
+ fs.mkdirSync(robotsDir, { recursive: true });
36
+ fs.writeFileSync(robotsFile, 'User-agent: *\n');
37
+ }
38
+
39
+ // The app container reads the app's proteum.config.ts at import; the HTTP server only needs its root path.
40
+ const httpServerModulePath = path.join(coreRoot, 'server/services/router/http/index.ts');
41
+ const originalLoad = Module._load;
42
+ Module._load = function patchedLoad(request, parent, isMain) {
43
+ if (parent?.filename === httpServerModulePath && request === '@server/app/container') {
44
+ return { __esModule: true, default: { path: { root: appRoot } } };
45
+ }
46
+
47
+ return originalLoad.call(this, request, parent, isMain);
48
+ };
49
+
50
+ let HttpServer;
51
+ let buildContentSecurityPolicyHeaders;
52
+ try {
53
+ ({ default: HttpServer } = require('../server/services/router/http/index.ts'));
54
+ ({ buildContentSecurityPolicyHeaders } = require('../server/services/router/http/securityHeaders.ts'));
55
+ } finally {
56
+ Module._load = originalLoad;
57
+ }
58
+
59
+ /*----------------------------------
60
+ - FIXTURES
61
+ ----------------------------------*/
62
+
63
+ // The shape both Unique Domains apps use today.
64
+ const legacyCsp = {
65
+ scripts: ['blob:', 'cdn.example.com'],
66
+ styles: ["'self'", "'unsafe-inline'"],
67
+ images: ["'self'", 'data:', 'https:'],
68
+ };
69
+ const phaseOneDirectives = { 'frame-ancestors': ["'self'"], 'object-src': ["'none'"], 'base-uri': ["'self'"] };
70
+ const reportOnly = { policy: { 'default-src': ["'self'"] }, reportUri: '/csp-report' };
71
+ const reportOnlyHeader = "default-src 'self';report-uri /csp-report";
72
+ const routeOwnPolicy = "default-src 'none';script-src 'nonce-abc'";
73
+ const routeOwnReportOnly = "default-src 'none';report-uri /route-report";
74
+
75
+ const html = (title) => `<!doctype html><title>${title}</title>`;
76
+
77
+ /** Answers by path with the write calls the Proteum router makes: pages, JSON, its own 404 and error page. */
78
+ const createFakeRouterMiddleware = () => {
79
+ // One static page cache per server.
80
+ const renderedPages = new Map();
81
+
82
+ return (req, res) => {
83
+ switch (req.path) {
84
+ case '/api':
85
+ case '/api/x/y':
86
+ case '/page':
87
+ return res.send(html(req.path));
88
+ case '/api/v1/x':
89
+ case '/data.json':
90
+ return res.json({ ok: true });
91
+ case '/llms.txt':
92
+ return res.type('text/plain').send('# llms');
93
+ case '/vendor/worker.js':
94
+ case '/api/vendor/worker.js':
95
+ // A worker served by a route, like the agenda map's: it runs under this answer's policy.
96
+ return res.type('text/javascript').send('self.onmessage = () => {};');
97
+ case '/api/page.xhtml':
98
+ return res.type('application/xhtml+xml').send(html('xhtml'));
99
+ case '/api/array-content-type':
100
+ res.setHeader('Content-Type', ['text/html; charset=utf-8']);
101
+ return res.end(html('array'));
102
+ case '/api/streamed':
103
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
104
+ res.write('<!doctype html>');
105
+ return res.end('<title>streamed</title>');
106
+ case '/api/write-head':
107
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
108
+ return res.end(html('write head'));
109
+ case '/api/no-policy':
110
+ res.removeHeader('Content-Security-Policy');
111
+ return res.send(html('no policy'));
112
+ case '/api/redirect':
113
+ case '/redirect':
114
+ // The router's redirect: a status, a Location, no body.
115
+ res.status(302);
116
+ res.header({ Location: '/page' });
117
+ return res.send();
118
+ case '/api/own-policy':
119
+ case '/own-policy':
120
+ res.setHeader('Content-Security-Policy', routeOwnPolicy);
121
+ res.setHeader('Referrer-Policy', 'no-referrer');
122
+ return res.send(html('own'));
123
+ case '/api/own-report-only':
124
+ case '/own-report-only':
125
+ res.setHeader('Content-Security-Policy-Report-Only', routeOwnReportOnly);
126
+ return res.send(html('own report-only'));
127
+ case '/api/cached':
128
+ case '/cached': {
129
+ // The router's static page cache branch: status, the response headers, then the stored HTML.
130
+ const cached = renderedPages.get(req.path);
131
+ if (!cached) {
132
+ renderedPages.set(req.path, html('cached'));
133
+ return res.send(renderedPages.get(req.path));
134
+ }
135
+ res.status(200);
136
+ res.header({ 'Content-Type': 'text/html; charset=utf-8' });
137
+ return res.send(cached);
138
+ }
139
+ case '/api/throws':
140
+ case '/throws':
141
+ // The router catches its own errors and renders the error page as HTML.
142
+ try {
143
+ throw new Error('boom');
144
+ } catch {
145
+ return res.status(500).send(html('error'));
146
+ }
147
+ default:
148
+ if (req.path.startsWith('/api/v1/')) return res.status(404).json({ error: 'Not found' });
149
+ return res.status(404).send(html('not found'));
150
+ }
151
+ };
152
+ };
153
+
154
+ const servers = [];
155
+
156
+ /** Raw HTTP, so the header list keeps its order and no client adds request headers of its own. */
157
+ const send = ({ host, port }, requestPath, { method = 'GET', headers = {}, body } = {}) =>
158
+ new Promise((resolve, reject) => {
159
+ const req = http.request({ host, port, path: requestPath, method, headers }, (res) => {
160
+ res.resume();
161
+ res.on('end', () => {
162
+ const rawHeaders = [];
163
+ for (let index = 0; index < res.rawHeaders.length; index += 2)
164
+ rawHeaders.push([res.rawHeaders[index], res.rawHeaders[index + 1]]);
165
+ resolve({ status: res.statusCode, headers: new Headers(res.headers), rawHeaders });
166
+ });
167
+ });
168
+ req.on('error', reject);
169
+ req.end(body);
170
+ });
171
+
172
+ const startServer = async (config = {}) => {
173
+ const app = { env: { name: 'server', profile: 'prod' }, on: () => undefined };
174
+ const router = { app, middleware: createFakeRouterMiddleware() };
175
+ const server = new HttpServer(
176
+ { domain: 'localhost', port: 0, ssl: false, upload: { maxSize: '1mb' }, csp: legacyCsp, ...config },
177
+ router,
178
+ );
179
+ servers.push(server);
180
+
181
+ const listening = new Promise((resolve) => server.http.once('listening', resolve));
182
+ const consoleInfo = console.info;
183
+ console.info = () => undefined;
184
+ try {
185
+ await server.start();
186
+ await listening;
187
+ } finally {
188
+ console.info = consoleInfo;
189
+ }
190
+
191
+ // Ask the server where it listens: a foreign process on 127.0.0.1 and the same port must never answer.
192
+ const address = server.http.address();
193
+ const target = { host: address.family === 'IPv6' ? '::1' : '127.0.0.1', port: address.port };
194
+ const request = (requestPath, init) => send(target, requestPath, init);
195
+ request.revalidate = async (requestPath) => {
196
+ const first = await request(requestPath);
197
+ const etag = first.headers.get('etag');
198
+ assert.ok(etag, `${requestPath}: an ETag`);
199
+ return await request(requestPath, { headers: { 'If-None-Match': etag } });
200
+ };
201
+ return request;
202
+ };
203
+
204
+ afterAll(async () => {
205
+ await Promise.all(servers.map((server) => new Promise((resolve) => server.http.close(resolve))));
206
+ fs.rmSync(appRoot, { recursive: true, force: true });
207
+ if (createdRobotsFile) fs.rmSync(createdRobotsDir ? robotsDir : robotsFile, { recursive: true, force: true });
208
+ });
209
+
210
+ /** What 2.6.1 produced at its second policy mount, for the same legacy config. */
211
+ const helmetPolicyOf = (directives) => {
212
+ const headers = {};
213
+ helmet.contentSecurityPolicy({ useDefaults: false, directives })({}, { setHeader: (name, value) => (headers[name] = value) }, () => undefined);
214
+ return headers['Content-Security-Policy'];
215
+ };
216
+ const legacyPolicyOf = (csp) => {
217
+ const directives = {
218
+ defaultSrc: csp.default?.length ? csp.default : helmet.contentSecurityPolicy.dangerouslyDisableDefaultSrc,
219
+ scriptSrc: ["'unsafe-inline'", "'self'", "'unsafe-eval'", ...csp.scripts],
220
+ };
221
+ if (csp.styles?.length) directives.styleSrc = csp.styles;
222
+ if (csp.images?.length) directives.imgSrc = csp.images;
223
+ return helmetPolicyOf(directives);
224
+ };
225
+ const legacyPolicy261 = legacyPolicyOf(legacyCsp);
226
+ const helmetDefaultPolicy = helmetPolicyOf(helmet.contentSecurityPolicy.getDefaultDirectives());
227
+
228
+ const baseHeaders = {
229
+ 'strict-transport-security': 'max-age=15552000; includeSubDomains',
230
+ 'x-frame-options': 'SAMEORIGIN',
231
+ 'x-content-type-options': 'nosniff',
232
+ 'referrer-policy': 'no-referrer',
233
+ 'x-dns-prefetch-control': 'off',
234
+ 'expect-ct': 'max-age=0',
235
+ 'x-download-options': 'noopen',
236
+ 'x-permitted-cross-domain-policies': 'none',
237
+ 'x-xss-protection': '0',
238
+ };
239
+ const crossOriginHeaders = ['cross-origin-resource-policy', 'cross-origin-opener-policy', 'cross-origin-embedder-policy'];
240
+
241
+ const assertBaseHeaders = (response, label, expected = baseHeaders) => {
242
+ for (const [name, value] of Object.entries(expected))
243
+ assert.equal(response.headers.get(name), value, `${label}: ${name}`);
244
+ assert.equal(response.headers.get('x-powered-by'), null, `${label}: x-powered-by must be absent`);
245
+ // helmet 5 and later send these by default; they would refuse Product's images on the website.
246
+ for (const name of crossOriginHeaders) assert.equal(response.headers.get(name), null, `${label}: ${name}`);
247
+ };
248
+
249
+ const assertPolicy = (response, label, policy, reportOnlyValue = null) => {
250
+ assert.equal(response.headers.get('content-security-policy'), policy, `${label}: content-security-policy`);
251
+ assert.equal(response.headers.get('content-security-policy-report-only'), reportOnlyValue, `${label}: report-only`);
252
+ };
253
+
254
+ /*----------------------------------
255
+ - HEADERS BY KIND OF ANSWER
256
+ ----------------------------------*/
257
+
258
+ test('an HTML answer under /api carries the app policy and every base header', async () => {
259
+ const request = await startServer();
260
+
261
+ for (const requestPath of ['/api', '/api/x/y']) {
262
+ const response = await request(requestPath);
263
+ assert.equal(response.status, 200);
264
+ assertPolicy(response, requestPath, legacyPolicy261);
265
+ assertBaseHeaders(response, requestPath);
266
+ }
267
+ });
268
+
269
+ test('a JSON answer under /api carries the base headers and the fallback policy, no Cross-Origin header', async () => {
270
+ const request = await startServer();
271
+
272
+ for (const requestPath of ['/api/v1/x', '/api/v1/missing']) {
273
+ const response = await request(requestPath);
274
+ assert.match(response.headers.get('content-type'), /^application\/json/);
275
+ assertPolicy(response, requestPath, helmetDefaultPolicy);
276
+ assertBaseHeaders(response, requestPath);
277
+ }
278
+ });
279
+
280
+ test('an HTML answer outside /api keeps the 2.6.1 policy, plus the new directives only when configured', async () => {
281
+ const legacy = await startServer();
282
+ const page = await legacy('/page');
283
+ assertPolicy(page, '/page', legacyPolicy261);
284
+ assertBaseHeaders(page, '/page');
285
+
286
+ const phaseOne = await startServer({ csp: { ...legacyCsp, directives: phaseOneDirectives } });
287
+ const phaseOnePage = await phaseOne('/page');
288
+ assertPolicy(phaseOnePage, '/page phase 1', `${legacyPolicy261};frame-ancestors 'self';object-src 'none';base-uri 'self'`);
289
+ });
290
+
291
+ test('every router answer outside /api carries the app policy, whatever its type', async () => {
292
+ const request = await startServer();
293
+
294
+ // The map's worker is a JavaScript answer: a worker takes its policy from its own script's answer.
295
+ const answers = [
296
+ ['/vendor/worker.js', 200],
297
+ ['/llms.txt', 200],
298
+ ['/data.json', 200],
299
+ ['/redirect', 302],
300
+ ['/missing', 404],
301
+ ['/throws', 500],
302
+ ];
303
+ for (const [requestPath, status] of answers) {
304
+ const response = await request(requestPath);
305
+ assert.equal(response.status, status, requestPath);
306
+ assertPolicy(response, requestPath, legacyPolicy261);
307
+ assertBaseHeaders(response, requestPath);
308
+ }
309
+ });
310
+
311
+ test('under /api, other kinds of answer keep the fallback, and every way of writing HTML gets the app policy', async () => {
312
+ const request = await startServer();
313
+
314
+ for (const requestPath of ['/api/redirect', '/api/vendor/worker.js']) {
315
+ const response = await request(requestPath);
316
+ assertPolicy(response, requestPath, helmetDefaultPolicy);
317
+ assertBaseHeaders(response, requestPath);
318
+ }
319
+
320
+ for (const requestPath of ['/api/streamed', '/api/write-head', '/api/page.xhtml', '/api/array-content-type']) {
321
+ const response = await request(requestPath);
322
+ assertPolicy(response, requestPath, legacyPolicy261);
323
+ assertBaseHeaders(response, requestPath);
324
+ }
325
+
326
+ const head = await request('/api', { method: 'HEAD' });
327
+ assertPolicy(head, 'HEAD /api', legacyPolicy261);
328
+ assertBaseHeaders(head, 'HEAD /api');
329
+
330
+ // A route that removed the policy is left without one.
331
+ const removed = await request('/api/no-policy');
332
+ assertPolicy(removed, '/api/no-policy', null);
333
+ assertBaseHeaders(removed, '/api/no-policy');
334
+ });
335
+
336
+ test('a route that sets its own policy keeps it, under /api and outside', async () => {
337
+ // The referrer value lane B configures, so the route's own `no-referrer` is told apart from helmet's.
338
+ const request = await startServer({
339
+ csp: { ...legacyCsp, reportOnly },
340
+ helmet: { referrerPolicy: { policy: 'strict-origin-when-cross-origin' } },
341
+ });
342
+
343
+ for (const requestPath of ['/api/own-policy', '/own-policy']) {
344
+ const response = await request(requestPath);
345
+ assertPolicy(response, requestPath, routeOwnPolicy);
346
+ assert.equal(response.headers.get('referrer-policy'), 'no-referrer', requestPath);
347
+ assert.equal(response.headers.get('x-powered-by'), null, requestPath);
348
+ }
349
+
350
+ for (const requestPath of ['/api', '/page']) {
351
+ const response = await request(requestPath);
352
+ assert.equal(response.headers.get('referrer-policy'), 'strict-origin-when-cross-origin', requestPath);
353
+ }
354
+
355
+ // A route's own report-only header is kept, beside the app policy.
356
+ for (const requestPath of ['/api/own-report-only', '/own-report-only']) {
357
+ const response = await request(requestPath);
358
+ assertPolicy(response, requestPath, legacyPolicy261, routeOwnReportOnly);
359
+ }
360
+ });
361
+
362
+ test('a 404 HTML under /api carries the app policy', async () => {
363
+ const request = await startServer();
364
+ const response = await request('/api/unknown/page');
365
+ assert.equal(response.status, 404);
366
+ assertPolicy(response, '/api/unknown/page', legacyPolicy261);
367
+ assertBaseHeaders(response, '/api/unknown/page');
368
+ });
369
+
370
+ test('a page served from the static page cache carries the same headers as the first render', async () => {
371
+ const request = await startServer({ csp: { ...legacyCsp, reportOnly } });
372
+
373
+ for (const requestPath of ['/api/cached', '/cached']) {
374
+ const first = await request(requestPath);
375
+ const second = await request(requestPath);
376
+
377
+ assertPolicy(first, `${requestPath} first`, legacyPolicy261, reportOnlyHeader);
378
+ assertPolicy(second, `${requestPath} cached`, legacyPolicy261, reportOnlyHeader);
379
+ assertBaseHeaders(second, `${requestPath} cached`);
380
+ }
381
+ });
382
+
383
+ test('the server error page under /api carries the app policy', async () => {
384
+ const request = await startServer({ csp: { ...legacyCsp, reportOnly } });
385
+ const response = await request('/api/throws');
386
+ assert.equal(response.status, 500);
387
+ assertPolicy(response, '/api/throws', legacyPolicy261, reportOnlyHeader);
388
+ assertBaseHeaders(response, '/api/throws');
389
+ });
390
+
391
+ test('a body-parser error under /api, before the router, keeps Express\'s own strict policy', async () => {
392
+ const request = await startServer();
393
+ const consoleError = console.error;
394
+ console.error = () => undefined;
395
+ try {
396
+ const response = await request('/api/v1/x', {
397
+ method: 'POST',
398
+ headers: { 'Content-Type': 'application/json' },
399
+ body: '{not json',
400
+ });
401
+ assert.equal(response.status, 400);
402
+ assertPolicy(response, 'malformed JSON under /api', "default-src 'none'");
403
+ assertBaseHeaders(response, 'malformed JSON under /api');
404
+ } finally {
405
+ console.error = consoleError;
406
+ }
407
+ });
408
+
409
+ test('a 304 under /api carries no policy; outside /api it carries the app policy, as in 2.6.1', async () => {
410
+ const request = await startServer({ csp: { ...legacyCsp, reportOnly } });
411
+
412
+ // Under /api the writer would otherwise leave helmet's default, which would replace a page's policy.
413
+ for (const requestPath of ['/api', '/api/cached', '/api/v1/x']) {
414
+ const revalidated = await request.revalidate(requestPath);
415
+ assert.equal(revalidated.status, 304, requestPath);
416
+ assertPolicy(revalidated, `${requestPath} 304`, null);
417
+ assertBaseHeaders(revalidated, `${requestPath} 304`);
418
+ }
419
+
420
+ // Outside /api a 304 carries the same values its 200 carried.
421
+ for (const requestPath of ['/page', '/vendor/worker.js']) {
422
+ const revalidated = await request.revalidate(requestPath);
423
+ assert.equal(revalidated.status, 304, requestPath);
424
+ assertPolicy(revalidated, `${requestPath} 304`, legacyPolicy261, reportOnlyHeader);
425
+ assertBaseHeaders(revalidated, `${requestPath} 304`);
426
+ }
427
+ });
428
+
429
+ /** Each static answer's header list as 2.6.1 sends it, in order; `null` marks a value that changes per run. */
430
+ const staticHeaders261 = (extra) => [
431
+ ['Content-Security-Policy', helmetDefaultPolicy],
432
+ ['X-DNS-Prefetch-Control', 'off'],
433
+ ['Expect-CT', 'max-age=0'],
434
+ ['X-Frame-Options', 'SAMEORIGIN'],
435
+ ['Strict-Transport-Security', 'max-age=15552000; includeSubDomains'],
436
+ ['X-Download-Options', 'noopen'],
437
+ ['X-Content-Type-Options', 'nosniff'],
438
+ ['X-Permitted-Cross-Domain-Policies', 'none'],
439
+ ['Referrer-Policy', 'no-referrer'],
440
+ ['X-XSS-Protection', '0'],
441
+ ...extra,
442
+ ['Date', null],
443
+ ['Connection', 'keep-alive'],
444
+ ['Keep-Alive', 'timeout=5'],
445
+ ];
446
+ const recordedStaticHeaders = {
447
+ '/public/worker.js': staticHeaders261([
448
+ ['Access-Control-Allow-Origin', '*'],
449
+ ['Cache-Control', 'public, max-age=0, must-revalidate'],
450
+ ['Accept-Ranges', 'bytes'],
451
+ ['Last-Modified', null],
452
+ ['ETag', null],
453
+ ['Content-Type', 'application/javascript; charset=UTF-8'],
454
+ ['Content-Length', '26'],
455
+ ['Vary', 'Accept-Encoding'],
456
+ ]),
457
+ '/public/static.html': staticHeaders261([
458
+ ['Access-Control-Allow-Origin', '*'],
459
+ ['Cache-Control', 'public, max-age=0, must-revalidate'],
460
+ ['Accept-Ranges', 'bytes'],
461
+ ['Last-Modified', null],
462
+ ['ETag', null],
463
+ ['Content-Type', 'text/html; charset=UTF-8'],
464
+ ['Content-Length', '36'],
465
+ ['Vary', 'Accept-Encoding'],
466
+ ]),
467
+ '/public/missing.js': [
468
+ ...staticHeaders261([
469
+ ['Access-Control-Allow-Origin', '*'],
470
+ ['Cache-Control', 'no-store'],
471
+ ]),
472
+ ['Transfer-Encoding', 'chunked'],
473
+ ],
474
+ '/ping': staticHeaders261([
475
+ ['Content-Type', 'text/html; charset=utf-8'],
476
+ ['Content-Length', '4'],
477
+ ['ETag', null],
478
+ ['Vary', 'Accept-Encoding'],
479
+ ]),
480
+ '/robots.txt': staticHeaders261([
481
+ ['Accept-Ranges', 'bytes'],
482
+ ['Cache-Control', 'public, max-age=0'],
483
+ ['Last-Modified', null],
484
+ ['ETag', null],
485
+ ['Content-Type', 'text/plain; charset=UTF-8'],
486
+ ['Content-Length', '14'],
487
+ ['Vary', 'Accept-Encoding'],
488
+ ]),
489
+ '/favicon.ico': staticHeaders261([
490
+ ['Accept-Ranges', 'bytes'],
491
+ ['Cache-Control', 'public, max-age=0'],
492
+ ['Last-Modified', null],
493
+ ['ETag', null],
494
+ ['Content-Type', 'image/x-icon'],
495
+ ['Content-Length', '4'],
496
+ ['Vary', 'Accept-Encoding'],
497
+ ]),
498
+ };
499
+
500
+ test('static files keep the 2.6.1 header list byte for byte, names, values and order', async () => {
501
+ // Recorded from the 2.6.1 HttpServer on this harness. Everything configured, so nothing may leak onto them.
502
+ const request = await startServer({ csp: { ...legacyCsp, directives: phaseOneDirectives, reportOnly } });
503
+
504
+ for (const [requestPath, expected] of Object.entries(recordedStaticHeaders)) {
505
+ const response = await request(requestPath);
506
+ assert.deepEqual(
507
+ response.rawHeaders.map(([name, value], index) => [name, expected[index]?.[1] === null ? null : value]),
508
+ expected,
509
+ requestPath,
510
+ );
511
+ }
512
+ });
513
+
514
+ /*----------------------------------
515
+ - CONFIG
516
+ ----------------------------------*/
517
+
518
+ test('csp.policy replaces the whole policy, with nothing added by Proteum', async () => {
519
+ const request = await startServer({
520
+ csp: { policy: { 'default-src': ["'self'"], 'script-src': ["'self'", "'unsafe-inline'"], 'upgrade-insecure-requests': [] } },
521
+ });
522
+ const response = await request('/api');
523
+ assertPolicy(response, '/api', "default-src 'self';script-src 'self' 'unsafe-inline';upgrade-insecure-requests");
524
+ });
525
+
526
+ test('csp.reportOnly rides wherever the app policy goes, with its report-uri, never on the fallback', async () => {
527
+ const request = await startServer({
528
+ csp: { ...legacyCsp, reportOnly: { policy: { 'default-src': ["'self'"], 'frame-src': ['challenges.example.com'] }, reportUri: '/csp-report' } },
529
+ });
530
+
531
+ for (const requestPath of ['/api', '/page', '/api/unknown', '/llms.txt', '/vendor/worker.js', '/data.json']) {
532
+ const response = await request(requestPath);
533
+ assertPolicy(response, requestPath, legacyPolicy261, "default-src 'self';frame-src challenges.example.com;report-uri /csp-report");
534
+ }
535
+
536
+ for (const requestPath of ['/api/v1/x', '/api/vendor/worker.js', '/ping', '/public/worker.js']) {
537
+ const response = await request(requestPath);
538
+ assert.equal(response.headers.get('content-security-policy-report-only'), null, requestPath);
539
+ }
540
+ });
541
+
542
+ test('without csp.reportOnly no answer carries a report-only header', async () => {
543
+ const request = await startServer();
544
+
545
+ for (const requestPath of ['/api', '/page', '/vendor/worker.js', '/api/v1/x', '/public/static.html']) {
546
+ const response = await request(requestPath);
547
+ assert.equal(response.headers.get('content-security-policy-report-only'), null, requestPath);
548
+ }
549
+ });
550
+
551
+ test('config.helmet still reaches helmet', async () => {
552
+ const request = await startServer({ helmet: { referrerPolicy: { policy: 'strict-origin-when-cross-origin' } } });
553
+
554
+ for (const requestPath of ['/api', '/api/v1/x', '/page', '/public/worker.js']) {
555
+ const response = await request(requestPath);
556
+ assert.equal(response.headers.get('referrer-policy'), 'strict-origin-when-cross-origin', requestPath);
557
+ }
558
+ });
559
+
560
+ test('X-Powered-By stays off even when helmet is told to keep it', async () => {
561
+ const request = await startServer({ helmet: { hidePoweredBy: false } });
562
+
563
+ for (const requestPath of ['/api', '/api/v1/x', '/page', '/public/worker.js']) {
564
+ const response = await request(requestPath);
565
+ assert.equal(response.headers.get('x-powered-by'), null, requestPath);
566
+ }
567
+ });
568
+
569
+ test('with helmet\'s policy turned off, the fallback answers carry none and the others the app policy', async () => {
570
+ const request = await startServer({ helmet: { contentSecurityPolicy: false } });
571
+
572
+ for (const requestPath of ['/api/v1/x', '/public/worker.js', '/ping']) {
573
+ const response = await request(requestPath);
574
+ assertPolicy(response, requestPath, null);
575
+ }
576
+
577
+ for (const requestPath of ['/api', '/page', '/vendor/worker.js']) {
578
+ const response = await request(requestPath);
579
+ assertPolicy(response, requestPath, legacyPolicy261);
580
+ }
581
+ });
582
+
583
+ test('a malformed csp config fails the boot', async () => {
584
+ const bootFailures = [
585
+ [{ policy: { 'default-src': ["'self'"] }, scripts: ['a.example.com'] }, /csp\.policy replaces the whole policy, so scripts must be absent/],
586
+ [{ policy: { 'default-src': ["'self'"] }, directives: { 'object-src': ["'none'"] } }, /directives must be absent/],
587
+ [{ ...legacyCsp, directives: { 'script-src': ['b.example.com'] } }, /legacy lists own/],
588
+ [{ ...legacyCsp, directives: { 'default-src': ["'self'"] } }, /legacy lists own/],
589
+ [{ ...legacyCsp, directives: { frameAncestors: ["'self'"] } }, /unknown directive name "frameAncestors"/],
590
+ [{ ...legacyCsp, directives: { 'frame-ancestor': ["'self'"] } }, /unknown directive name "frame-ancestor"/],
591
+ [{ ...legacyCsp, directives: { 'frame-src': [] } }, /is empty/],
592
+ [{ ...legacyCsp, directives: { 'upgrade-insecure-requests': ['yes'] } }, /takes no value/],
593
+ [{ ...legacyCsp, directives: { 'frame-ancestors': ['self'] } }, /bare word "self"/],
594
+ [{ ...legacyCsp, directives: { 'object-src': ['None'] } }, /bare word "None"/],
595
+ [{ ...legacyCsp, directives: { 'frame-ancestors': ['Self'] } }, /bare word "Self"/],
596
+ [{ policy: { 'script-src': ["'self"] } }, /unbalanced single quotes/],
597
+ [{ policy: { 'script-src': ["''"] } }, /unbalanced single quotes/],
598
+ [{ ...legacyCsp, directives: { 'frame-ancestors': ['’self’'] } }, /printable ASCII/],
599
+ [{ ...legacyCsp, directives: { 'frame-src': ['a\u0000b'] } }, /printable ASCII/],
600
+ [{ ...legacyCsp, directives: { 'frame-src': ['a.example.com;script-src *'] } }, /no whitespace/],
601
+ [{ ...legacyCsp, directives: { 'frame-src': ['a.example.com b.example.com'] } }, /no whitespace/],
602
+ [{ ...legacyCsp, directives: { 'frame-src': [''] } }, /non-empty source/],
603
+ [{ ...legacyCsp, directives: { 'frame-src': 'a.example.com' } }, /must be an array/],
604
+ [{ scripts: 'a.example.com' }, /csp\.scripts must be an array/],
605
+ [{ scripts: [42] }, /csp\.scripts\[0\] must be a string/],
606
+ [{ scripts: ['例子.com'] }, /printable ASCII/],
607
+ [{ scripts: ['café.example.com'] }, /printable ASCII/],
608
+ [{ scripts: [], styles: [''] }, /csp\.styles\[0\] is empty/],
609
+ [{ scripts: [], default: [' '] }, /csp\.default\[0\] is empty/],
610
+ [{ styles: ["'self'"] }, /csp\.scripts is required/],
611
+ [undefined, /csp must be an object/],
612
+ [{ ...legacyCsp, reportonly: reportOnly }, /csp has unknown keys "reportonly"/],
613
+ [{ ...legacyCsp, directive: phaseOneDirectives }, /csp has unknown keys "directive"/],
614
+ [{ ...legacyCsp, reportOnly: { policy: { 'default-src': ["'self'"] }, reporturi: '/a' } }, /csp\.reportOnly has unknown keys "reporturi"/],
615
+ [{ policy: {} }, /csp\.policy has no directive/],
616
+ [{ ...legacyCsp, reportOnly: { policy: {} } }, /reportOnly\.policy has no directive/],
617
+ [{ ...legacyCsp, reportOnly: { reportUri: '/a' } }, /reportOnly\.policy must be an object/],
618
+ [{ ...legacyCsp, reportOnly: { policy: { 'report-uri': ['/a'] }, reportUri: '/b' } }, /report-uri twice/],
619
+ [{ ...legacyCsp, reportOnly: { policy: { 'default-src': ["'self'"] }, reportUri: '/a b' } }, /reportUri must be/],
620
+ [{ ...legacyCsp, reportOnly: { policy: { 'default-src': ["'self'"] }, reportUri: '' } }, /reportUri must be/],
621
+ ];
622
+
623
+ for (const [csp, message] of bootFailures) {
624
+ assert.throws(() => buildContentSecurityPolicyHeaders(csp), message, JSON.stringify(csp));
625
+ }
626
+
627
+ // Valid CSP the checks must not refuse.
628
+ assert.equal(
629
+ buildContentSecurityPolicyHeaders({
630
+ policy: {
631
+ 'default-src': ["'self'"],
632
+ 'trusted-types': [],
633
+ sandbox: ['allow-scripts'],
634
+ 'require-trusted-types-for': ["'script'"],
635
+ webrtc: ["'block'"],
636
+ 'script-src': ["'self'", "'nonce-abc'", "'sha256-AbC+/=='", 'https:', 'wss://relay.example.com', '*.example.com'],
637
+ 'report-uri': ['https://example.com/csp?app=web'],
638
+ },
639
+ }).policy,
640
+ "default-src 'self';trusted-types;sandbox allow-scripts;require-trusted-types-for 'script';webrtc 'block';script-src 'self' 'nonce-abc' 'sha256-AbC+/==' https: wss://relay.example.com *.example.com;report-uri https://example.com/csp?app=web",
641
+ );
642
+
643
+ // The four lists keep their 2.6 leniency: a bare keyword is not checked there.
644
+ assert.equal(buildContentSecurityPolicyHeaders({ scripts: [], styles: ['self'] }).policy, "script-src 'unsafe-inline' 'self' 'unsafe-eval';style-src self");
645
+
646
+ // Through the server itself: start() rejects before it listens.
647
+ const app = { env: { name: 'server', profile: 'prod' }, on: () => undefined };
648
+ const server = new HttpServer(
649
+ { domain: 'localhost', port: 0, ssl: false, upload: { maxSize: '1mb' }, csp: { policy: {}, scripts: [] } },
650
+ { app, middleware: createFakeRouterMiddleware() },
651
+ );
652
+ await assert.rejects(server.start(), /\[http\.csp\]/);
653
+ assert.equal(server.http.listening, false);
654
+ });
655
+
656
+ test('the legacy serializer matches helmet 4.6 for every legacy list combination', () => {
657
+ const combos = [
658
+ { scripts: [] },
659
+ { scripts: ['a.example.com'], default: ["'self'"] },
660
+ { scripts: ['a.example.com'], default: [], styles: [], images: ["'self'"] },
661
+ { scripts: ['a.example.com'], default: ["'self'", 'https:'], styles: ["'unsafe-inline'"], images: ['data:'] },
662
+ // 2.6 accepted several sources in one entry, spaces as written; helmet joined the entries with a space.
663
+ { scripts: ['a.example.com b.example.com'], styles: ["'self' 'unsafe-inline'"], images: [' data:'] },
664
+ ];
665
+
666
+ for (const csp of combos) {
667
+ assert.equal(buildContentSecurityPolicyHeaders(csp).policy, legacyPolicyOf(csp), JSON.stringify(csp));
668
+ }
669
+ });