1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'HstsPreloadAuditor/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('URL 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
47 const hostLiteral = url.hostname.startsWith('[') && url.hostname.endsWith(']')
48 ? url.hostname.slice(1, -1)
49 : url.hostname;
50
51 const literalType = net.isIP(hostLiteral);
52 if (literalType === 4 && isPrivateIPv4(hostLiteral)) throw new Error('Private IPv4 targets are blocked');
53 if (literalType === 6 && isPrivateIPv6(hostLiteral)) throw new Error('Private IPv6 targets are blocked');
54
55 const records = literalType ? [{ address: hostLiteral, family: literalType }] : await dns.lookup(url.hostname, { all: true });
56 for (const record of records) {
57 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
58 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
59 }
60 return url;
61}
62
63
64
65
66
67
68
69async function fetchUrl(initialUrl, timeoutSeconds, redirectsRemaining = 3) {
70 await normalizeAndValidateUrl(initialUrl.href);
71 const controller = new AbortController();
72 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
73 try {
74 const response = await fetch(initialUrl, {
75 redirect: 'manual',
76 signal: controller.signal,
77 headers: { 'user-agent': USER_AGENT, accept: 'text/html,application/xhtml+xml,*/*;q=0.1' },
78 });
79
80 if ([301, 302, 303, 307, 308].includes(response.status)) {
81 if (redirectsRemaining <= 0) throw new Error('Too many redirects');
82 const location = response.headers.get('location');
83 if (!location) throw new Error('Redirect without Location header');
84 const nextUrl = new URL(location, initialUrl.href);
85 try { await response.arrayBuffer(); } catch { }
86 return fetchUrl(nextUrl, timeoutSeconds, redirectsRemaining - 1);
87 }
88
89 try { await response.arrayBuffer(); } catch { }
90 return {
91 ok: response.ok,
92 status: response.status,
93 finalUrl: response.url || initialUrl.href,
94 https: (response.url || initialUrl.href).startsWith('https://'),
95 headers: response.headers,
96 error: null,
97 };
98 } catch (error) {
99 return {
100 ok: false,
101 status: null,
102 finalUrl: initialUrl.href,
103 https: initialUrl.protocol === 'https:',
104 headers: null,
105 error: error.message,
106 };
107 } finally {
108 clearTimeout(timeout);
109 }
110}
111
112
113
114
115
116
117
118
119
120
121
122const PRELOAD_MAX_AGE_REQUIRED = 31536000;
123
124export function parseHstsHeader(rawValue) {
125 const result = {
126 raw: rawValue || null,
127 maxAge: null,
128 maxAgeRaw: null,
129 includeSubDomains: false,
130 preload: false,
131 unknownDirectives: [],
132 hasMaxAge: false,
133 hasInvalidMaxAge: false,
134 multipleMaxAge: false,
135 hasExtraDirectives: false,
136 extraDirectives: [],
137 valid: false,
138 };
139 if (!rawValue || typeof rawValue !== 'string' || rawValue.trim() === '') return result;
140
141 const known = new Set(['max-age', 'includesubdomains', 'preload']);
142 let maxAgeCount = 0;
143 const extra = new Set();
144
145
146
147 for (const part of rawValue.split(',')) {
148 for (const token of part.split(';')) {
149 const trimmed = token.trim();
150 if (trimmed === '') continue;
151 const eq = trimmed.indexOf('=');
152 const name = (eq === -1 ? trimmed : trimmed.slice(0, eq)).trim().toLowerCase();
153 const value = eq === -1 ? null : trimmed.slice(eq + 1).trim();
154
155 if (name === 'max-age') {
156 maxAgeCount += 1;
157 result.maxAgeRaw = value;
158 if (value !== null && /^\d+$/.test(value)) {
159 result.maxAge = Number(value);
160 result.hasMaxAge = true;
161 } else {
162 result.hasInvalidMaxAge = true;
163 }
164 } else if (name === 'includesubdomains') {
165 result.includeSubDomains = true;
166 } else if (name === 'preload') {
167 result.preload = true;
168 } else {
169
170 result.unknownDirectives.push(trimmed);
171 extra.add(name);
172 }
173 }
174 }
175
176 if (maxAgeCount > 1) result.multipleMaxAge = true;
177 if (extra.size > 0) {
178 result.hasExtraDirectives = true;
179 result.extraDirectives = [...extra];
180 }
181 result.valid = result.hasMaxAge && !result.hasInvalidMaxAge;
182 return result;
183}
184
185
186
187
188
189
190
191
192
193
194export function classifyHsts(parsed) {
195 if (parsed.hasInvalidMaxAge) return 'invalid';
196 if (!parsed.hasMaxAge) return 'missing';
197 if (parsed.maxAge < PRELOAD_MAX_AGE_REQUIRED) return 'short-max-age';
198 if (!parsed.includeSubDomains) return 'missing-include-subdomains';
199 if (!parsed.preload) return 'missing-preload';
200 return 'preload-eligible';
201}
202
203
204
205
206
207
208
209export function scoreHsts(analysis) {
210 const issues = [];
211 const recommendations = [];
212 const { hasHsts, parsed, classification, httpsRedirectOk, validCertificate } = analysis;
213
214 if (!hasHsts) {
215 issues.push('No Strict-Transport-Security header was served on the HTTPS response. Browsers will still permit plain-HTTP connections to this host.');
216 recommendations.push('Configure the web server to send Strict-Transport-Security: max-age=31536000; includeSubDomains; preload on all HTTPS responses.');
217 return finalize(20, issues, recommendations);
218 }
219
220 let score = 70;
221
222 if (classification === 'invalid') {
223 score -= 30;
224 issues.push(`The max-age directive value "${parsed.maxAgeRaw}" is not a valid non-negative integer. Browsers ignore the entire header.`);
225 recommendations.push('Set max-age to a plain number of seconds, e.g. max-age=31536000.');
226 } else {
227 if (!parsed.includeSubDomains) {
228 score -= 15;
229 issues.push('The includeSubDomains directive is missing. Subdomains are not covered by the HSTS policy and the domain cannot be preloaded.');
230 recommendations.push('Add the includeSubDomains directive. Confirm every subdomain serves valid HTTPS before doing so.');
231 }
232 if (!parsed.preload) {
233 score -= 10;
234 issues.push('The preload directive is missing. hstspreload.org will reject a submission without it.');
235 recommendations.push('Add the preload directive once all subdomains serve valid HTTPS over valid certificates.');
236 }
237 if (parsed.hasMaxAge && parsed.maxAge < PRELOAD_MAX_AGE_REQUIRED) {
238 score -= 15;
239 issues.push(`max-age is ${parsed.maxAge} seconds, below the ${PRELOAD_MAX_AGE_REQUIRED} seconds (1 year) required for preload submission.`);
240 recommendations.push(`Raise max-age to at least ${PRELOAD_MAX_AGE_REQUIRED}.`);
241 }
242 if (parsed.multipleMaxAge) {
243 score -= 5;
244 issues.push('Multiple max-age directives were detected in the header. This is malformed and the header may be ignored.');
245 recommendations.push('Send a single max-age directive.');
246 }
247 if (parsed.hasExtraDirectives) {
248 issues.push(`Unrecognized directive(s) present: ${parsed.extraDirectives.join(', ')}. RFC 6797 requires browsers to ignore them, but hstspreload.org submission requires a clean header.`);
249 recommendations.push('Remove any directives other than max-age, includeSubDomains, and preload.');
250 }
251 }
252
253 if (classification === 'preload-eligible' && httpsRedirectOk !== false && validCertificate !== false) {
254 score = 100;
255 }
256
257 if (httpsRedirectOk === false) {
258 score -= 15;
259 issues.push('The plain-HTTP (port 80) request did not redirect to HTTPS on the same host. Preload submission requires an HTTP-to-HTTPS redirect.');
260 recommendations.push('Configure the HTTP (port 80) virtual host to redirect to the HTTPS URL on the same host.');
261 }
262
263 if (validCertificate === false) {
264 score -= 15;
265 issues.push('The HTTPS request failed certificate validation or did not complete. Preload submission requires a valid certificate chain.');
266 recommendations.push('Fix the TLS certificate chain (valid, non-expired, matching the hostname) and retry.');
267 }
268
269 return finalize(score, issues, recommendations);
270}
271
272function finalize(score, issues, recommendations) {
273 const bounded = Math.max(0, Math.min(100, score));
274 const grade = bounded >= 95 ? 'A+'
275 : bounded >= 85 ? 'A'
276 : bounded >= 75 ? 'B'
277 : bounded >= 65 ? 'C'
278 : bounded >= 50 ? 'D'
279 : bounded >= 30 ? 'E'
280 : 'F';
281
282 if (bounded >= 95 && issues.length === 0) {
283 recommendations.push('HSTS preload requirements are met. Submit the domain at hstspreload.org, then monitor that the header never regresses after deploys.');
284 }
285
286 if (recommendations.length === 0 && issues.length === 0) {
287 recommendations.push('No HSTS issues detected.');
288 }
289
290 return { score: bounded, grade, issues, recommendations };
291}
292
293
294
295
296
297
298
299
300export async function auditHstsPreload(input) {
301 const timeoutSeconds = Math.min(Math.max(Number(input.timeoutSeconds || DEFAULT_TIMEOUT_SECONDS), 3), 30);
302 const checkHttpRedirect = input.checkHttpRedirect !== false;
303 const checkWwwSubdomain = input.checkWwwSubdomain === true;
304
305 let startUrl;
306 try {
307 startUrl = await normalizeAndValidateUrl(input.startUrl);
308 } catch (validationError) {
309 return errorResult(input.startUrl, validationError.message);
310 }
311
312
313 let fetched;
314 try {
315 fetched = await fetchUrl(startUrl, timeoutSeconds);
316 } catch (fetchError) {
317 return errorResult(input.startUrl, fetchError.message);
318 }
319
320 if (fetched.error) {
321 return errorResult(input.startUrl, fetched.error, fetched.finalUrl, fetched.https, fetched.status);
322 }
323
324 const rawHsts = fetched.headers ? fetched.headers.get('strict-transport-security') : null;
325 const hasHsts = rawHsts !== null && rawHsts.trim() !== '';
326 const parsed = hasHsts ? parseHstsHeader(rawHsts) : parseHstsHeader(null);
327 const classification = hasHsts ? classifyHsts(parsed) : 'missing';
328
329
330 let httpsRedirectOk = null;
331 let httpRedirectStatus = null;
332 let httpRedirectTarget = null;
333 if (checkHttpRedirect && startUrl.protocol === 'https:') {
334 const httpUrl = new URL(startUrl.href);
335 httpUrl.protocol = 'http:';
336 const httpResult = await fetchUrl(httpUrl, timeoutSeconds);
337 if (httpResult.error) {
338 httpsRedirectOk = false;
339 httpRedirectStatus = null;
340 httpRedirectTarget = null;
341 } else if (httpResult.https) {
342 httpsRedirectOk = true;
343 httpRedirectStatus = httpResult.status;
344 httpRedirectTarget = httpResult.finalUrl;
345 } else {
346 httpsRedirectOk = false;
347 httpRedirectStatus = httpResult.status;
348 httpRedirectTarget = httpResult.finalUrl;
349 }
350 }
351
352
353 let wwwSubdomainOk = null;
354 let wwwSubdomainStatus = null;
355 if (checkWwwSubdomain && startUrl.hostname.split('.').length > 2) {
356 const parts = startUrl.hostname.split('.');
357 if (parts[0] === 'www') {
358
359 const bareHost = parts.slice(1).join('.');
360 try {
361 const bareUrl = new URL(`https://${bareHost}/`);
362 const bareResult = await fetchUrl(bareUrl, timeoutSeconds);
363 wwwSubdomainOk = !bareResult.error && bareResult.ok;
364 wwwSubdomainStatus = bareResult.status;
365 } catch {
366 wwwSubdomainOk = false;
367 }
368 } else {
369 try {
370 const wwwUrl = new URL(`https://www.${startUrl.hostname}/`);
371 const wwwResult = await fetchUrl(wwwUrl, timeoutSeconds);
372 wwwSubdomainOk = !wwwResult.error && wwwResult.ok;
373 wwwSubdomainStatus = wwwResult.status;
374 } catch {
375 wwwSubdomainOk = false;
376 }
377 }
378 }
379
380
381
382
383 const validCertificate = fetched.https && fetched.ok;
384
385 const analysis = {
386 hasHsts,
387 parsed,
388 classification,
389 httpsRedirectOk,
390 validCertificate,
391 };
392
393 const scored = scoreHsts(analysis);
394
395
396 const headerEligible = classification === 'preload-eligible';
397 const redirectEligible = httpsRedirectOk !== false;
398 const certEligible = validCertificate !== false;
399 const wwwEligible = wwwSubdomainOk !== false;
400 const preloadEligible = headerEligible && redirectEligible && certEligible && wwwEligible;
401
402 return {
403 inputUrl: input.startUrl,
404 normalizedInputUrl: startUrl.href,
405 finalUrl: fetched.finalUrl,
406 https: fetched.https,
407 ok: true,
408 checkedAt: new Date().toISOString(),
409 httpStatus: fetched.status,
410 hasHsts,
411 rawHsts,
412 maxAge: parsed.maxAge,
413 maxAgeMeetsPreload: parsed.hasMaxAge && parsed.maxAge >= PRELOAD_MAX_AGE_REQUIRED,
414 includeSubDomains: parsed.includeSubDomains,
415 preloadDirective: parsed.preload,
416 hasExtraDirectives: parsed.hasExtraDirectives,
417 extraDirectives: parsed.extraDirectives,
418 multipleMaxAge: parsed.multipleMaxAge,
419 classification,
420 validCertificate,
421 httpRedirectChecked: httpsRedirectOk !== null,
422 httpRedirectOk: httpsRedirectOk,
423 httpRedirectStatus,
424 httpRedirectTarget,
425 wwwSubdomainChecked: wwwSubdomainOk !== null,
426 wwwSubdomainOk,
427 wwwSubdomainStatus,
428 preloadEligible,
429 submissionReady: preloadEligible,
430 score: scored.score,
431 grade: scored.grade,
432 issues: scored.issues,
433 recommendations: scored.recommendations,
434 error: null,
435 };
436}
437
438function errorResult(inputUrl, errorMsg, finalUrl, https, httpStatus) {
439 return {
440 inputUrl,
441 normalizedInputUrl: null,
442 finalUrl: finalUrl || (typeof inputUrl === 'string' ? inputUrl : null),
443 https: https ?? false,
444 ok: false,
445 checkedAt: new Date().toISOString(),
446 httpStatus: httpStatus ?? null,
447 hasHsts: false,
448 rawHsts: null,
449 maxAge: null,
450 maxAgeMeetsPreload: false,
451 includeSubDomains: false,
452 preloadDirective: false,
453 hasExtraDirectives: false,
454 extraDirectives: [],
455 multipleMaxAge: false,
456 classification: 'missing',
457 validCertificate: false,
458 httpRedirectChecked: false,
459 httpRedirectOk: null,
460 httpRedirectStatus: null,
461 httpRedirectTarget: null,
462 wwwSubdomainChecked: false,
463 wwwSubdomainOk: null,
464 wwwSubdomainStatus: null,
465 preloadEligible: false,
466 submissionReady: false,
467 score: 0,
468 grade: 'F',
469 issues: ['The request failed before the HSTS header could be inspected. Verify the URL is reachable over HTTPS and try again.'],
470 recommendations: ['Verify the URL is public, reachable over HTTPS with a valid certificate, and returns a response.'],
471 error: errorMsg,
472 };
473}
474
475
476
477
478
479const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
480
481if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
482 await Actor.init();
483 try {
484 const input = await Actor.getInput();
485 const result = await auditHstsPreload(input || {});
486 await Actor.pushData(result);
487 await Actor.setValue('OUTPUT', result);
488 Actor.log.info('HSTS preload audit complete', {
489 hasHsts: result.hasHsts,
490 classification: result.classification,
491 preloadEligible: result.preloadEligible,
492 score: result.score,
493 grade: result.grade,
494 });
495 } finally {
496 await Actor.exit();
497 }
498}