-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathproduction-boundary.test.js
More file actions
401 lines (369 loc) · 15.9 KB
/
Copy pathproduction-boundary.test.js
File metadata and controls
401 lines (369 loc) · 15.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadBaseline, CspValidationError } from '../../src/security/csp.js';
import {
applyHardenedSecurityHeaders,
buildHardenedHeaderMap,
validateApprovedEndpointOrigins,
validateHardenedBaseline,
validateSecurityHeaderValues,
validateServedHeaderMap,
} from '../../src/security/header-values.js';
const clone = globalThis.structuredClone ?? ((value) => JSON.parse(JSON.stringify(value)));
const REAL = loadBaseline();
function mutatedHeader(name, value) {
const baseline = clone(REAL);
baseline.additional_headers[name] = value;
return baseline;
}
test('HSTS preload is blocked until the deployment and rollback gate is accepted', () => {
const baseline = mutatedHeader(
'Strict-Transport-Security',
'max-age=63072000; includeSubDomains; preload',
);
const errors = validateSecurityHeaderValues(baseline);
assert.ok(errors.some((error) => /preload is blocked/.test(error)), errors.join(' | '));
assert.throws(() => buildHardenedHeaderMap(baseline), CspValidationError);
});
test('Permissions-Policy rejects empty, trailing and unreviewed directives', () => {
for (const value of [
`${REAL.additional_headers['Permissions-Policy']},`,
`,${REAL.additional_headers['Permissions-Policy']}`,
`${REAL.additional_headers['Permissions-Policy']}, interest-cohort=()`,
]) {
const errors = validateSecurityHeaderValues(mutatedHeader('Permissions-Policy', value));
assert.ok(errors.length >= 1, `expected ${JSON.stringify(value)} to fail`);
}
});
test('approved endpoint policy rejects non-public and noncanonical targets', () => {
for (const origin of [
'https://0.0.0.0',
'https://10.0.0.1',
'https://100.64.0.1',
'https://127.0.0.1',
'https://169.254.169.254',
'https://172.16.0.1:8443',
'https://192.0.2.1',
'https://192.168.1.10',
'https://198.18.0.1',
'https://198.51.100.1',
'https://203.0.113.1',
'https://224.0.0.1',
'https://[::]',
'https://[::1]',
'https://[fd00::1]',
'https://[fe80::1]',
'https://[ff02::1]',
'https://[2001:db8::1]',
'https://[::ffff:c0a8:101]',
'https://localhost',
'https://app.localhost',
'https://mcp.example.com.',
]) {
const errors = validateApprovedEndpointOrigins([origin]);
assert.ok(errors.length >= 1, `expected ${origin} to fail`);
}
});
test('alternative numeric IPv4 spellings fail canonical-origin validation', () => {
for (const origin of [
'https://2130706433',
'https://0x7f000001',
'https://0177.0.0.1',
'https://[::ffff:192.168.1.1]',
]) {
const errors = validateApprovedEndpointOrigins([origin]);
assert.ok(errors.length >= 1, `expected ${origin} to fail`);
}
});
test('canonical IPv6 literals with embedded or reserved non-public addresses hit the non-public gate', () => {
for (const origin of [
'https://[::c0a8:101]', // IPv4-compatible ::/96 form of 192.168.1.1 (Codex bypass)
'https://[::ffff:c0a8:101]', // IPv4-mapped 192.168.1.1
'https://[64:ff9b::c0a8:101]', // NAT64 well-known prefix embedding 192.168.1.1
'https://[::ffff:7f00:1]', // IPv4-mapped 127.0.0.1
'https://[fc00::1]', // unique local
'https://[fd12:3456::1]', // unique local
'https://[fe80::1]', // link-local
'https://[fec0::1]', // deprecated site-local
'https://[ff02::1]', // multicast
'https://[2001:db8::1]', // documentation
'https://[2002:c0a8:101::]', // 6to4 embedding private 192.168.1.1
'https://[2002:7f00:1::]', // 6to4 embedding loopback 127.0.0.1
'https://[::]', // unspecified
'https://[::1]', // loopback
]) {
const errors = validateApprovedEndpointOrigins([origin]);
assert.ok(
errors.some((error) => /non-public\/reserved/.test(error)),
`expected ${origin} to be rejected as a non-public/reserved literal (got ${JSON.stringify(errors)})`,
);
}
});
test('transition-form IPv6 literals embedding a non-public IPv4 are rejected (Teredo, ISATAP, 6to4-relay)', () => {
// Canonical (WHATWG-URL-normalized) spellings — the form a browser actually
// resolves an origin to. Each smuggles a private/link-local IPv4 through a
// transition encoding that a naive allowlist would miss.
for (const origin of [
'https://[2001:0:808:808::f5ff:fffe]', // Teredo, client IPv4 = 10.0.0.1 (XOR-obfuscated)
'https://[2001:0:a00:1::fefe:fefe]', // Teredo, server IPv4 = 10.0.0.1 (plain)
'https://[2620:0:2d0:200:0:5efe:a00:1]', // ISATAP interface id embedding 10.0.0.1
'https://[2620:0:2d0::5efe:a9fe:a9fe]', // ISATAP embedding 169.254.169.254 (metadata)
'https://192.88.99.1', // 6to4 Relay Anycast (RFC 7526, deprecated)
]) {
const errors = validateApprovedEndpointOrigins([origin]);
assert.ok(
errors.some((error) => /non-public\/reserved/.test(error)),
`expected ${origin} to be rejected as a non-public/reserved literal (got ${JSON.stringify(errors)})`,
);
}
});
test('remaining IANA special-purpose IPv6 prefixes are refused outright', () => {
// Not-globally-reachable prefixes that carry no decodable embedded IPv4, so
// the whole prefix must be refused. 64:ff9b:1::/48 is the NAT64 local-use
// residual that literal decoding provably cannot cover (RFC 8215 puts the
// IPv4 at a network-specific offset).
for (const origin of [
'https://[100::]', // 100::/64 discard-only (RFC 6666)
'https://[100::1]',
'https://[100:0:0:1::1]', // 100:0:0:1::/64 dummy prefix (RFC 9780)
'https://[64:ff9b:1::1]', // NAT64 local-use (RFC 8215) — the documented residual
'https://[::ffff:808:808]', // ::ffff:0:0/96 IPv4-mapped, PUBLIC embedded IPv4
'https://[2001:2::1]', // benchmarking (RFC 5180)
'https://[2001:2:1::1]', // still inside 2001::/23, outside the /48 — pins the /48-vs-/32 edge
'https://[2001:10::1]', // ORCHID, deprecated (RFC 4843)
'https://[2001:1f:ffff::1]', // 2001:10::/28 upper edge
'https://[2001:5::1]', // unassigned remainder of 2001::/23 (RFC 2928)
'https://[2001:100::1]', // unassigned remainder, upper half of the /23
'https://[2001:1ff:ffff::1]', // 2001::/23 upper edge
'https://[2001:1::4]', // 2001:1::/32 outside the three reachable /128 anycasts
'https://[3fff::1]', // documentation (RFC 9637)
'https://[3fff:fff:ffff::1]', // 3fff::/20 upper edge
'https://[5f00::1]', // SRv6 SIDs (RFC 9602)
]) {
const errors = validateApprovedEndpointOrigins([origin]);
assert.ok(
errors.some((error) => /non-public\/reserved/.test(error)),
`expected ${origin} to be rejected as a non-public/reserved literal (got ${JSON.stringify(errors)})`,
);
}
});
test('address space adjacent to the special-purpose prefixes is not over-blocked', () => {
// Guards the boundary arithmetic: each of these sits one step outside a
// prefix refused above. The 2001:* entries are the important ones — the IANA
// registry marks them "Globally Reachable: True", so refusing them would be
// over-blocking that the registry does not support.
for (const origin of [
'https://[100:0:0:2::1]', // outside both 100::/64 blocks (g3 = 2)
'https://[101::1]', // outside 100::/64 (different g0)
'https://[64:ff9b:2::1]', // outside the NAT64 local-use /48
'https://[64:ff9b::808:808]', // NAT64 well-known, public embedded IPv4 — reachable
'https://[2001:3::1]', // AMT (RFC 7450) — Globally Reachable: True
'https://[2001:4:112::1]', // AS112-v6 (RFC 7535) — True
'https://[2001:20::1]', // ORCHIDv2 (RFC 7343) — Globally Reachable: True
'https://[2001:2f:ffff::1]', // ORCHIDv2 upper edge — must stay accepted
'https://[2001:30::1]', // Drone Remote ID (RFC 9374) — Globally Reachable: True
'https://[2001:1::1]', // Port Control Protocol Anycast (RFC 7723) — True
'https://[2001:1::2]', // TURN Anycast (RFC 8155) — True
'https://[2001:1::3]', // DNS-SD SRP Anycast (RFC 9665) — True
'https://[2002:808:808::]', // 6to4 with public embedded IPv4 — outside the /23
'https://[2000::1]', // below 2001::/23
'https://[2001:200::1]', // just above 2001::/23 (g1 = 0x0200)
'https://[3fff:1000::1]', // inside 3fff::/16 but OUTSIDE the /20 — pins the /20-vs-/16 edge
'https://[4000::1]', // outside 3fff::/20
'https://[3ffe::1]', // below 3fff::/20
'https://[5f01::1]', // outside 5f00::/16
]) {
assert.deepEqual(
validateApprovedEndpointOrigins([origin]),
[],
`expected ${origin} to remain accepted`,
);
}
});
test('transition-form IPv6 literals embedding only public IPv4 stay accepted', () => {
for (const origin of [
'https://[2001:0:808:808::fefe:fefe]', // Teredo, server+client both 8.8.8.8 / 1.1.1.1
'https://[2620:0:2d0:200:0:5efe:808:808]', // ISATAP embedding 8.8.8.8
'https://192.88.98.1', // adjacent public /24, must not be over-blocked
]) {
assert.deepEqual(
validateApprovedEndpointOrigins([origin]),
[],
`expected ${origin} to remain accepted`,
);
}
});
test('dotted-quad IPv6 spellings of non-public hosts are rejected (canonicalized before allowlisting)', () => {
for (const origin of [
'https://[::192.168.1.1]', // IPv4-compatible dotted-quad → canonicalizes to ::c0a8:101
'https://[::ffff:192.168.1.1]', // IPv4-mapped dotted-quad
'https://[64:ff9b::192.168.1.1]', // NAT64 dotted-quad
]) {
const errors = validateApprovedEndpointOrigins([origin]);
assert.ok(
errors.length >= 1,
`expected ${origin} to be rejected (got ${JSON.stringify(errors)})`,
);
}
});
test('canonical public IPv6 origins remain accepted', () => {
for (const origin of [
'https://[2606:4700:4700::1111]',
'https://[2001:4860:4860::8888]',
'https://[2620:fe::fe]',
'https://[2002:808:808::]', // 6to4 embedding public 8.8.8.8 stays accepted
]) {
assert.deepEqual(
validateApprovedEndpointOrigins([origin]),
[],
`expected ${origin} to remain accepted`,
);
}
});
test('only explicit plain-HTTP loopback development origins are accepted', () => {
for (const origin of [
'http://localhost:3000',
'http://127.0.0.1:8000',
'http://[::1]:9000',
]) {
assert.deepEqual(validateApprovedEndpointOrigins([origin]), []);
}
for (const origin of [
'http://127.0.0.2:8000',
'http://app.localhost:3000',
'https://127.0.0.1:8443',
]) {
assert.ok(validateApprovedEndpointOrigins([origin]).length >= 1);
}
});
test('hardened validation rejects a non-public origin even when connect-src lists it', () => {
const origin = 'https://192.168.1.10';
const baseline = clone(REAL);
baseline.directives['connect-src'] = ["'self'", origin];
const errors = validateHardenedBaseline(baseline, { approvedEndpoints: [origin] });
assert.ok(errors.some((error) => /non-public\/reserved/.test(error)), errors.join(' | '));
assert.throws(
() => buildHardenedHeaderMap(baseline, { approvedEndpoints: [origin] }),
CspValidationError,
);
});
test('final served-header validation detects missing, changed and duplicate protected headers', () => {
const expected = buildHardenedHeaderMap(REAL);
assert.deepEqual(validateServedHeaderMap(expected, REAL), []);
const missing = { ...expected };
delete missing['Content-Security-Policy'];
assert.ok(validateServedHeaderMap(missing, REAL).some((error) => /missing/.test(error)));
const changed = { ...expected, 'Referrer-Policy': 'unsafe-url' };
assert.ok(validateServedHeaderMap(changed, REAL).some((error) => /differs/.test(error)));
const duplicateEntries = [
...Object.entries(expected),
['referrer-policy', expected['Referrer-Policy']],
];
assert.ok(
validateServedHeaderMap(duplicateEntries, REAL).some((error) => /duplicate/.test(error)),
);
});
test('final served-header validation permits unrelated operational headers', () => {
const expected = buildHardenedHeaderMap(REAL);
const actual = { ...expected, 'Cache-Control': 'no-store', 'Content-Type': 'text/html' };
assert.deepEqual(validateServedHeaderMap(actual, REAL), []);
});
test('applyHardenedSecurityHeaders verifies the Node response boundary', () => {
const values = new Map();
const response = {
setHeader(name, value) {
values.set(name, value);
},
getHeaders() {
return Object.fromEntries(values);
},
};
const applied = applyHardenedSecurityHeaders(response, REAL);
assert.deepEqual(Object.fromEntries(values), applied);
});
test('applyHardenedSecurityHeaders fails when response accessors are incomplete', () => {
assert.throws(
() => applyHardenedSecurityHeaders({ setHeader() {} }, REAL),
TypeError,
);
});
test('Integrity-Policy is required, exact, and cannot be served as a phantom', () => {
// Present and exact in the real baseline.
assert.deepEqual(validateSecurityHeaderValues(REAL), []);
assert.equal(
buildHardenedHeaderMap(REAL)['Integrity-Policy'],
'blocked-destinations=(script style)',
);
// Dropping it must fail closed — script-src 'self' does not cover same-origin
// integrity, so its absence is a real hole, not a cosmetic one.
const without = clone(REAL);
delete without.additional_headers['Integrity-Policy'];
assert.ok(
validateSecurityHeaderValues(without).some((error) => /integrity-policy.*missing/i.test(error)),
'expected a missing Integrity-Policy to be reported',
);
assert.throws(() => buildHardenedHeaderMap(without), CspValidationError);
// Narrowing or emptying the blocked list must fail: a policy that blocks
// nothing still serves and still reads as "present" in an audit.
for (const value of [
'blocked-destinations=()',
'blocked-destinations=(script)',
'blocked-destinations=(style)',
'sources=(inline)',
'',
]) {
assert.ok(
validateSecurityHeaderValues(mutatedHeader('Integrity-Policy', value)).length >= 1,
`expected Integrity-Policy ${JSON.stringify(value)} to be rejected`,
);
assert.throws(
() => buildHardenedHeaderMap(mutatedHeader('Integrity-Policy', value)),
CspValidationError,
`expected Integrity-Policy ${JSON.stringify(value)} to fail closed`,
);
}
});
test('Integrity-Policy-Report-Only cannot be smuggled onto the served response', () => {
// The report-only twin enforces nothing and emits telemetry. It must be
// refused both in the baseline and at the final response boundary, exactly
// like content-security-policy-report-only.
assert.ok(
validateSecurityHeaderValues(
mutatedHeader('Integrity-Policy-Report-Only', 'blocked-destinations=(script)'),
).length >= 1,
'expected the report-only twin to be refused in the baseline',
);
const served = {
...buildHardenedHeaderMap(REAL),
'Integrity-Policy-Report-Only': 'blocked-destinations=(script)',
};
assert.ok(
validateServedHeaderMap(served, REAL).some((error) => /forbidden M1 header/.test(error)),
'expected the report-only twin to be refused at the response boundary',
);
});
test('the two globally reachable /32s inside 192.0.0.0/24 stay accepted', () => {
// 192.0.0.0/24 is registry-False as a block, but the registry re-delegates two
// /32s inside it that carry "Globally Reachable: True". Refusing the /24
// wholesale over-blocks them. This is the IPv4 twin of the 2001:1::1/2/3
// carve-out below — it was missing because the bidirectional registry check
// that produced the IPv6 carve-outs was only ever run over the IPv6 registry.
for (const origin of [
'https://192.0.0.9', // Port Control Protocol Anycast (RFC 7723)
'https://192.0.0.10', // TURN Anycast (RFC 8155)
]) {
assert.deepEqual(
validateApprovedEndpointOrigins([origin]),
[],
`expected ${origin} to remain accepted (registry: Globally Reachable = True)`,
);
}
// The surrounding /24 must still be refused — the carve-out is exactly two
// addresses wide, not a hole in the block.
for (const origin of ['https://192.0.0.8', 'https://192.0.0.11', 'https://192.0.0.1']) {
assert.ok(
validateApprovedEndpointOrigins([origin]).some((e) => /non-public\/reserved/.test(e)),
`expected ${origin} to stay refused`,
);
}
});