Skip to content

Enhance security header analysis with stricter CSP and cross-origin checks#50

Merged
dmchaledev merged 12 commits into
mainfrom
claude/ecstatic-edison-EoBwJ
May 29, 2026
Merged

Enhance security header analysis with stricter CSP and cross-origin checks#50
dmchaledev merged 12 commits into
mainfrom
claude/ecstatic-edison-EoBwJ

Conversation

@dmchaledev
Copy link
Copy Markdown
Contributor

Summary

This PR significantly improves the security header analyzer by implementing stricter Content-Security-Policy (CSP) validation, better cross-origin policy detection, and more nuanced scoring for edge cases. The changes make the analyzer more aligned with modern security best practices.

Key Changes

Content-Security-Policy (CSP) Enhancements

  • form-action directive requirement: CSP now requires an explicit form-action directive (or inheritable default-src) to earn full credit, as form-action does not inherit from default-src by default. Missing form-action reduces score by 3 points.
  • Strict CSP pattern support: 'unsafe-inline' is no longer penalized when used with 'strict-dynamic' and a nonce/hash present, as this is the recommended backwards-compatible Strict CSP pattern.
  • Improved wildcard detection: Wildcard (*) detection now covers sensitive directives (default-src, script-src, connect-src, form-action, frame-src, worker-src) while intentionally excluding low-risk directives (img-src, style-src, font-src, media-src).
  • Report-only CSP handling: CSP-Report-Only headers now receive partial credit (score 10) with a warning status, distinguishing them from completely missing CSP.
  • Enforcing precedence: When both CSP and CSP-Report-Only are present, the enforcing policy takes precedence.

X-Frame-Options / CSP frame-ancestors Improvements

  • Permissive frame-ancestors detection: CSP frame-ancestors directives with permissive values (wildcard * or bare schemes like https:) are now correctly identified as non-protective, returning a warning score of 8.
  • Specific origin support: frame-ancestors 'self' with specific trusted origins now correctly scores as protective (15 points).

HSTS Enhancements

  • max-age=0 revocation detection: HSTS with max-age=0 is now correctly identified as a revocation that disables HTTPS enforcement, earning only 10 points with a warning status.
  • Conditional bonus logic: includeSubDomains and preload bonuses are only awarded when max-age > 0, preventing false positives on revocation headers.

Referrer-Policy Refinement

  • no-referrer-when-downgrade downgrade: This policy is no longer considered "strong" as it leaks full URLs (path + query) to cross-origin HTTPS destinations. Score reduced from 10 to 5 with warning status.

Permissions-Policy Improvements

  • Stricter validation: Partial policies (e.g., only camera=()) now return warning status with score 5 instead of full credit.
  • Conditional recommendations: Good policies no longer emit unnecessary recommendations.

Cross-Origin Policies Refinement

  • Value-based scoring: Only restrictive values (require-corp/credentialless for COEP, same-origin/same-origin-allow-popups for COOP, same-origin/same-site for CORP) earn credit. Permissive defaults (unsafe-none, cross-origin) earn no points.
  • Mixed policy handling: Policies with a mix of protective and permissive values correctly score based only on protective headers.

Fetch Improvements

  • GET instead of HEAD: Changed from HEAD to GET requests to ensure security headers (particularly CSP) are captured, as many CDNs/edge workers only emit them on full responses. Response body is discarded without reading.

Testing & Documentation

  • Comprehensive test coverage added for all new validation logic
  • Updated README examples to include form-action directive
  • Test fixtures updated to reflect stricter validation requirements

Implementation Details

  • New helper function extractCspDirective() for robust CSP directive parsing
  • New helper function isPermissiveSource() to identify unrestricted source tokens
  • Improved header matching with case-insensitive lookups throughout
  • Better separation of concerns between missing, warning, and good states

https://claude.ai/code/session_01Sq1NTg6S8x66rNXsHfH2cR

claude added 12 commits May 29, 2026 18:13
The dev dependency @vitest/coverage-v8 was pinned to ^1.6.0 while vitest
was on ^4.1.7, producing an ERESOLVE peer-dependency conflict that broke
'npm ci' / 'npm install' without --legacy-peer-deps. Bump coverage-v8 to
^4.1.7 to match the installed vitest major.
Closes #15.

