diff --git a/README.md b/README.md index d85cb02..c0bd84a 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ interface HeaderFinding { | Header | Max Points | Key Checks | |---|---|---| | Strict-Transport-Security | 20 | max-age ≥ 1 year, includeSubDomains, preload | -| Content-Security-Policy | 30 | presence, no unsafe-inline/eval, no wildcards, form-action set | +| Content-Security-Policy | 30 | presence, no unsafe-inline/eval, no wildcards, form-action/base-uri/object-src fallback set | | X-Frame-Options | 15 | DENY or SAMEORIGIN (or CSP frame-ancestors) | | X-Content-Type-Options | 10 | nosniff | | Referrer-Policy | 10 | strict values only | diff --git a/src/rules.ts b/src/rules.ts index 779cf09..009c433 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -138,6 +138,16 @@ export function checkCSP(headers: RawHeaders): HeaderFinding { findings.push("No base-uri directive — 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 injection"); } + // object-src DOES inherit from default-src, but only when default-src is + // actually set. If neither is present, / plugin content is + // completely unrestricted — a legacy but still-audited XSS vector — and every + // other fetch directive not explicitly listed (img-src, media-src, connect-src, + // etc.) also silently defaults to allow-all with no default-src to fall back to. + if (extractCspDirective(raw, 'default-src') === undefined && extractCspDirective(raw, 'object-src') === undefined) { + score -= 2; + findings.push('No default-src or object-src directive — plugin content (/) is unrestricted, and any other fetch directive not explicitly listed defaults to allow-all'); + recommendations.push("Add object-src 'none' (or set default-src as a fallback that covers it)"); + } 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 }; diff --git a/test/analyzer.test.ts b/test/analyzer.test.ts index ed22923..af16d05 100644 --- a/test/analyzer.test.ts +++ b/test/analyzer.test.ts @@ -165,9 +165,10 @@ describe('checkCSP', () => { }); it("does not penalize 'unsafe-inline' when 'strict-dynamic' + nonce present", () => { + // 20 - 2 (no default-src or object-src) = 18 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); + expect(r.score).toBe(18); }); it("still penalizes 'unsafe-inline' when 'strict-dynamic' present without nonce/hash", () => { @@ -263,6 +264,24 @@ describe('checkCSP', () => { expect(r.findings.some(f => /base-uri/i.test(f))).toBe(false); expect(r.score).toBe(20); }); + + it('flags a policy with neither default-src nor object-src', () => { + const r = checkCSP({ 'content-security-policy': "script-src 'self'; form-action 'self'; base-uri 'self'" }); + expect(r.findings.some(f => /object-src/i.test(f))).toBe(true); + expect(r.status).toBe('warning'); + expect(r.score).toBe(18); + }); + + it('default-src alone satisfies the object-src fallback check', () => { + const r = checkCSP({ 'content-security-policy': "default-src 'self'; form-action 'self'; base-uri 'self'" }); + expect(r.findings.some(f => /object-src/i.test(f))).toBe(false); + }); + + it("object-src 'none' satisfies the check even without default-src", () => { + const r = checkCSP({ 'content-security-policy': "script-src 'self'; object-src 'none'; form-action 'self'; base-uri 'self'" }); + expect(r.findings.some(f => /object-src/i.test(f))).toBe(false); + expect(r.score).toBe(20); + }); }); describe('checkXFrameOptions', () => {