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
8 changes: 8 additions & 0 deletions src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,14 @@ export function checkCSP(headers: RawHeaders): HeaderFinding {
findings.push('No form-action directive — form submissions are unrestricted (form-action does not inherit from default-src)');
recommendations.push("Add form-action 'self' (or 'none') to restrict where forms can submit");
}
// base-uri does NOT inherit from default-src. Without it an attacker who can
// inject a <base> element can redirect relative URLs — including relative-URL
// nonce sources — to an attacker-controlled host (base-uri injection attack).
if (extractCspDirective(raw, 'base-uri') === undefined) {
score -= 2;
findings.push("No base-uri directive — <base> injection can redirect relative nonce sources (base-uri does not inherit from default-src)");
recommendations.push("Add base-uri 'self' or base-uri 'none' to prevent <base> injection");
}
score = Math.max(5, score); // at least 5 for having any CSP

return { header: 'Content-Security-Policy', score, maxScore: 30, status: findings.length === 0 ? 'good' : 'warning', raw, findings, recommendations };
Expand Down
46 changes: 33 additions & 13 deletions test/analyzer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,8 @@ describe('checkCSP', () => {
'content-security-policy': "default-src 'self'; form-action 'self'",
'content-security-policy-report-only': "default-src *",
});
expect(r.score).toBe(20);
expect(r.status).toBe('good');
expect(r.score).toBe(18);
expect(r.status).toBe('warning');
});

it('detects unsafe-inline', () => {
Expand All @@ -165,7 +165,7 @@ describe('checkCSP', () => {
});

it("does not penalize 'unsafe-inline' when 'strict-dynamic' + nonce present", () => {
const r = checkCSP({ 'content-security-policy': "script-src 'strict-dynamic' 'nonce-abc123' 'unsafe-inline' https://example.com; form-action 'self'" });
const r = checkCSP({ 'content-security-policy': "script-src 'strict-dynamic' 'nonce-abc123' 'unsafe-inline' https://example.com; form-action 'self'; base-uri 'none'" });
expect(r.findings.some(f => f.includes('unsafe-inline'))).toBe(false);
expect(r.score).toBe(20);
});
Expand All @@ -178,7 +178,7 @@ describe('checkCSP', () => {
it('detects wildcard in connect-src', () => {
const r = checkCSP({ 'content-security-policy': "default-src 'self'; form-action 'self'; connect-src *" });
expect(r.findings.some(f => /Wildcard.*connect-src/i.test(f))).toBe(true);
expect(r.score).toBe(15);
expect(r.score).toBe(13);
});

it('detects wildcard in form-action', () => {
Expand All @@ -189,44 +189,64 @@ describe('checkCSP', () => {
it("detects mid-policy wildcard (default-src 'self' *)", () => {
const r = checkCSP({ 'content-security-policy': "default-src 'self' *; form-action 'self'" });
expect(r.findings.some(f => /Wildcard/i.test(f))).toBe(true);
expect(r.score).toBe(15);
expect(r.score).toBe(13);
});

it('does not flag a wildcard in low-risk img-src', () => {
const r = checkCSP({ 'content-security-policy': "default-src 'self'; form-action 'self'; img-src *" });
expect(r.findings.some(f => /Wildcard/i.test(f))).toBe(false);
expect(r.score).toBe(20);
expect(r.score).toBe(18);
});

it('clean CSP returns score 20', () => {
const r = checkCSP({ 'content-security-policy': "default-src 'self'; form-action 'self'" });
const r = checkCSP({ 'content-security-policy': "default-src 'self'; form-action 'self'; base-uri 'self'" });
expect(r.score).toBe(20);
expect(r.status).toBe('good');
});

it('flags missing form-action directive', () => {
const r = checkCSP({ 'content-security-policy': "default-src 'self'" });
expect(r.findings.some(f => /form-action/i.test(f))).toBe(true);
expect(r.status).toBe('warning');
expect(r.score).toBe(17);
expect(r.score).toBe(15);
});

it("form-action 'none' satisfies the form-action check", () => {
const r = checkCSP({ 'content-security-policy': "default-src 'self'; form-action 'none'" });
const r = checkCSP({ 'content-security-policy': "default-src 'self'; form-action 'none'; base-uri 'none'" });
expect(r.findings.some(f => /form-action/i.test(f))).toBe(false);
expect(r.score).toBe(20);
});

it('CSP with both unsafe-inline and unsafe-eval scores 10', () => {
// 20 - 5 - 5 = 10, which is above the floor of 5
it('CSP with both unsafe-inline and unsafe-eval scores 8', () => {
// 20 - 5 (unsafe-inline) - 5 (unsafe-eval) - 2 (no base-uri) = 8, above the floor of 5
const r = checkCSP({ 'content-security-policy': "default-src 'unsafe-inline' 'unsafe-eval'; form-action 'self'" });
expect(r.score).toBe(10);
expect(r.score).toBe(8);
});

it('minimum score for any CSP is 5', () => {
// 20 - 5 (unsafe-inline) - 5 (unsafe-eval) - 5 (wildcard) = 5, floor is 5
const r = checkCSP({ 'content-security-policy': "default-src * 'unsafe-inline' 'unsafe-eval'" });
expect(r.score).toBe(5);
});

it('flags missing base-uri directive', () => {
const r = checkCSP({ 'content-security-policy': "default-src 'self'; form-action 'self'" });
expect(r.findings.some(f => /base-uri/i.test(f))).toBe(true);
expect(r.status).toBe('warning');
expect(r.score).toBe(18);
});

it("base-uri 'none' satisfies the base-uri check", () => {
const r = checkCSP({ 'content-security-policy': "default-src 'self'; form-action 'self'; base-uri 'none'" });
expect(r.findings.some(f => /base-uri/i.test(f))).toBe(false);
expect(r.score).toBe(20);
});

it("base-uri 'self' satisfies the base-uri check", () => {
const r = checkCSP({ 'content-security-policy': "default-src 'self'; form-action 'self'; base-uri 'self'" });
expect(r.findings.some(f => /base-uri/i.test(f))).toBe(false);
expect(r.score).toBe(20);
});
});

describe('checkXFrameOptions', () => {
Expand Down Expand Up @@ -479,7 +499,7 @@ describe('grade boundaries', () => {
it('A+ at 90%', () => {
const headers = {
'strict-transport-security': 'max-age=31536000; includeSubDomains; preload',
'content-security-policy': "default-src 'self'; form-action 'self'",
'content-security-policy': "default-src 'self'; form-action 'self'; base-uri 'self'",
'x-frame-options': 'DENY',
'x-content-type-options': 'nosniff',
'referrer-policy': 'strict-origin-when-cross-origin',
Expand Down
Loading