diff --git a/src/rules.ts b/src/rules.ts
index 45c315e..db5d48c 100644
--- a/src/rules.ts
+++ b/src/rules.ts
@@ -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 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 — 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");
+ }
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 ddf88f8..da35ca9 100644
--- a/test/analyzer.test.ts
+++ b/test/analyzer.test.ts
@@ -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', () => {
@@ -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);
});
@@ -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', () => {
@@ -189,37 +189,38 @@ 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', () => {
@@ -227,6 +228,25 @@ describe('checkCSP', () => {
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', () => {
@@ -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',