From 311573dd5028c4c6235d3a959aee8699e1caf6ee Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 05:10:47 +0000 Subject: [PATCH] feat(set-cookie): analyze Set-Cookie attributes (Secure/HttpOnly/SameSite) A site could score A+ while shipping a session cookie with none of Secure, HttpOnly, or SameSite set. Add a Set-Cookie check (10 points, N/A when no cookies are set) and fix fetchHeadersWithMeta, which was silently collapsing multiple Set-Cookie response headers down to just the last one. Closes #95 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NX141JRVNPPMDaeDdYvf8V --- README.md | 5 +-- src/analyzer.ts | 3 +- src/fetch.ts | 10 +++++- src/rules.ts | 72 +++++++++++++++++++++++++++++++++++++++++++ test/analyzer.test.ts | 62 ++++++++++++++++++++++++++++++++++--- test/fetch.test.ts | 16 +++++++++- 6 files changed, 158 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 8f761de..db35296 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ ## What it does -Fetches (or accepts raw header objects) and grades 7 security header categories — HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, and Cross-Origin policies. Returns an A+ to F letter grade, a 0–100 percentage score, per-header findings, and specific remediation steps. +Fetches (or accepts raw header objects) and grades 8 security header categories — HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, Cross-Origin policies, and Set-Cookie attributes. Returns an A+ to F letter grade, a 0–100 percentage score, per-header findings, and specific remediation steps. --- @@ -57,7 +57,7 @@ import { analyze } from '@hailbytes/security-headers'; const report = await analyze('https://example.com'); console.log(report.grade); // 'A+' | 'A' | 'B' | 'C' | 'D' | 'F' -console.log(report.score); // 0–100 +console.log(report.score); // 0–report.maxScore (raw points) console.log(report.percentage); // 0–100 console.log(report.headers); // HeaderFinding[] ``` @@ -136,6 +136,7 @@ interface HeaderFinding { | Referrer-Policy | 10 | strict values only | | Permissions-Policy | 10 | camera, microphone, and geolocation restricted | | Cross-Origin Policies | 5 | COEP, COOP, CORP | +| Set-Cookie | 10 | Secure, HttpOnly, SameSite=Strict/Lax on every cookie (N/A if no cookies are set) | --- diff --git a/src/analyzer.ts b/src/analyzer.ts index 606bac5..92de645 100644 --- a/src/analyzer.ts +++ b/src/analyzer.ts @@ -1,5 +1,5 @@ import type { SecurityHeaderReport, Grade } from './types.js'; -import { checkHSTS, checkCSP, checkXFrameOptions, checkXContentTypeOptions, checkReferrerPolicy, checkPermissionsPolicy, checkCrossOriginPolicies } from './rules.js'; +import { checkHSTS, checkCSP, checkXFrameOptions, checkXContentTypeOptions, checkReferrerPolicy, checkPermissionsPolicy, checkCrossOriginPolicies, checkSetCookie } from './rules.js'; function toGrade(pct: number): Grade { if (pct >= 90) return 'A+'; @@ -19,6 +19,7 @@ export function analyzeHeaders(headers: Record, url?: string): S checkReferrerPolicy(headers), checkPermissionsPolicy(headers), checkCrossOriginPolicies(headers), + checkSetCookie(headers), ]; const score = checks.reduce((s, c) => s + c.score, 0); const maxScore = checks.reduce((s, c) => s + c.maxScore, 0); diff --git a/src/fetch.ts b/src/fetch.ts index c6b6fec..98b3513 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -101,7 +101,15 @@ export async function fetchHeadersWithMeta(url: string, options?: FetchOptions): continue; } const headers: Record = {}; - res.headers.forEach((value, key) => { headers[key.toLowerCase()] = value; }); + res.headers.forEach((value, key) => { + // Set-Cookie is excluded here and handled below: a response setting multiple + // cookies emits one Set-Cookie entry per cookie, and naively assigning each + // into this flat Record would silently drop all but the last one. + if (key.toLowerCase() === 'set-cookie') return; + headers[key.toLowerCase()] = value; + }); + const setCookies = res.headers.getSetCookie?.() ?? []; + if (setCookies.length > 0) headers['set-cookie'] = setCookies.join('\n'); try { await res.body?.cancel(); } catch { /* body may be absent or already closed */ } return { headers, finalUrl: current.toString() }; } diff --git a/src/rules.ts b/src/rules.ts index c6a3084..a00aa00 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -294,6 +294,78 @@ export function checkPermissionsPolicy(headers: RawHeaders): HeaderFinding { }; } +/** + * Set-Cookie values are joined with '\n' by fetchHeadersWithMeta (see fetch.ts) + * since commas can legitimately appear inside a single cookie, e.g. the + * Expires attribute ("Expires=Wed, 21 Oct 2015 07:28:00 GMT"). + */ +function splitSetCookieHeader(raw: string): string[] { + return raw.split('\n').map(c => c.trim()).filter(Boolean); +} + +function parseCookieAttributes(cookie: string): { name: string; secure: boolean; httpOnly: boolean; sameSite?: string } { + const parts = cookie.split(';').map(p => p.trim()); + const name = (parts.shift() ?? '').split('=')[0].trim() || '(unnamed)'; + let secure = false; + let httpOnly = false; + let sameSite: string | undefined; + for (const part of parts) { + const eq = part.indexOf('='); + const key = (eq === -1 ? part : part.slice(0, eq)).trim().toLowerCase(); + const val = eq === -1 ? '' : part.slice(eq + 1).trim().toLowerCase(); + if (key === 'secure') secure = true; + else if (key === 'httponly') httpOnly = true; + else if (key === 'samesite') sameSite = val; + } + return { name, secure, httpOnly, sameSite }; +} + +export function checkSetCookie(headers: RawHeaders): HeaderFinding { + const raw = getHeader(headers, 'set-cookie'); + // Not every response sets cookies — absence is not a security defect, so it + // earns full credit rather than being penalized like a missing security header. + if (!raw) { + return { header: 'Set-Cookie', score: 10, maxScore: 10, status: 'good', findings: [], recommendations: [] }; + } + + const cookies = splitSetCookieHeader(raw); + const findings: string[] = []; + const recommendations: string[] = []; + let worstDeduction = 0; + + for (const cookie of cookies) { + const { name, secure, httpOnly, sameSite } = parseCookieAttributes(cookie); + let deduction = 0; + if (!secure) { + deduction += 4; + findings.push(`Cookie '${name}' missing Secure — can be transmitted over plain HTTP`); + recommendations.push(`Add Secure to cookie '${name}'`); + } + if (!httpOnly) { + deduction += 3; + findings.push(`Cookie '${name}' missing HttpOnly — readable by JavaScript (XSS risk)`); + recommendations.push(`Add HttpOnly to cookie '${name}'`); + } + if (sameSite === 'none' && !secure) { + // Per spec, SameSite=None requires Secure — browsers reject the cookie outright. + deduction += 3; + findings.push(`Cookie '${name}' sets SameSite=None without Secure — invalid combination, browsers will reject this cookie`); + recommendations.push(`Add Secure, or use SameSite=Lax/Strict, for cookie '${name}'`); + } else if (sameSite !== 'strict' && sameSite !== 'lax') { + deduction += 3; + findings.push(`Cookie '${name}' missing a restrictive SameSite attribute — vulnerable to CSRF`); + recommendations.push(`Add SameSite=Strict or SameSite=Lax to cookie '${name}'`); + } + worstDeduction = Math.max(worstDeduction, deduction); + } + + const score = Math.max(0, 10 - worstDeduction); + return { + header: 'Set-Cookie', score, maxScore: 10, status: score === 10 ? 'good' : 'warning', raw, + findings, recommendations, + }; +} + export function checkCrossOriginPolicies(headers: RawHeaders): HeaderFinding { const coep = getHeader(headers, 'cross-origin-embedder-policy'); const coop = getHeader(headers, 'cross-origin-opener-policy'); diff --git a/test/analyzer.test.ts b/test/analyzer.test.ts index af55cb6..2d78740 100644 --- a/test/analyzer.test.ts +++ b/test/analyzer.test.ts @@ -3,7 +3,7 @@ import { analyzeHeaders } from '../src/analyzer.js'; import { analyze } from '../src/index.js'; import { checkHSTS, checkCSP, checkXFrameOptions, checkXContentTypeOptions, - checkReferrerPolicy, checkPermissionsPolicy, checkCrossOriginPolicies + checkReferrerPolicy, checkPermissionsPolicy, checkCrossOriginPolicies, checkSetCookie } from '../src/rules.js'; const STRONG_HEADERS = { @@ -27,9 +27,11 @@ describe('analyzeHeaders', () => { it('gives grade F for empty headers', () => { const r = analyzeHeaders({}); - expect(r.score).toBe(0); + // Set-Cookie is the one category that earns full credit when absent (no + // cookies to secure), so it's the sole 'good' entry among all-missing headers. expect(r.grade).toBe('F'); - expect(r.headers.every(h => h.status === 'missing')).toBe(true); + expect(r.headers.filter(h => h.header !== 'Set-Cookie').every(h => h.status === 'missing')).toBe(true); + expect(r.headers.find(h => h.header === 'Set-Cookie')?.status).toBe('good'); }); it('header scores sum to total score', () => { @@ -48,9 +50,9 @@ describe('analyzeHeaders', () => { expect(r.analyzedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); }); - it('maxScore is 100', () => { + it('maxScore is 110', () => { const r = analyzeHeaders({}); - expect(r.maxScore).toBe(100); + expect(r.maxScore).toBe(110); }); }); @@ -628,6 +630,56 @@ describe('checkCrossOriginPolicies', () => { }); }); +describe('checkSetCookie', () => { + it('no Set-Cookie header earns full credit, not a missing penalty', () => { + const r = checkSetCookie({}); + expect(r.score).toBe(10); + expect(r.status).toBe('good'); + expect(r.findings).toEqual([]); + }); + + it('cookie with Secure, HttpOnly, and SameSite=Strict is good', () => { + const r = checkSetCookie({ 'set-cookie': 'sessionid=abc123; Path=/; Secure; HttpOnly; SameSite=Strict' }); + expect(r.score).toBe(10); + expect(r.status).toBe('good'); + expect(r.findings).toEqual([]); + }); + + it('cookie missing all three attributes is flagged with a finding per attribute', () => { + const r = checkSetCookie({ 'set-cookie': 'sessionid=abc123; Path=/' }); + expect(r.status).toBe('warning'); + expect(r.score).toBe(0); + expect(r.findings).toHaveLength(3); + expect(r.findings.some(f => f.includes('Secure'))).toBe(true); + expect(r.findings.some(f => f.includes('HttpOnly'))).toBe(true); + expect(r.findings.some(f => f.includes('SameSite'))).toBe(true); + }); + + it('SameSite=None without Secure is flagged as an invalid/broken combination', () => { + const r = checkSetCookie({ 'set-cookie': 'sessionid=abc123; Path=/; HttpOnly; SameSite=None' }); + expect(r.status).toBe('warning'); + expect(r.findings.some(f => f.includes('SameSite=None without Secure'))).toBe(true); + }); + + it('SameSite=None with Secure is valid but still loses CSRF-protection credit', () => { + const r = checkSetCookie({ 'set-cookie': 'sessionid=abc123; Path=/; Secure; HttpOnly; SameSite=None' }); + expect(r.status).toBe('warning'); + expect(r.findings.some(f => f.includes('SameSite=None without Secure'))).toBe(false); + expect(r.findings.some(f => f.includes('CSRF'))).toBe(true); + }); + + it('evaluates multiple cookies (newline-joined by fetchHeadersWithMeta) and rolls up the worst case', () => { + const raw = [ + 'good=1; Path=/; Secure; HttpOnly; SameSite=Strict', + 'bad=2; Path=/', + ].join('\n'); + const r = checkSetCookie({ 'set-cookie': raw }); + expect(r.score).toBe(0); + expect(r.findings.some(f => f.includes("'bad'"))).toBe(true); + expect(r.findings.some(f => f.includes("'good'"))).toBe(false); + }); +}); + describe('grade boundaries', () => { it('A+ at 90%', () => { const headers = { diff --git a/test/fetch.test.ts b/test/fetch.test.ts index 2f0e6d9..fe4b525 100644 --- a/test/fetch.test.ts +++ b/test/fetch.test.ts @@ -7,7 +7,7 @@ vi.mock('node:dns/promises', () => ({ import { lookup } from 'node:dns/promises'; import { fetchHeaders, fetchHeadersWithMeta } from '../src/fetch.js'; -function fakeResponse(status: number, headers: Record) { +function fakeResponse(status: number, headers: Record, setCookies: string[] = []) { return { status, headers: { @@ -15,7 +15,11 @@ function fakeResponse(status: number, headers: Record) { get: (k: string) => headers[k.toLowerCase()] ?? null, forEach: (cb: (value: string, key: string) => void) => { for (const [k, v] of Object.entries(headers)) cb(v, k); + // Mirrors the real Fetch API/undici: Headers#forEach yields one 'set-cookie' + // entry per cookie rather than combining them into a single value. + for (const cookie of setCookies) cb(cookie, 'set-cookie'); }, + getSetCookie: () => setCookies, }, body: { cancel: vi.fn().mockResolvedValue(undefined) }, }; @@ -117,6 +121,16 @@ describe('fetchHeaders', () => { await expect(fetchHeaders('https://example.com/start')).rejects.toThrow(/too many redirects/i); }); + it('preserves multiple Set-Cookie headers instead of collapsing to the last one', async () => { + vi.mocked(lookup).mockResolvedValue([{ address: '93.184.216.34', family: 4 }] as never); + vi.mocked(fetch).mockResolvedValue( + fakeResponse(200, {}, ['a=1; Path=/', 'b=2; Path=/']) as never + ); + + const headers = await fetchHeaders('https://example.com'); + expect(headers['set-cookie']).toBe('a=1; Path=/\nb=2; Path=/'); + }); + it('allows private networks when allowPrivateNetworks is set', async () => { vi.mocked(fetch).mockResolvedValue(fakeResponse(200, { 'x-frame-options': 'DENY' }) as never);