Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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[]
```
Expand Down Expand Up @@ -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) |

---

Expand Down
3 changes: 2 additions & 1 deletion src/analyzer.ts
Original file line number Diff line number Diff line change
@@ -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+';
Expand All @@ -19,6 +19,7 @@ export function analyzeHeaders(headers: Record<string, string>, 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);
Expand Down
10 changes: 9 additions & 1 deletion src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,15 @@ export async function fetchHeadersWithMeta(url: string, options?: FetchOptions):
continue;
}
const headers: Record<string, string> = {};
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() };
}
Expand Down
72 changes: 72 additions & 0 deletions src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
62 changes: 57 additions & 5 deletions test/analyzer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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', () => {
Expand All @@ -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);
});
});

Expand Down Expand Up @@ -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 = {
Expand Down
16 changes: 15 additions & 1 deletion test/fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,19 @@ vi.mock('node:dns/promises', () => ({
import { lookup } from 'node:dns/promises';
import { fetchHeaders, fetchHeadersWithMeta } from '../src/fetch.js';

function fakeResponse(status: number, headers: Record<string, string>) {
function fakeResponse(status: number, headers: Record<string, string>, setCookies: string[] = []) {
return {
status,
headers: {
has: (k: string) => k.toLowerCase() in headers,
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) },
};
Expand Down Expand Up @@ -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);

Expand Down
Loading