From 0f096b8ab3337eb77ed7c220c2742146f69e9c0d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 05:07:34 +0000 Subject: [PATCH] fix(hsts): only award preload bonus when max-age meets hstspreload.org 2-year minimum The preload +2 bonus was awarded for any max-age > 0 with a preload directive. hstspreload.org requires max-age >= 63072000 (2 years), so sites with max-age=31536000 (1 year) were receiving a misleading bonus for a preload status they could not actually attain. Now the bonus is only awarded when max-age >= 63072000; a finding and recommendation are emitted otherwise. Updated three existing tests that assumed max-age=31536000 + preload earned a perfect HSTS score, and added a dedicated test for the new behaviour. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01WpgTDYqyYdTqvMqXEhL6A9 --- src/rules.ts | 11 ++++++++++- test/analyzer.test.ts | 18 ++++++++++++------ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/rules.ts b/src/rules.ts index 779cf09..506d4fd 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -65,7 +65,16 @@ export function checkHSTS(headers: RawHeaders): HeaderFinding { if (maxAge > 0) { if (/includesubdomains/i.test(raw)) { score += 3; } else { findings.push('includeSubDomains not set'); recommendations.push('Add includeSubDomains directive'); } - if (/preload/i.test(raw)) score += 2; + if (/preload/i.test(raw)) { + // hstspreload.org requires max-age >= 63072000 (2 years). Award the bonus + // only when the site meets that threshold; otherwise surface a finding. + if (maxAge >= 63072000) { + score += 2; + } else { + findings.push(`preload is set but max-age=${maxAge} is below the 63072000 (2 year) minimum required by hstspreload.org`); + recommendations.push('Set max-age=63072000 to meet the HSTS preload minimum'); + } + } } return { header: 'Strict-Transport-Security', score, maxScore: 20, status: score >= 15 ? 'good' : 'warning', raw, findings, recommendations }; diff --git a/test/analyzer.test.ts b/test/analyzer.test.ts index ed22923..34674fb 100644 --- a/test/analyzer.test.ts +++ b/test/analyzer.test.ts @@ -80,7 +80,7 @@ describe('checkHSTS', () => { }); it('full HSTS returns score 20', () => { - const r = checkHSTS({ 'strict-transport-security': 'max-age=31536000; includeSubDomains; preload' }); + const r = checkHSTS({ 'strict-transport-security': 'max-age=63072000; includeSubDomains; preload' }); expect(r.score).toBe(20); expect(r.status).toBe('good'); }); @@ -96,14 +96,20 @@ describe('checkHSTS', () => { expect(r.score).toBe(15); }); - it('preload adds 2 bonus points', () => { - const withPreload = checkHSTS({ 'strict-transport-security': 'max-age=31536000; includeSubDomains; preload' }); - const withoutPreload = checkHSTS({ 'strict-transport-security': 'max-age=31536000; includeSubDomains' }); + it('preload adds 2 bonus points when max-age meets the 2-year requirement', () => { + const withPreload = checkHSTS({ 'strict-transport-security': 'max-age=63072000; includeSubDomains; preload' }); + const withoutPreload = checkHSTS({ 'strict-transport-security': 'max-age=63072000; includeSubDomains' }); expect(withPreload.score).toBe(withoutPreload.score + 2); }); + it('preload with max-age < 2 years triggers a finding instead of a bonus', () => { + const r = checkHSTS({ 'strict-transport-security': 'max-age=31536000; includeSubDomains; preload' }); + expect(r.findings.some(f => /preload/i.test(f))).toBe(true); + expect(r.score).toBe(18); // 10 base + 5 (1yr) + 3 (includeSubDomains), no preload bonus + }); + it('case-insensitive header name matching', () => { - const r = checkHSTS({ 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload' }); + const r = checkHSTS({ 'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload' }); expect(r.score).toBe(20); }); @@ -514,7 +520,7 @@ describe('checkCrossOriginPolicies', () => { describe('grade boundaries', () => { it('A+ at 90%', () => { const headers = { - 'strict-transport-security': 'max-age=31536000; includeSubDomains; preload', + 'strict-transport-security': 'max-age=63072000; includeSubDomains; preload', 'content-security-policy': "default-src 'self'; form-action 'self'; base-uri 'self'", 'x-frame-options': 'DENY', 'x-content-type-options': 'nosniff',