1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'CspAuditor/0.1 (+https://apify.com)';
7const DEFAULT_TIMEOUT_SECONDS = 10;
8
9
10
11
12
13
14
15
16function isPrivateIPv4(ip) {
17 const parts = ip.split('.').map(Number);
18 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
19 const [a, b] = parts;
20 return a === 10
21 || (a === 172 && b >= 16 && b <= 31)
22 || (a === 192 && b === 168)
23 || a === 127
24 || a === 0
25 || (a === 169 && b === 254);
26}
27
28function isPrivateIPv6(ip) {
29 const normalized = ip.toLowerCase();
30 return normalized === '::1'
31 || normalized.startsWith('fc')
32 || normalized.startsWith('fd')
33 || normalized.startsWith('fe80:');
34}
35
36export async function normalizeAndValidateUrl(rawUrl) {
37 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
38 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) {
39 throw new Error('Only HTTP and HTTPS URLs are supported');
40 }
41 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
42 const url = new URL(withScheme);
43 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
44 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
45
46 const literalType = net.isIP(url.hostname);
47 if (literalType === 4 && isPrivateIPv4(url.hostname)) throw new Error('Private IPv4 targets are blocked');
48 if (literalType === 6 && isPrivateIPv6(url.hostname)) throw new Error('Private IPv6 targets are blocked');
49
50 const records = literalType ? [{ address: url.hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
51 for (const record of records) {
52 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
53 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
54 }
55 return url;
56}
57
58
59
60
61
62async function fetchHeaders(initialUrl, timeoutSeconds, redirectsRemaining = 3) {
63 await normalizeAndValidateUrl(initialUrl.href);
64 const controller = new AbortController();
65 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
66 try {
67 const response = await fetch(initialUrl, {
68 redirect: 'manual',
69 signal: controller.signal,
70 headers: {
71 'user-agent': USER_AGENT,
72 accept: 'text/html,application/xhtml+xml,*/*;q=0.1',
73 },
74 });
75
76 if ([301, 302, 303, 307, 308].includes(response.status)) {
77 if (redirectsRemaining <= 0) throw new Error('Too many redirects');
78 const location = response.headers.get('location');
79 if (!location) throw new Error('Redirect without Location header');
80 const nextUrl = new URL(location, initialUrl.href);
81 await normalizeAndValidateUrl(nextUrl.href);
82 return fetchHeaders(nextUrl, timeoutSeconds, redirectsRemaining - 1);
83 }
84
85
86 try { await response.arrayBuffer(); } catch { }
87
88 return {
89 ok: response.ok,
90 status: response.status,
91 finalUrl: response.url || initialUrl.href,
92 https: (response.url || initialUrl.href).startsWith('https://'),
93 csp: response.headers.get('content-security-policy'),
94 cspReportOnly: response.headers.get('content-security-policy-report-only'),
95 reportingEndpoints: response.headers.get('reporting-endpoints'),
96 reportTo: response.headers.get('report-to'),
97 error: null,
98 };
99 } catch (error) {
100 return {
101 ok: false,
102 status: null,
103 finalUrl: initialUrl.href,
104 https: initialUrl.protocol === 'https:',
105 csp: null,
106 cspReportOnly: null,
107 reportingEndpoints: null,
108 reportTo: null,
109 error: error.message,
110 };
111 } finally {
112 clearTimeout(timeout);
113 }
114}
115
116
117
118
119
120
121const FETCH_DIRECTIVES = new Set([
122 'default-src', 'script-src', 'style-src', 'img-src', 'font-src',
123 'connect-src', 'media-src', 'object-src', 'frame-src', 'child-src',
124 'worker-src', 'manifest-src', 'prefetch-src', 'object-src', 'navigate-to',
125 'script-src-elem', 'script-src-attr', 'style-src-elem', 'style-src-attr',
126 'base-uri', 'form-action', 'frame-ancestors', 'plugin-types',
127]);
128
129const DEPRECATED_DIRECTIVES = new Set([
130 'report-uri',
131 'plugin-types',
132 'reflect-nonce',
133 'prefetch-src',
134 'child-src',
135]);
136
137const KEYWORDS = new Set([
138 "'self'", "'none'", "'strict-dynamic'", "'unsafe-inline'",
139 "'unsafe-eval'", "'unsafe-hashes'", "'wasm-unsafe-eval'",
140 "'allow-downloads'", "'unsafe-allow-redirects'",
141]);
142
143
144
145
146function splitCspSources(value) {
147 if (!value || typeof value !== 'string') return [];
148 const tokens = [];
149 let buf = '';
150 let inQuote = false;
151 for (let i = 0; i < value.length; i++) {
152 const ch = value[i];
153 if (ch === "'") inQuote = !inQuote;
154 if (ch === ' ' && !inQuote) {
155 if (buf.length) { tokens.push(buf); buf = ''; }
156 } else {
157 buf += ch;
158 }
159 }
160 if (buf.length) tokens.push(buf);
161 return tokens;
162}
163
164
165
166
167export function parseCsp(policy) {
168 if (!policy || typeof policy !== 'string') return { directives: [], duplicates: [] };
169 const directives = [];
170 const seenNames = new Map();
171 for (const rawDirective of policy.split(';')) {
172 const trimmed = rawDirective.trim();
173 if (!trimmed) continue;
174 const sources = splitCspSources(trimmed);
175 if (sources.length === 0) continue;
176 const name = sources[0].toLowerCase();
177 const value = sources.slice(1);
178 seenNames.set(name, (seenNames.get(name) || 0) + 1);
179 directives.push({ name, sources: value, raw: trimmed });
180 }
181 const duplicates = [];
182 for (const [name, count] of seenNames.entries()) {
183 if (count > 1) duplicates.push(name);
184 }
185 return { directives, duplicates };
186}
187
188function isKeyword(token) {
189 return KEYWORDS.has(token.toLowerCase());
190}
191
192function isNonce(token) {
193 return /^'nonce-[A-Za-z0-9+/=_-]+'$/i.test(token)
194 || /^'nonce-[^']+'$/i.test(token);
195}
196
197function isHash(token) {
198 return /^'sha(256|384|512)-[A-Za-z0-9+/=]+'$/i.test(token);
199}
200
201function isSchemeSource(token) {
202 return /^[a-z][a-z0-9+.-]*:$/i.test(token) && !token.includes('*');
203}
204
205function isWildcardHost(token) {
206
207 if (token === '*') return true;
208 return /^\*\.[a-z0-9.-]+/i.test(token);
209}
210
211function isHttpUrl(token) {
212 return /^http:\/\//i.test(token);
213}
214
215function isHttpsUrl(token) {
216 return /^https:\/\//i.test(token);
217}
218
219
220function analyzeDirective(directive) {
221 const { name, sources } = directive;
222 const out = {
223 name,
224 sources,
225 isFetchDirective: FETCH_DIRECTIVES.has(name),
226 isDeprecated: DEPRECATED_DIRECTIVES.has(name),
227 keywords: [],
228 unsafeInline: false,
229 unsafeEval: false,
230 unsafeHashes: false,
231 hasNonce: false,
232 hasHash: false,
233 hasWildcard: false,
234 hasNonHttps: false,
235 nonHttpsSources: [],
236 wildcardSources: [],
237 issues: [],
238 };
239
240 for (const src of sources) {
241 const lower = src.toLowerCase();
242 if (isKeyword(src)) out.keywords.push(lower);
243 if (lower === "'unsafe-inline'") out.unsafeInline = true;
244 if (lower === "'unsafe-eval'") out.unsafeEval = true;
245 if (lower === "'unsafe-hashes'") out.unsafeHashes = true;
246 if (isNonce(src)) out.hasNonce = true;
247 if (isHash(src)) out.hasHash = true;
248 if (isWildcardHost(src)) { out.hasWildcard = true; out.wildcardSources.push(src); }
249 if (isHttpUrl(src) && !lower.startsWith('https://', 0)) {
250 out.hasNonHttps = true;
251 out.nonHttpsSources.push(src);
252 }
253 if (isSchemeSource(src) && lower === 'http:') {
254 out.hasNonHttps = true;
255 out.nonHttpsSources.push(src);
256 }
257 }
258
259
260 if (out.unsafeInline && (name === 'script-src' || name === 'style-src' || name === 'default-src')) {
261 out.issues.push(`'unsafe-inline' in ${name} weakens the policy; use nonces or hashes for inline content.`);
262 }
263 if (out.unsafeEval && (name === 'script-src' || name === 'default-src')) {
264 out.issues.push(`'unsafe-eval' in ${name} allows eval; remove it unless required by frameworks.`);
265 }
266 if (out.hasWildcard && out.isFetchDirective) {
267 out.issues.push(`${name} uses wildcard sources (${out.wildcardSources.join(', ')}); restrict to known origins.`);
268 }
269 if (out.hasNonHttps && out.isFetchDirective) {
270 out.issues.push(`${name} lists non-HTTPS sources (${out.nonHttpsSources.join(', ')}); prefer HTTPS-only.`);
271 }
272 return out;
273}
274
275
276function analyzePolicy(policy, { isReportOnly }) {
277 const { directives, duplicates } = parseCsp(policy);
278 const analyzed = directives.map(analyzeDirective);
279 const issues = [];
280 let unsafeInline = false;
281 let unsafeEval = false;
282 let unsafeHashes = false;
283 let hasWildcardSource = false;
284 let hasNonHttpsSource = false;
285 let hasNonce = false;
286 let hasHash = false;
287 let deprecatedDirectives = [];
288 const directiveNames = new Set();
289 let sourceCount = 0;
290
291 for (const d of analyzed) {
292 directiveNames.add(d.name);
293 sourceCount += d.sources.length;
294 if (d.unsafeInline) unsafeInline = true;
295 if (d.unsafeEval) unsafeEval = true;
296 if (d.unsafeHashes) unsafeHashes = true;
297 if (d.hasWildcard && d.isFetchDirective) hasWildcardSource = true;
298 if (d.hasNonHttps && d.isFetchDirective) hasNonHttpsSource = true;
299 if (d.hasNonce) hasNonce = true;
300 if (d.hasHash) hasHash = true;
301 if (d.isDeprecated) deprecatedDirectives.push(d.name);
302 for (const issue of d.issues) issues.push({
303 directive: d.name,
304 severity: 'warn',
305 message: issue,
306 });
307 }
308
309 const hasDefaultSrc = directiveNames.has('default-src');
310 const hasFrameAncestors = directiveNames.has('frame-ancestors');
311
312
313 if (!isReportOnly) {
314 if (!hasDefaultSrc) {
315 issues.push({
316 directive: '',
317 severity: 'warn',
318 message: 'Missing default-src; add a restrictive default-src as a fallback to avoid per-directive coverage gaps.',
319 });
320 }
321 if (!hasFrameAncestors) {
322 issues.push({
323 directive: '',
324 severity: 'warn',
325 message: 'Missing frame-ancestors; set it to prevent clickjacking (replaces X-Frame-Options).',
326 });
327 }
328
329 if (!hasDefaultSrc && !directiveNames.has('script-src')) {
330 issues.push({
331 directive: '',
332 severity: 'warn',
333 message: 'No default-src and no script-src; script loading is unconstrained.',
334 });
335 }
336 }
337
338 for (const dup of duplicates) {
339 issues.push({
340 directive: dup,
341 severity: 'warn',
342 message: `Duplicate directive "${dup}" detected; only the first declaration is applied.`,
343 });
344 }
345 for (const dep of deprecatedDirectives) {
346 issues.push({
347 directive: dep,
348 severity: 'warn',
349 message: `Deprecated directive "${dep}" detected; migrate to the modern equivalent.`,
350 });
351 }
352
353 return {
354 directives: analyzed,
355 directiveCount: analyzed.length,
356 sourceCount,
357 duplicates,
358 duplicateDirectiveCount: duplicates.length,
359 deprecatedDirectives,
360 deprecatedDirectiveCount: deprecatedDirectives.length,
361 unsafeInline,
362 unsafeEval,
363 unsafeHashes,
364 hasWildcardSource,
365 hasNonHttpsSource,
366 hasDefaultSrc,
367 hasFrameAncestors,
368 hasNonce,
369 hasHash,
370 issues,
371 };
372}
373
374function scoreCsp(analysis, { hasPolicy, isReportOnly }) {
375
376 if (!hasPolicy && !isReportOnly) {
377 return {
378 score: 0,
379 grade: 'F',
380 issues: ['No Content-Security-Policy header present; the page has no CSP mitigation against XSS and injection.'],
381 recommendations: ['Set a Content-Security-Policy header starting with default-src as a baseline.'],
382 };
383 }
384
385 let score = 100;
386 const issues = [];
387
388 if (analysis.unsafeInline) {
389 score -= 25;
390 issues.push("'unsafe-inline' is present; this weakens XSS protection significantly (nonces or hashes are recommended).");
391 }
392 if (analysis.unsafeEval) {
393 score -= 20;
394 issues.push("'unsafe-eval' is present; eval/crypto mining attack surface expands. Remove unless a framework requires it.");
395 }
396 if (analysis.unsafeHashes) {
397 score -= 10;
398 issues.push("'unsafe-hashes' is present; callers can use event-handler attributes. Prefer nonce-based policies.");
399 }
400 if (analysis.hasWildcardSource) {
401 score -= 15;
402 issues.push('Wildcard source used in one or more fetch directives; restrict sources to trusted hosts.');
403 }
404 if (analysis.hasNonHttpsSource) {
405 score -= 10;
406 issues.push('Non-HTTPS source detected; CSP can be bypassed via an insecure origin. Use HTTPS sources only.');
407 }
408 if (!analysis.hasFrameAncestors && !isReportOnly) {
409 score -= 5;
410 issues.push('Missing frame-ancestors; set it to prevent clickjacking (replaces X-Frame-Options).');
411 }
412 if (analysis.deprecatedDirectiveCount > 0) {
413 score -= Math.min(10, analysis.deprecatedDirectiveCount * 3);
414 const list = analysis.deprecatedDirectives.join(', ');
415 issues.push(`Deprecated directives in use: ${list}. Migrate to modern equivalents.`);
416 }
417 if (analysis.duplicateDirectiveCount > 0) {
418 score -= Math.min(8, analysis.duplicateDirectiveCount * 4);
419 const list = analysis.duplicates.join(', ');
420 issues.push(`Duplicate directives: ${list}. Only the first declaration is applied; consolidate.`);
421 }
422
423 if ((analysis.hasNonce || analysis.hasHash) && !analysis.unsafeInline) {
424 score = Math.min(100, score + 5);
425 }
426
427 const bounded = Math.max(0, Math.min(100, score));
428 const grade = bounded >= 95 ? 'A+'
429 : bounded >= 85 ? 'A'
430 : bounded >= 75 ? 'B'
431 : bounded >= 65 ? 'C'
432 : bounded >= 50 ? 'D'
433 : bounded >= 30 ? 'E'
434 : 'F';
435
436 const recommendations = [
437 ...issues,
438 bounded >= 95
439 ? 'CSP posture looks strong; consider load-testing with report-only changes and monitor CSP reports.'
440 : 'Review CSP reports and tighten sources; remove unsafe-inline via nonces or hashes.',
441 ];
442
443 return { score: bounded, grade, issues, recommendations };
444}
445
446export async function auditCsp(input) {
447 const timeoutSeconds = Math.min(Math.max(Number(input.timeoutSeconds || DEFAULT_TIMEOUT_SECONDS), 3), 30);
448
449
450
451
452 let startUrl;
453 let fetched;
454 try {
455 startUrl = await normalizeAndValidateUrl(input.startUrl);
456 fetched = await fetchHeaders(startUrl, timeoutSeconds);
457 } catch (validationError) {
458 return {
459 inputUrl: input.startUrl,
460 normalizedInputUrl: null,
461 finalUrl: typeof input.startUrl === 'string' ? input.startUrl : null,
462 https: false,
463 ok: false,
464 checkedAt: new Date().toISOString(),
465 httpStatus: null,
466 hasCsp: false,
467 hasReportOnly: false,
468 rawCsp: null,
469 rawReportOnly: null,
470 reportingEndpoints: null,
471 reportTo: null,
472 directives: [],
473 directiveCount: 0,
474 sourceCount: 0,
475 unsafeInline: false,
476 unsafeEval: false,
477 unsafeHashes: false,
478 hasWildcardSource: false,
479 hasNonHttpsSource: false,
480 hasDefaultSrc: false,
481 hasFrameAncestors: false,
482 hasNonce: false,
483 hasHash: false,
484 deprecatedDirectives: [],
485 deprecatedDirectiveCount: 0,
486 duplicateDirectives: [],
487 duplicateDirectiveCount: 0,
488 score: 0,
489 grade: 'F',
490 issues: ['The request failed before headers could be inspected. Verify the URL is reachable and try again.'],
491 recommendations: ['Verify the URL is public, reachable, and returns a response.'],
492 error: validationError.message,
493 };
494 }
495
496 if (fetched.error) {
497 return {
498 inputUrl: input.startUrl,
499 normalizedInputUrl: startUrl.href,
500 finalUrl: fetched.finalUrl,
501 https: fetched.https,
502 ok: false,
503 checkedAt: new Date().toISOString(),
504 httpStatus: fetched.status,
505 hasCsp: false,
506 hasReportOnly: false,
507 rawCsp: null,
508 rawReportOnly: null,
509 reportingEndpoints: null,
510 reportTo: null,
511 directives: [],
512 directiveCount: 0,
513 sourceCount: 0,
514 unsafeInline: false,
515 unsafeEval: false,
516 unsafeHashes: false,
517 hasWildcardSource: false,
518 hasNonHttpsSource: false,
519 hasDefaultSrc: false,
520 hasFrameAncestors: false,
521 hasNonce: false,
522 hasHash: false,
523 deprecatedDirectives: [],
524 deprecatedDirectiveCount: 0,
525 duplicateDirectives: [],
526 duplicateDirectiveCount: 0,
527 score: 0,
528 grade: 'F',
529 issues: ['The request failed before headers could be inspected. Verify the URL is reachable and try again.'],
530 recommendations: ['Verify the URL is public, reachable, and returns a response.'],
531 error: fetched.error,
532 };
533 }
534
535 const hasCsp = fetched.csp !== null && fetched.csp !== '';
536 const hasReportOnly = fetched.cspReportOnly !== null && fetched.cspReportOnly !== '';
537
538 const enforced = hasCsp ? analyzePolicy(fetched.csp, { isReportOnly: false }) : null;
539 const reportOnly = hasReportOnly ? analyzePolicy(fetched.cspReportOnly, { isReportOnly: true }) : null;
540
541 const primary = enforced || reportOnly || analyzePolicy("default-src 'none';", { isReportOnly: false });
542 const scored = scoreCsp(primary, { hasPolicy: hasCsp, isReportOnly: !hasCsp && hasReportOnly });
543
544 const result = {
545 inputUrl: input.startUrl,
546 normalizedInputUrl: startUrl.href,
547 finalUrl: fetched.finalUrl,
548 https: fetched.https,
549 ok: true,
550 checkedAt: new Date().toISOString(),
551 httpStatus: fetched.status,
552 hasCsp,
553 hasReportOnly,
554 rawCsp: fetched.csp,
555 rawReportOnly: fetched.cspReportOnly,
556 reportingEndpoints: fetched.reportingEndpoints,
557 reportTo: fetched.reportTo,
558 directives: primary.directives,
559 directiveCount: primary.directiveCount,
560 sourceCount: primary.sourceCount,
561 unsafeInline: primary.unsafeInline,
562 unsafeEval: primary.unsafeEval,
563 unsafeHashes: primary.unsafeHashes,
564 hasWildcardSource: primary.hasWildcardSource,
565 hasNonHttpsSource: primary.hasNonHttpsSource,
566 hasDefaultSrc: primary.hasDefaultSrc,
567 hasFrameAncestors: primary.hasFrameAncestors,
568 hasNonce: primary.hasNonce,
569 hasHash: primary.hasHash,
570 deprecatedDirectives: primary.deprecatedDirectives,
571 deprecatedDirectiveCount: primary.deprecatedDirectiveCount,
572 duplicateDirectives: primary.duplicates,
573 duplicateDirectiveCount: primary.duplicateDirectiveCount,
574 score: scored.score,
575 grade: scored.grade,
576 issues: scored.issues,
577 recommendations: scored.recommendations,
578 error: null,
579 };
580
581 return result;
582}
583
584const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
585
586if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
587 await Actor.init();
588 try {
589 const result = await auditCsp(await Actor.getInput() || {});
590 await Actor.pushData(result);
591 await Actor.setValue('OUTPUT', result);
592 Actor.log.info('CSP audit complete', { finalUrl: result.finalUrl, score: result.score, grade: result.grade, hasCsp: result.hasCsp, directiveCount: result.directiveCount });
593 } finally {
594 await Actor.exit();
595 }
596}