The strict Permissions-Policy scoring (requires camera/microphone/geolocation
to be restricted for full credit, from #4) left four assertions failing
against pre-strict fixtures:
- checkPermissionsPolicy feature-policy fallback / precedence / case-insensitive
  tests used partial policies that now score 5; updated to full strict policies
  (still exercising the same code paths) and added an explicit test that a
  partial 'camera=()' policy scores 5/warning.
- 'A+ at 90%' grade boundary used a partial policy; updated to the full policy.

Also de-flaked 'analyze returns same result as analyzeHeaders': it compared
the independently-computed analyzedAt timestamps via toEqual, which could
differ by a millisecond. Now compares all other fields and asserts both
timestamps are valid ISO strings.
Closes #23.

checkXFrameOptions previously awarded a full 15/15 'good' for the mere
presence of a frame-ancestors directive in the CSP, so 'frame-ancestors *'
or 'frame-ancestors https:' (which permit embedding by any origin and offer
zero clickjacking protection) scored identically to 'frame-ancestors none'.

Now the directive's source list is parsed: a wildcard (*) or bare-scheme
source is treated as permissive and yields status 'warning' (8/15) with a
finding, while 'none'/'self'/specific origins remain 'good' (15/15). Adds a
reusable extractCspDirective helper used here and by later CSP checks.
… good

Closes #22.

checkPermissionsPolicy populated the recommendations array unconditionally,
so a correctly-configured 'camera=(), microphone=(), geolocation=()' policy
returned status 'good' with empty findings but a spurious 'Set Permissions-
Policy to...' recommendation — telling developers to set the policy they
already had. Every other rule returns recommendations: [] on its good path;
this brings checkPermissionsPolicy in line.
Closes #21.

Per CSP3 and Google's Strict CSP guidance, 'unsafe-inline' is intentionally
included alongside 'strict-dynamic' + a nonce/hash as a backwards-compat
fallback; browsers that support 'strict-dynamic' ignore 'unsafe-inline'
entirely. checkCSP previously deducted 5 points and emitted a finding for
this recommended pattern. It now suppresses the penalty only when both
'strict-dynamic' and a nonce/hash source are present, and still penalizes a
bare 'unsafe-inline' (including 'strict-dynamic' without a nonce/hash).
Closes #20.

checkCSP only queried the enforcing Content-Security-Policy header, so a
report-only deployment (the standard incremental CSP rollout pattern) scored
0/30 'missing' with a 'CSP not present' finding. It now detects
Content-Security-Policy-Report-Only when no enforcing header exists and
returns partial credit (10/30, 'warning') with feedback to promote the policy
to enforcing. An enforcing CSP still takes precedence.
Closes #19.

form-action is one of the few CSP directives that does not fall back to
default-src, so a policy like 'default-src \'self\'' leaves form submissions
entirely unrestricted. checkCSP now deducts 3 points and emits a finding /
recommendation when no form-action directive is present. Updated the README
recommended policy and scoring table, and the test fixtures intended to
represent a fully-hardened CSP, to include form-action 'self'.
…cy values

Closes #18.

no-referrer-when-downgrade sends the full URL (path and query string) to
every cross-origin HTTPS destination — it was the historical browser default
specifically because it was the least restrictive option, and Chrome 85
replaced it with strict-origin-when-cross-origin for that reason. It no longer
counts as a 'strong' value, so it now scores 5/'warning' instead of 10/'good',
matching the README's documented 'strict values only' intent.
Closes #17.

checkHSTS awarded the includeSubDomains (+3) and preload (+2) bonuses
independently of max-age, so 'max-age=0; includeSubDomains; preload' scored
15/20 'good' — even though max-age=0 is the standard HSTS revocation pattern
that purges the host from the browser's HSTS cache and disables HTTPS
enforcement. The directive bonuses now only apply when max-age > 0, and
max-age=0 emits an explicit revocation finding, yielding 10/20 'warning'.
Closes #16.

Wildcard detection previously matched only a '*' appearing as the first token
of default-src or script-src, so 'connect-src *', 'form-action *', and
mid-policy wildcards like 'default-src \'self\' *' silently passed. It now
parses the source list of each sensitive directive (default-src, script-src,
connect-src, form-action, frame-src, worker-src) and flags a '*' source
anywhere within it. Low-risk directives (img-src, style-src, etc.) are
intentionally excluded.
Closes #8.

checkCrossOriginPolicies awarded points for the mere presence of COEP/COOP/
CORP headers, so a site explicitly opting out of isolation with
'Cross-Origin-Opener-Policy: unsafe-none', 'Cross-Origin-Embedder-Policy:
unsafe-none', and 'Cross-Origin-Resource-Policy: cross-origin' (two of which
are browser defaults) still scored 5/5 'good'. Points are now awarded only for
restrictive values (COEP require-corp/credentialless, COOP same-origin[-allow-
popups], CORP same-origin/same-site); permissive values are flagged with a
finding and earn no credit.
Closes #7.

Many real-world deployments emit security headers (especially
Content-Security-Policy, set by HTML-only middleware, content-type-conditional
logic, CDN page rules, or edge workers) only on GET responses, not HEAD. A
hard-coded HEAD request therefore systematically reported headers as 'missing'
on well-configured sites, producing false D/F grades — and, because the CLI
exits non-zero on D/F, breaking CI gates. fetchHeaders now issues GET (as
securityheaders.com and Mozilla Observatory do) and discards the response body
without reading it so no content is downloaded.
@dmchaledev dmchaledev merged commit 7121cef into main May 29, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants