1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'HttpSecurityHeadersAuditor/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 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) {
42 throw new Error('Only HTTP and HTTPS URLs are supported');
43 }
44 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
45 const url = new URL(withScheme);
46 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
47 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
48
49 const literalType = net.isIP(url.hostname);
50 if (literalType === 4 && isPrivateIPv4(url.hostname)) throw new Error('Private IPv4 targets are blocked');
51 if (literalType === 6 && isPrivateIPv6(url.hostname)) throw new Error('Private IPv6 targets are blocked');
52
53 const records = literalType ? [{ address: url.hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
54 for (const record of records) {
55 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
56 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
57 }
58 return url;
59}
60
61
62
63
64
65async function fetchHeaders(initialUrl, timeoutSeconds, redirectsRemaining = 3) {
66 await normalizeAndValidateUrl(initialUrl.href);
67 const controller = new AbortController();
68 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
69 try {
70 const response = await fetch(initialUrl, {
71 redirect: 'manual',
72 signal: controller.signal,
73 headers: {
74 'user-agent': USER_AGENT,
75 accept: 'text/html,application/xhtml+xml,*/*;q=0.1',
76 },
77 });
78
79 if ([301, 302, 303, 307, 308].includes(response.status)) {
80 if (redirectsRemaining <= 0) throw new Error('Too many redirects');
81 const location = response.headers.get('location');
82 if (!location) throw new Error('Redirect without Location header');
83 const nextUrl = new URL(location, initialUrl.href);
84 await normalizeAndValidateUrl(nextUrl.href);
85 return fetchHeaders(nextUrl, timeoutSeconds, redirectsRemaining - 1);
86 }
87
88
89 try { await response.arrayBuffer(); } catch { }
90
91 const headerMap = {};
92 response.headers.forEach((value, key) => {
93 headerMap[key.toLowerCase()] = value;
94 });
95
96 return {
97 ok: response.ok,
98 status: response.status,
99 finalUrl: response.url || initialUrl.href,
100 https: (response.url || initialUrl.href).startsWith('https://'),
101 headers: headerMap,
102 error: null,
103 };
104 } catch (error) {
105 return {
106 ok: false,
107 status: null,
108 finalUrl: initialUrl.href,
109 https: initialUrl.protocol === 'https:',
110 headers: {},
111 error: error.message,
112 };
113 } finally {
114 clearTimeout(timeout);
115 }
116}
117
118
119
120
121
122
123
124const HEADER_RULES = [
125 {
126 name: 'strict-transport-security',
127 title: 'Strict-Transport-Security',
128 weight: 20,
129 applies: (ctx) => ctx.https,
130 evaluate: (value) => {
131 if (!value) return { status: 'missing', note: 'Not set. Browsers can be downgraded to plaintext HTTP.' };
132 const maxAgeMatch = /max-age=(\d+)/i.exec(value);
133 const maxAge = maxAgeMatch ? Number(maxAgeMatch[1]) : 0;
134 if (maxAge < 15552000) return { status: 'warn', note: `max-age=${maxAge} is below the recommended 6 months (15768000s).` };
135 const hasSubdomains = /includeSubDomains/i.test(value);
136 if (!hasSubdomains) return { status: 'warn', note: 'Missing includeSubDomains directive; subdomains are not protected.' };
137 return { status: 'good', note: `max-age=${maxAge}${hasSubdomains ? ', includeSubDomains' : ''}.` };
138 },
139 recommendation: 'Set Strict-Transport-Security: max-age=63072000; includeSubDomains; preload on all HTTPS responses.',
140 },
141 {
142 name: 'content-security-policy',
143 title: 'Content-Security-Policy',
144 weight: 20,
145 applies: () => true,
146 evaluate: (value) => {
147 if (!value) return { status: 'missing', note: 'Not set. Cross-site scripting and data injection are not mitigated.' };
148 if (/default-src\s+\*|script-src\s+[^;]*\*|script-src\s+[^;]*'unsafe-inline'/i.test(value)) {
149 return { status: 'warn', note: 'Policy contains wildcard sources or unsafe-inline; protection is weakened.' };
150 }
151 return { status: 'good', note: 'Policy present and avoids obvious wildcards/unsafe-inline.' };
152 },
153 recommendation: "Set a Content-Security-Policy with a restrictive default-src and avoid 'unsafe-inline' and wildcard sources.",
154 },
155 {
156 name: 'x-content-type-options',
157 title: 'X-Content-Type-Options',
158 weight: 10,
159 applies: () => true,
160 evaluate: (value) => {
161 if (!value) return { status: 'missing', note: 'Not set. Browsers may MIME-sniff responses.' };
162 if (!/nosniff/i.test(value)) return { status: 'warn', note: 'Set but does not contain nosniff.' };
163 return { status: 'good', note: 'nosniff is set.' };
164 },
165 recommendation: 'Set X-Content-Type-Options: nosniff to prevent MIME-type sniffing.',
166 },
167 {
168 name: 'x-frame-options',
169 title: 'X-Frame-Options',
170 weight: 10,
171 applies: () => true,
172 evaluate: (value) => {
173 if (!value) return { status: 'missing', note: 'Not set. The page can be framed (clickjacking risk).' };
174 const v = value.trim().toLowerCase();
175 if (v === 'deny' || v === 'sameorigin') return { status: 'good', note: `Set to ${v}.` };
176 return { status: 'warn', note: `Value "${value}" is not deny or sameorigin.` };
177 },
178 recommendation: 'Set X-Frame-Options: DENY (or SAMEORIGIN) or use CSP frame-ancestors to prevent clickjacking.',
179 },
180 {
181 name: 'referrer-policy',
182 title: 'Referrer-Policy',
183 weight: 10,
184 applies: () => true,
185 evaluate: (value) => {
186 if (!value) return { status: 'missing', note: 'Not set. Full Referer headers may leak to third parties.' };
187 const weak = ['unsafe-url', 'no-referrer-when-downgrade'].includes(value.trim().toLowerCase());
188 if (weak) return { status: 'warn', note: `Policy "${value}" may leak full URLs to third parties.` };
189 return { status: 'good', note: `Policy "${value}".` };
190 },
191 recommendation: 'Set Referrer-Policy: strict-origin-when-cross-origin to limit referrer leakage.',
192 },
193 {
194 name: 'permissions-policy',
195 title: 'Permissions-Policy',
196 weight: 10,
197 applies: () => true,
198 evaluate: (value) => {
199 if (!value) return { status: 'missing', note: 'Not set. Browser features (camera, geolocation, etc.) are not restricted.' };
200 return { status: 'good', note: 'Policy present.' };
201 },
202 recommendation: 'Set Permissions-Policy to restrict access to sensitive browser features (camera, microphone, geolocation).',
203 },
204 {
205 name: 'cross-origin-opener-policy',
206 title: 'Cross-Origin-Opener-Policy',
207 weight: 5,
208 applies: () => true,
209 evaluate: (value) => {
210 if (!value) return { status: 'missing', note: 'Not set. The page is not isolated from cross-origin openers.' };
211 const v = value.trim().toLowerCase();
212 if (v === 'same-origin') return { status: 'good', note: 'Set to same-origin.' };
213 return { status: 'warn', note: `Value "${value}"; same-origin is recommended.` };
214 },
215 recommendation: 'Set Cross-Origin-Opener-Policy: same-origin to isolate browsing contexts.',
216 },
217 {
218 name: 'cross-origin-resource-policy',
219 title: 'Cross-Origin-Resource-Policy',
220 weight: 5,
221 applies: () => true,
222 evaluate: (value) => {
223 if (!value) return { status: 'missing', note: 'Not set. Resources may be loaded cross-origin without restriction.' };
224 return { status: 'good', note: `Set to "${value}".` };
225 },
226 recommendation: 'Set Cross-Origin-Resource-Policy: same-site to block unauthorized cross-origin loads.',
227 },
228 {
229 name: 'x-permitted-cross-domain-policies',
230 title: 'X-Permitted-Cross-Domain-Policies',
231 weight: 5,
232 applies: () => true,
233 evaluate: (value) => {
234 if (!value) return { status: 'missing', note: 'Not set. Adobe/PDF cross-domain policies may be permitted.' };
235 if (/none/i.test(value)) return { status: 'good', note: 'Set to none.' };
236 return { status: 'warn', note: `Value "${value}"; "none" is recommended.` };
237 },
238 recommendation: 'Set X-Permitted-Cross-Domain-Policies: none to disable Adobe cross-domain policy files.',
239 },
240 {
241 name: 'x-xss-protection',
242 title: 'X-XSS-Protection',
243 weight: 5,
244 applies: () => true,
245 evaluate: (value) => {
246 if (!value) return { status: 'missing', note: 'Not set. (Legacy header; modern browsers rely on CSP instead.)' };
247 if (/mode\s*=\s*block/i.test(value)) return { status: 'good', note: 'Set to block mode.' };
248 if (/1\s*;\s*report/i.test(value)) return { status: 'good', note: 'Reporting mode enabled.' };
249 return { status: 'warn', note: `Value "${value}"; prefer mode=block or rely on CSP.` };
250 },
251 recommendation: 'Set X-XSS-Protection: 1; mode=block for legacy browser support (modern browsers rely on CSP).',
252 },
253];
254
255export function analyzeHeaders(headerMap, context) {
256 const ctx = { https: context.https };
257 const results = [];
258 let earned = 0;
259 let possible = 0;
260
261 for (const rule of HEADER_RULES) {
262 if (!rule.applies(ctx)) continue;
263 const value = headerMap[rule.name] || null;
264 const evaluation = rule.evaluate(value);
265 possible += rule.weight;
266 if (evaluation.status === 'good') earned += rule.weight;
267 else if (evaluation.status === 'warn') earned += Math.round(rule.weight * 0.5);
268
269 results.push({
270 name: rule.title,
271 header: rule.name,
272 present: Boolean(value),
273 value: value || '',
274 status: evaluation.status,
275 note: evaluation.note,
276 weight: rule.weight,
277 recommendation: evaluation.status === 'good' ? null : rule.recommendation,
278 });
279 }
280
281 return { headers: results, earned, possible };
282}
283
284export function scoreAudit(earned, possible) {
285 if (possible === 0) return 0;
286 return Math.round((earned / possible) * 100);
287}
288
289export function gradeFromScore(score) {
290 if (score >= 95) return 'A+';
291 if (score >= 85) return 'A';
292 if (score >= 75) return 'B';
293 if (score >= 65) return 'C';
294 if (score >= 50) return 'D';
295 if (score >= 30) return 'E';
296 return 'F';
297}
298
299export function buildRecommendations(headerResults) {
300 const recs = [];
301 const missing = headerResults.filter((h) => h.status === 'missing');
302 const warned = headerResults.filter((h) => h.status === 'warn');
303 for (const h of [...missing, ...warned]) {
304 if (h.recommendation) recs.push(h.recommendation);
305 }
306 return recs;
307}
308
309
310
311
312
313export async function auditSecurityHeaders(input) {
314 const startUrl = await normalizeAndValidateUrl(input.startUrl);
315 const timeoutSeconds = Math.min(Math.max(Number(input.timeoutSeconds || DEFAULT_TIMEOUT_SECONDS), 3), 30);
316
317 const fetchResult = await fetchHeaders(startUrl, timeoutSeconds);
318
319 if (fetchResult.error) {
320 return {
321 inputUrl: input.startUrl,
322 finalUrl: fetchResult.finalUrl,
323 https: fetchResult.https,
324 ok: false,
325 score: 0,
326 grade: 'F',
327 checkedAt: new Date().toISOString(),
328 headers: [],
329 recommendations: ['The request failed before headers could be inspected. Verify the URL is reachable and try again.'],
330 error: fetchResult.error,
331 };
332 }
333
334 const { headers: headerResults, earned, possible } = analyzeHeaders(fetchResult.headers, { https: fetchResult.https });
335 const score = scoreAudit(earned, possible);
336 const grade = gradeFromScore(score);
337 const recommendations = buildRecommendations(headerResults);
338
339 return {
340 inputUrl: input.startUrl,
341 finalUrl: fetchResult.finalUrl,
342 https: fetchResult.https,
343 ok: true,
344 score,
345 grade,
346 checkedAt: new Date().toISOString(),
347 headers: headerResults,
348 recommendations,
349 };
350}
351
352
353
354
355
356const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
357
358if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
359 await Actor.init();
360 try {
361 const input = await Actor.getInput();
362 const result = await auditSecurityHeaders(input || {});
363 await Actor.pushData(result);
364 await Actor.setValue('OUTPUT', result);
365 Actor.log.info('HTTP security headers audit complete', { finalUrl: result.finalUrl, score: result.score, grade: result.grade });
366 } finally {
367 await Actor.exit();
368 }
369}