From 594e5e6afe4db1f0cc4febafb31ffb3158daacb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 05:05:39 +0000 Subject: [PATCH] fix(hsts): don't award preload credit when includeSubDomains is absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HSTS preload list (hstspreload.org) requires both includeSubDomains and preload. Previously, a header like `max-age=31536000; preload` (without includeSubDomains) scored 17/20 as 'good' despite being ineligible for preload — misleading operators into thinking their config was preload-ready. Now the preload bonus is only awarded when includeSubDomains is also set. When preload appears alone, a targeted finding is emitted instead. Adds a test asserting the corrected score (15) and the new finding. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01Szyfsw63nhZSYXqfq2269F --- src/rules.ts | 14 ++++++++++++-- test/analyzer.test.ts | 9 ++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/rules.ts b/src/rules.ts index 779cf09..2e31253 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -63,9 +63,19 @@ export function checkHSTS(headers: RawHeaders): HeaderFinding { // includeSubDomains / preload only add protection when HSTS is actually // enforced; awarding their bonuses under max-age=0 would mask a revocation. if (maxAge > 0) { - if (/includesubdomains/i.test(raw)) { score += 3; } + const hasIncludeSubDomains = /includesubdomains/i.test(raw); + if (hasIncludeSubDomains) { 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)) { + if (hasIncludeSubDomains) { + score += 2; + } else { + // preload without includeSubDomains is rejected by the HSTS preload list + // (hstspreload.org requires both). The directive is inert in this config. + findings.push('preload requires includeSubDomains — this config is not eligible for the HSTS preload list'); + recommendations.push('Add includeSubDomains alongside preload to qualify for the HSTS preload list'); + } + } } 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..ed0441a 100644 --- a/test/analyzer.test.ts +++ b/test/analyzer.test.ts @@ -96,12 +96,19 @@ describe('checkHSTS', () => { expect(r.score).toBe(15); }); - it('preload adds 2 bonus points', () => { + it('preload adds 2 bonus points when includeSubDomains is also set', () => { const withPreload = checkHSTS({ 'strict-transport-security': 'max-age=31536000; includeSubDomains; preload' }); const withoutPreload = checkHSTS({ 'strict-transport-security': 'max-age=31536000; includeSubDomains' }); expect(withPreload.score).toBe(withoutPreload.score + 2); }); + it('preload without includeSubDomains earns no bonus and flags a finding', () => { + const r = checkHSTS({ 'strict-transport-security': 'max-age=31536000; preload' }); + // 10 (base) + 5 (max-age ≥ 1yr) + 0 (no includeSubDomains) + 0 (preload inert) = 15 + expect(r.score).toBe(15); + expect(r.findings.some(f => /preload.*includeSubDomains/i.test(f))).toBe(true); + }); + it('case-insensitive header name matching', () => { const r = checkHSTS({ 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload' }); expect(r.score).toBe(20);