You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fetchHeaders (src/fetch.ts:2) issues an HTTP HEAD request to retrieve response headers. In real-world deployments many sites do not return the same security headers on HEAD as they do on GET — most commonly because security headers (especially Content-Security-Policy) are set by middleware that only runs against HTML responses, by content-type-conditional logic, by CDN page rules, or by edge workers that short-circuit HEAD. The result is that this analyzer reports missing for CSP/HSTS/etc. on sites that actually have them, producing false-failure grades (F/D) and — because the CLI exits non-zero on D/F (src/cli.ts:49) — breaking CI gates against genuinely well-configured sites.
Every comparable analyzer in this category (securityheaders.com, Mozilla Observatory, Hardenize) issues GET, not HEAD, for exactly this reason.
Investigation Path
Read every source file: src/fetch.ts, src/analyzer.ts, src/rules.ts, src/cli.ts, src/index.ts, src/types.ts.
Confirmed HEAD is hard-coded with no fallback path: grep -rn "HEAD" src/ returns exactly one match (src/fetch.ts:2).
RFC 9110 §9.3.2 (HEAD is defined to return identical headers, but the spec also notes "the server SHOULD send the same header fields … as it would have sent if the request had been a GET" — i.e. SHOULD, not MUST, and well-known exceptions exist).
Cloudflare's documented behavior of stripping certain response headers on HEAD for cached static assets.
Express helmet middleware behavior in apps that set CSP only on text/html responses (a very common pattern, since CSP is meaningless on JSON/asset responses).
Traced the failure path: empty CSP/HSTS in the returned record → checkCSP/checkHSTS return status: 'missing' and score: 0 → cumulative score drops two grade tiers → CLI exits 1.
Current behavior: A request is issued with method: 'HEAD'. Whatever headers the server returns on HEAD are the only headers the analyzer sees. There is no GET fallback when key security headers are absent.
Expected behavior: Use GET (and discard the body, or stream-and-abort after headers). GET is the method every browser actually uses to navigate to the page, so it is the only method whose response headers reflect what real users receive.
Real-world reproducer: A common Express pattern that demonstrates the failure mode:
// app.js — emits CSP only on HTML responsesapp.use((req,res,next)=>{res.on('header',()=>{if(String(res.getHeader('content-type')||'').startsWith('text/html')){res.setHeader('Content-Security-Policy',"default-src 'self'");}});next();});
A HEAD request has no body and may never set Content-Type: text/html, so CSP is never attached. The analyzer reports CSP missing (-30 points) and very likely flips the grade from B → D or F.
External references:
RFC 9110 §9.3.2 — HEAD: headers should match GET, but spec uses SHOULD; servers in the wild diverge.
Every developer who runs this tool against a real production site is at risk of receiving a wrong grade. Two failure modes follow:
False low grade → broken CI gate. A site that genuinely has CSP, HSTS, etc. but only attaches them to HTML responses (the common pattern) gets graded D or F. The CLI exits 1 (src/cli.ts:49). Deployment pipelines that use this as a gate (the README's chore: add dependabot.yml for automated dependency updates #3 recommended use case) reject good deployments.
Silent miscommunication. A developer scans their own site, sees Content-Security-Policy: missing, and either (a) adds a second CSP via meta tag or duplicate middleware — creating policy-merge surprises — or (b) loses trust in the tool and stops using it.
Concretely, the README's quick-start example — npx @hailbytes/security-headers https://example.com — produces materially different output against the same site depending on whether that site's stack happens to set headers uniformly on HEAD and GET. The user has no way to know this without reading fetch.ts.
This is the primary correctness contract of an HTTP-header analyzer: "the headers we report are the headers a browser sees." HEAD breaks that contract.
Suggested Remediation
Switch the default request to GET and discard the body. Two implementation sketches:
Option A — minimal change (preferred):
// src/fetch.tsexportasyncfunctionfetchHeaders(url: string): Promise<Record<string,string>>{constres=awaitfetch(url,{method: 'GET',redirect: 'follow'});// We only need headers; release the body so the connection can be reused.// (Node's undici returns a streamed body; cancelling it is safe.)try{awaitres.body?.cancel();}catch{/* already closed */}constheaders: Record<string,string>={};res.headers.forEach((value,key)=>{headers[key.toLowerCase()]=value;});returnheaders;}
Option B — GET with HEAD opt-in for users who explicitly want it:
Combine with the in-flight FetchOptions work in PR #2:
Default to 'GET', document 'HEAD' as a performance opt-in with a warning about under-reporting.
Additional hardening to consider in the same PR (each one-liner):
Set a non-default User-Agent (e.g. @hailbytes/security-headers/1.0.x) — many WAFs and CDNs respond differently (or block) requests with no UA or node-fetch's default UA, which can also skew which headers come back.
Add an Accept: text/html request header so servers that vary their response middleware on Accept return their HTML-response headers.
Acceptance Criteria
src/fetch.ts issues a GET request by default (or makes GET the documented default of a new options object)
Response body is explicitly cancelled/discarded so memory usage does not scale with page size
An integration-style test (vitest with a Hono/node:http server, or a vi.spyOn(globalThis, 'fetch') mock) asserts the request method is GET
An integration-style test asserts that a server which returns CSP/HSTS only on GET (and an empty header set on HEAD) is now graded correctly
README's "Library — analyze a URL" section mentions that the tool issues GET (matches what a browser does)
If a method option is exposed, the README documents the trade-off
Claude Code review routine · commit 8d29a8c183b9d0065dddaf03fe18ea61351750dc · 2026-05-26T00:42:00Z
Summary
fetchHeaders(src/fetch.ts:2) issues an HTTPHEADrequest to retrieve response headers. In real-world deployments many sites do not return the same security headers onHEADas they do onGET— most commonly because security headers (especiallyContent-Security-Policy) are set by middleware that only runs against HTML responses, by content-type-conditional logic, by CDN page rules, or by edge workers that short-circuitHEAD. The result is that this analyzer reportsmissingforCSP/HSTS/etc. on sites that actually have them, producing false-failure grades (F/D) and — because the CLI exits non-zero onD/F(src/cli.ts:49) — breaking CI gates against genuinely well-configured sites.Every comparable analyzer in this category (securityheaders.com, Mozilla Observatory, Hardenize) issues
GET, notHEAD, for exactly this reason.Investigation Path
src/fetch.ts,src/analyzer.ts,src/rules.ts,src/cli.ts,src/index.ts,src/types.ts.HEADis hard-coded with no fallback path:grep -rn "HEAD" src/returns exactly one match (src/fetch.ts:2).timeoutMsoption but keepsmethod: 'HEAD'.HEADfor cached static assets.helmetmiddleware behavior in apps that set CSP only ontext/htmlresponses (a very common pattern, since CSP is meaningless on JSON/asset responses).checkCSP/checkHSTSreturnstatus: 'missing'andscore: 0→ cumulative score drops two grade tiers → CLI exits1.Evidence
src/fetch.tsline 2, commit8d29a8cmethod: 'HEAD'. Whatever headers the server returns onHEADare the only headers the analyzer sees. There is noGETfallback when key security headers are absent.GET(and discard the body, or stream-and-abort after headers).GETis the method every browser actually uses to navigate to the page, so it is the only method whose response headers reflect what real users receive.HEADrequest has no body and may never setContent-Type: text/html, so CSP is never attached. The analyzer reports CSP missing (-30points) and very likely flips the grade fromB→DorF.GET.GET.HEADand return a different header set thanGET.Impact
Every developer who runs this tool against a real production site is at risk of receiving a wrong grade. Two failure modes follow:
DorF. The CLI exits1(src/cli.ts:49). Deployment pipelines that use this as a gate (the README's chore: add dependabot.yml for automated dependency updates #3 recommended use case) reject good deployments.Content-Security-Policy: missing, and either (a) adds a second CSP via meta tag or duplicate middleware — creating policy-merge surprises — or (b) loses trust in the tool and stops using it.Concretely, the README's quick-start example —
npx @hailbytes/security-headers https://example.com— produces materially different output against the same site depending on whether that site's stack happens to set headers uniformly onHEADandGET. The user has no way to know this without readingfetch.ts.This is the primary correctness contract of an HTTP-header analyzer: "the headers we report are the headers a browser sees."
HEADbreaks that contract.Suggested Remediation
Switch the default request to
GETand discard the body. Two implementation sketches:Option A — minimal change (preferred):
Option B —
GETwithHEADopt-in for users who explicitly want it:Combine with the in-flight
FetchOptionswork in PR #2:Default to
'GET', document'HEAD'as a performance opt-in with a warning about under-reporting.Additional hardening to consider in the same PR (each one-liner):
User-Agent(e.g.@hailbytes/security-headers/1.0.x) — many WAFs and CDNs respond differently (or block) requests with no UA ornode-fetch's default UA, which can also skew which headers come back.Accept: text/htmlrequest header so servers that vary their response middleware onAcceptreturn their HTML-response headers.Acceptance Criteria
src/fetch.tsissues aGETrequest by default (or makesGETthe documented default of a new options object)Hono/node:httpserver, or avi.spyOn(globalThis, 'fetch')mock) asserts the request method isGETGET(and an empty header set onHEAD) is now graded correctlyGET(matches what a browser does)methodoption is exposed, the README documents the trade-offClaude Code review routine · commit
8d29a8c183b9d0065dddaf03fe18ea61351750dc· 2026-05-26T00:42:00Z