-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcsp-serializer.test.js
More file actions
241 lines (209 loc) · 9.17 KB
/
Copy pathcsp-serializer.test.js
File metadata and controls
241 lines (209 loc) · 9.17 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
// M1B enforcement tests: serializer correctness + the connect-src negative
// test (the crown-jewel exfiltration control). Zero deps: Node's built-in
// test runner + node:assert (`node --test`).
//
// The negative test MUST BITE: a violating connect-src -> validation errors;
// the real committed baseline -> clean.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
loadBaseline,
serializeCsp,
validateBaseline,
validateConnectSrc,
buildHeaderMap,
isExactOrigin,
CspValidationError,
DEFAULT_BASELINE_PATH,
} from '../../src/security/csp.js';
// Node >=17 has global structuredClone; fall back to JSON round-trip.
const clone = globalThis.structuredClone ?? ((o) => JSON.parse(JSON.stringify(o)));
const REAL = loadBaseline();
const FORBIDDEN = REAL.connect_src_policy.forbidden_tokens;
// --- serializer correctness ------------------------------------------------
test('serializeCsp emits the documented baseline CSP string', () => {
const csp = serializeCsp(REAL.directives);
// spot-check load-bearing directives exactly as documented.
assert.match(csp, /(^|; )default-src 'none'(;|$)/);
assert.match(csp, /(^|; )connect-src 'self'(;|$)/);
assert.match(csp, /(^|; )script-src 'self'(;|$)/);
assert.match(csp, /(^|; )frame-ancestors 'none'(;|$)/);
assert.match(csp, /(^|; )require-trusted-types-for 'script'(;|$)/);
// Trusted Types is locked down on both axes: enforce sinks AND forbid any
// policy creation, so a compromised script cannot mint its own pass-through.
assert.match(csp, /(^|; )trusted-types 'none'(;|$)/);
});
test('valueless directive is serialized as the name alone (no trailing space)', () => {
const csp = serializeCsp(REAL.directives);
assert.match(csp, /(^|; )upgrade-insecure-requests(;|$)/);
assert.doesNotMatch(csp, /upgrade-insecure-requests /);
});
test('serializeCsp rejects a non-array directive value', () => {
assert.throws(
() => serializeCsp({ 'connect-src': "'self'" }),
CspValidationError,
);
});
test('serializeCsp refuses an injected directive NAME (separator/control char)', () => {
// Aegis PoC: a `;`/whitespace-carrying key would smuggle an extra directive
// into the serialized string. The serializer must refuse to emit it.
assert.throws(
() =>
serializeCsp({
'script-src': ["'self'"],
'x-evil connect-src *': ["'self'"],
}),
CspValidationError,
);
});
test('serializeCsp refuses an unknown (non-whitelisted) directive NAME', () => {
assert.throws(
() => serializeCsp({ 'script-src': ["'self'"], 'totally-made-up-src': ["'self'"] }),
CspValidationError,
);
});
test('serializeCsp emits every known/whitelisted directive name unchanged', () => {
const csp = serializeCsp({
'default-src': ["'none'"],
'script-src': ["'self'"],
'upgrade-insecure-requests': [],
});
assert.equal(csp, "default-src 'none'; script-src 'self'; upgrade-insecure-requests");
});
// --- header map (single source of truth) -----------------------------------
test('buildHeaderMap emits CSP plus every additional header from the baseline', () => {
const headers = buildHeaderMap(REAL);
assert.ok(headers['Content-Security-Policy']);
for (const name of Object.keys(REAL.additional_headers)) {
assert.equal(headers[name], REAL.additional_headers[name], `${name} mismatch`);
}
// no duplicate CSP definition: the served CSP comes only from directives.
assert.equal(
headers['Content-Security-Policy'],
serializeCsp(REAL.directives),
);
});
test('the served header set includes HSTS/COOP/COEP/CORP/Trusted-Types', () => {
const headers = buildHeaderMap(REAL);
assert.ok(headers['Strict-Transport-Security'].includes('max-age='));
assert.equal(headers['Cross-Origin-Opener-Policy'], 'same-origin');
assert.equal(headers['Cross-Origin-Embedder-Policy'], 'require-corp');
assert.equal(headers['Cross-Origin-Resource-Policy'], 'same-origin');
assert.match(
headers['Content-Security-Policy'],
/require-trusted-types-for 'script'/,
);
});
// --- isExactOrigin oracle --------------------------------------------------
test('isExactOrigin accepts exact origins, rejects wildcards/paths/scheme-only', () => {
assert.equal(isExactOrigin('https://mcp.example.com'), true);
assert.equal(isExactOrigin('https://mcp.example.com:8443'), true);
assert.equal(isExactOrigin('https://mcp.example.com/'), false); // trailing path
assert.equal(isExactOrigin('https://mcp.example.com/api'), false);
assert.equal(isExactOrigin('https://*.example.com'), false); // wildcard host
assert.equal(isExactOrigin('https:'), false); // scheme-only
assert.equal(isExactOrigin('*'), false);
assert.equal(isExactOrigin('data:'), false);
});
test('isExactOrigin rejects a trailing-dot FQDN host (canonicalization bypass)', () => {
assert.equal(isExactOrigin('https://example.com.'), false);
assert.equal(isExactOrigin('https://mcp.example.com.'), false);
assert.equal(isExactOrigin('https://mcp.example.com.:8443'), false);
});
// --- THE crown-jewel connect-src negative test (must BITE) ------------------
// Each violating source, when placed in connect-src, must yield >=1 error.
const VIOLATING_CONNECT_SRC = [
'*', // total wildcard
'https:', // scheme-source (issue names this explicitly)
'http:', // scheme-source (issue names this explicitly)
'data:',
'blob:',
'ws:',
"'unsafe-inline'",
'https://*.evil.example', // wildcard host
'https://attacker.example.com', // exact origin but NOT in approved set
'https://mcp.example.com/exfil', // origin with path
];
for (const bad of VIOLATING_CONNECT_SRC) {
test(`connect-src negative test BITES on "${bad}"`, () => {
const mutated = clone(REAL);
mutated.directives['connect-src'] = ["'self'", bad];
const errors = validateBaseline(mutated); // no approved endpoints
assert.ok(
errors.length >= 1,
`expected a violation for connect-src source "${bad}", got none`,
);
// and buildHeaderMap must refuse to serve it (fail-closed).
assert.throws(() => buildHeaderMap(mutated), CspValidationError);
});
}
test('clean baseline (self-only) passes with NO violations', () => {
const errors = validateBaseline(REAL);
assert.deepEqual(errors, [], `unexpected violations: ${errors.join(' | ')}`);
});
test('an approved exact origin is accepted only when in the allowlist', () => {
const mutated = clone(REAL);
mutated.directives['connect-src'] = ["'self'", 'https://mcp.example.com'];
// without allowlist -> rejected
assert.ok(validateBaseline(mutated).length >= 1);
// with the curated allowlist -> clean
assert.deepEqual(
validateBaseline(mutated, { approvedEndpoints: ['https://mcp.example.com'] }),
[],
);
});
// --- silent-failure / edge inputs ------------------------------------------
test('empty connect-src is rejected (must pin at least self)', () => {
const errors = validateConnectSrc([], { forbiddenTokens: FORBIDDEN });
assert.ok(errors.length >= 1);
});
test('missing connect-src directive is rejected', () => {
const mutated = clone(REAL);
delete mutated.directives['connect-src'];
const errors = validateBaseline(mutated);
assert.ok(errors.some((e) => /connect-src/.test(e)));
});
test('missing default-src / script-src are rejected (required directives)', () => {
const noDefault = clone(REAL);
delete noDefault.directives['default-src'];
assert.ok(validateBaseline(noDefault).some((e) => /default-src/.test(e)));
const noScript = clone(REAL);
delete noScript.directives['script-src'];
assert.ok(validateBaseline(noScript).some((e) => /script-src/.test(e)));
});
test('script-src unsafe-inline / unsafe-eval / scheme-source are rejected', () => {
for (const bad of ["'unsafe-inline'", "'unsafe-eval'", '*', 'https:']) {
const mutated = clone(REAL);
mutated.directives['script-src'] = ["'self'", bad];
assert.ok(
validateBaseline(mutated).some((e) => /script-src/.test(e)),
`script-src "${bad}" should be rejected`,
);
}
});
test('the real forbidden_tokens list covers *, https:, http:', () => {
for (const t of ['*', 'https:', 'http:']) {
assert.ok(FORBIDDEN.includes(t), `forbidden_tokens must include ${t}`);
}
});
test('DEFAULT_BASELINE_PATH resolves to the committed source of truth', () => {
assert.match(DEFAULT_BASELINE_PATH.replace(/\\/g, '/'), /docs\/security\/csp-baseline\.json$/);
});
test('validateBaseline on its own rejects a phantom Integrity-Policy', () => {
// header-values.js pins the exact string, so in the full stack this layer can
// never be the one that fires. It exists for a caller using validateBaseline()
// standalone — the same belt-and-suspenders shape as the HSTS max-age floor.
// Testing it directly is the only way this layer is load-bearing at all.
const baseline = loadBaseline();
assert.deepEqual(validateBaseline(baseline), []);
for (const value of ['blocked-destinations=()', 'sources=(inline)', '']) {
const phantom = structuredClone(baseline);
phantom.additional_headers['Integrity-Policy'] = value;
assert.ok(
validateBaseline(phantom).some((error) =>
/Integrity-Policy must declare a non-empty blocked-destinations/.test(error),
),
`expected validateBaseline to reject Integrity-Policy ${JSON.stringify(value)}`,
);
}
});