diff --git a/browser-tests/.gitignore b/browser-tests/.gitignore new file mode 100644 index 0000000..dbd64df --- /dev/null +++ b/browser-tests/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +test-results/ +playwright-report/ diff --git a/browser-tests/package.json b/browser-tests/package.json new file mode 100644 index 0000000..bfc33ba --- /dev/null +++ b/browser-tests/package.json @@ -0,0 +1,16 @@ +{ + "name": "browser-tests", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "devDependencies": { + "@playwright/test": "^1.57.0" + } +} diff --git a/browser-tests/playwright.config.js b/browser-tests/playwright.config.js new file mode 100644 index 0000000..615614d --- /dev/null +++ b/browser-tests/playwright.config.js @@ -0,0 +1,29 @@ +// @ts-check +const { defineConfig, devices } = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: '.', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: 0, + workers: 1, + reporter: [['list'], ['html', { open: 'never' }]], + use: { + trace: 'on-first-retry', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], +}); diff --git a/browser-tests/trusted-types.spec.js b/browser-tests/trusted-types.spec.js new file mode 100644 index 0000000..32e59fc --- /dev/null +++ b/browser-tests/trusted-types.spec.js @@ -0,0 +1,246 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); + +// Test server that serves pages with specific CSP headers +const http = require('http'); + +let server; +let serverPort; + +test.beforeAll(async () => { + server = http.createServer((req, res) => { + const url = new URL(req.url, `http://localhost`); + const csp = url.searchParams.get('csp') || ''; + + res.setHeader('Content-Type', 'text/html'); + if (csp) { + res.setHeader('Content-Security-Policy', csp); + } + + res.end(` + +Trusted Types Test + +
+ + +`); + }); + + await new Promise((resolve) => { + server.listen(0, () => { + serverPort = server.address().port; + resolve(); + }); + }); +}); + +test.afterAll(async () => { + if (server) { + server.close(); + } +}); + +test.describe('Trusted Types keyword case sensitivity', () => { + + test('lowercase allow-duplicates keyword', async ({ page }) => { + const csp = "trusted-types myPolicy 'allow-duplicates'"; + await page.goto(`http://localhost:${serverPort}?csp=${encodeURIComponent(csp)}`); + + // Create the same policy twice - should succeed with allow-duplicates + const result1 = await page.evaluate(() => window.tryCreatePolicy('myPolicy')); + const result2 = await page.evaluate(() => window.tryCreatePolicy('myPolicy')); + + console.log('lowercase allow-duplicates:', { result1, result2 }); + + expect(result1.success).toBe(true); + expect(result2.success).toBe(true); // Should succeed due to allow-duplicates + }); + + test('UPPERCASE ALLOW-DUPLICATES keyword', async ({ page }) => { + const csp = "trusted-types myPolicy 'ALLOW-DUPLICATES'"; + await page.goto(`http://localhost:${serverPort}?csp=${encodeURIComponent(csp)}`); + + // Create the same policy twice + const result1 = await page.evaluate(() => window.tryCreatePolicy('myPolicy')); + const result2 = await page.evaluate(() => window.tryCreatePolicy('myPolicy')); + + console.log('UPPERCASE ALLOW-DUPLICATES:', { result1, result2 }); + + // Record whether uppercase worked + test.info().annotations.push({ + type: 'browser-behavior', + description: `UPPERCASE 'ALLOW-DUPLICATES': first=${result1.success}, second=${result2.success}` + }); + }); + + test('MixedCase Allow-Duplicates keyword', async ({ page }) => { + const csp = "trusted-types myPolicy 'Allow-Duplicates'"; + await page.goto(`http://localhost:${serverPort}?csp=${encodeURIComponent(csp)}`); + + const result1 = await page.evaluate(() => window.tryCreatePolicy('myPolicy')); + const result2 = await page.evaluate(() => window.tryCreatePolicy('myPolicy')); + + console.log('MixedCase Allow-Duplicates:', { result1, result2 }); + + test.info().annotations.push({ + type: 'browser-behavior', + description: `MixedCase 'Allow-Duplicates': first=${result1.success}, second=${result2.success}` + }); + }); + + test('lowercase none keyword', async ({ page }) => { + const csp = "trusted-types 'none'"; + await page.goto(`http://localhost:${serverPort}?csp=${encodeURIComponent(csp)}`); + + const result = await page.evaluate(() => window.tryCreatePolicy('anyPolicy')); + + console.log("lowercase 'none':", result); + + // 'none' should block all policy creation + expect(result.success).toBe(false); + }); + + test('UPPERCASE NONE keyword', async ({ page }) => { + const csp = "trusted-types 'NONE'"; + await page.goto(`http://localhost:${serverPort}?csp=${encodeURIComponent(csp)}`); + + const result = await page.evaluate(() => window.tryCreatePolicy('anyPolicy')); + + console.log("UPPERCASE 'NONE':", result); + + test.info().annotations.push({ + type: 'browser-behavior', + description: `UPPERCASE 'NONE' blocks policy creation: ${!result.success}` + }); + }); +}); + +test.describe('Trusted Types policy name case sensitivity', () => { + + test('exact case policy name match', async ({ page }) => { + const csp = "trusted-types myPolicy"; + await page.goto(`http://localhost:${serverPort}?csp=${encodeURIComponent(csp)}`); + + const result = await page.evaluate(() => window.tryCreatePolicy('myPolicy')); + + console.log('exact case myPolicy:', result); + + expect(result.success).toBe(true); + }); + + test('different case policy name - lowercase csp, uppercase create', async ({ page }) => { + const csp = "trusted-types mypolicy"; // lowercase in CSP + await page.goto(`http://localhost:${serverPort}?csp=${encodeURIComponent(csp)}`); + + const result = await page.evaluate(() => window.tryCreatePolicy('MYPOLICY')); // UPPERCASE in JS + + console.log('lowercase CSP, UPPERCASE create:', result); + + test.info().annotations.push({ + type: 'browser-behavior', + description: `CSP 'mypolicy' allows creating 'MYPOLICY': ${result.success}` + }); + }); + + test('different case policy name - uppercase csp, lowercase create', async ({ page }) => { + const csp = "trusted-types MYPOLICY"; // UPPERCASE in CSP + await page.goto(`http://localhost:${serverPort}?csp=${encodeURIComponent(csp)}`); + + const result = await page.evaluate(() => window.tryCreatePolicy('mypolicy')); // lowercase in JS + + console.log('UPPERCASE CSP, lowercase create:', result); + + test.info().annotations.push({ + type: 'browser-behavior', + description: `CSP 'MYPOLICY' allows creating 'mypolicy': ${result.success}` + }); + }); + + test('mixed case variations', async ({ page }) => { + const csp = "trusted-types MyPolicy"; + await page.goto(`http://localhost:${serverPort}?csp=${encodeURIComponent(csp)}`); + + const exactMatch = await page.evaluate(() => window.tryCreatePolicy('MyPolicy')); + + // Reset page for each test to get fresh trustedTypes state + await page.goto(`http://localhost:${serverPort}?csp=${encodeURIComponent(csp)}`); + const lowerCase = await page.evaluate(() => window.tryCreatePolicy('mypolicy')); + + await page.goto(`http://localhost:${serverPort}?csp=${encodeURIComponent(csp)}`); + const upperCase = await page.evaluate(() => window.tryCreatePolicy('MYPOLICY')); + + console.log('mixed case variations:', { exactMatch, lowerCase, upperCase }); + + test.info().annotations.push({ + type: 'browser-behavior', + description: `CSP 'MyPolicy': exact=${exactMatch.success}, lower=${lowerCase.success}, upper=${upperCase.success}` + }); + }); +}); + +test.describe('Comprehensive case sensitivity summary', () => { + test('full case sensitivity test', async ({ page }) => { + const results = {}; + + // Test 1: Does 'ALLOW-DUPLICATES' work like 'allow-duplicates'? + let csp = "trusted-types testPolicy 'ALLOW-DUPLICATES'"; + await page.goto(`http://localhost:${serverPort}?csp=${encodeURIComponent(csp)}`); + const dup1 = await page.evaluate(() => window.tryCreatePolicy('testPolicy')); + const dup2 = await page.evaluate(() => window.tryCreatePolicy('testPolicy')); + results.uppercaseAllowDuplicatesWorks = dup1.success && dup2.success; + + // Test 2: Does 'NONE' work like 'none'? + csp = "trusted-types 'NONE'"; + await page.goto(`http://localhost:${serverPort}?csp=${encodeURIComponent(csp)}`); + const noneResult = await page.evaluate(() => window.tryCreatePolicy('anyPolicy')); + results.uppercaseNoneBlocks = !noneResult.success; + + // Test 3: Are policy names case-sensitive? + csp = "trusted-types CaseSensitivePolicy"; + await page.goto(`http://localhost:${serverPort}?csp=${encodeURIComponent(csp)}`); + const exactCase = await page.evaluate(() => window.tryCreatePolicy('CaseSensitivePolicy')); + results.exactCaseWorks = exactCase.success; + + await page.goto(`http://localhost:${serverPort}?csp=${encodeURIComponent(csp)}`); + const wrongCase = await page.evaluate(() => window.tryCreatePolicy('casesensitivepolicy')); + results.wrongCaseWorks = wrongCase.success; + + results.policyNamesAreCaseSensitive = results.exactCaseWorks && !results.wrongCaseWorks; + results.policyNamesAreCaseInsensitive = results.exactCaseWorks && results.wrongCaseWorks; + + console.log('\n========================================'); + console.log('BROWSER BEHAVIOR SUMMARY:'); + console.log('========================================'); + console.log(`Keywords case-insensitive (ALLOW-DUPLICATES works): ${results.uppercaseAllowDuplicatesWorks}`); + console.log(`Keywords case-insensitive (NONE works): ${results.uppercaseNoneBlocks}`); + console.log(`Policy names are CASE-SENSITIVE: ${results.policyNamesAreCaseSensitive}`); + console.log(`Policy names are CASE-INSENSITIVE: ${results.policyNamesAreCaseInsensitive}`); + console.log('========================================\n'); + + // Store results for the test report + test.info().annotations.push({ + type: 'summary', + description: JSON.stringify(results, null, 2) + }); + }); +}); diff --git a/src/main/java/com/shapesecurity/salvation2/Directives/RequireTrustedTypesForDirective.java b/src/main/java/com/shapesecurity/salvation2/Directives/RequireTrustedTypesForDirective.java new file mode 100644 index 0000000..03de760 --- /dev/null +++ b/src/main/java/com/shapesecurity/salvation2/Directives/RequireTrustedTypesForDirective.java @@ -0,0 +1,63 @@ +package com.shapesecurity.salvation2.Directives; + +import com.shapesecurity.salvation2.Directive; +import com.shapesecurity.salvation2.Policy; + +import java.util.List; +import java.util.Locale; + +public class RequireTrustedTypesForDirective extends Directive { + // https://w3c.github.io/trusted-types/dist/spec/#require-trusted-types-for-csp-directive + // Currently only 'script' is defined + private static final String SCRIPT = "'script'"; + + private boolean script = false; + + public RequireTrustedTypesForDirective(List values, DirectiveErrorConsumer errors) { + super(values); + + if (values.isEmpty()) { + errors.add(Policy.Severity.Error, "The require-trusted-types-for directive requires a value", -1); + return; + } + + int index = 0; + for (String token : values) { + // ABNF strings are case-insensitive + String lowcaseToken = token.toLowerCase(Locale.ENGLISH); + switch (lowcaseToken) { + case "'script'": + if (!this.script) { + this.script = true; + } else { + errors.add(Policy.Severity.Warning, "Duplicate keyword 'script'", index); + } + break; + default: + if (token.startsWith("'") && token.endsWith("'")) { + errors.add(Policy.Severity.Error, "Unrecognized require-trusted-types-for keyword " + token, index); + } else { + errors.add(Policy.Severity.Error, "Unrecognized require-trusted-types-for value " + token + " - keywords must be wrapped in single quotes", index); + } + } + ++index; + } + } + + + public boolean script() { + return this.script; + } + + public void setScript(boolean script) { + if (this.script == script) { + return; + } + if (script) { + this.addValue(SCRIPT); + } else { + this.removeValueIgnoreCase(SCRIPT); + } + this.script = script; + } +} diff --git a/src/main/java/com/shapesecurity/salvation2/Directives/TrustedTypesDirective.java b/src/main/java/com/shapesecurity/salvation2/Directives/TrustedTypesDirective.java new file mode 100644 index 0000000..f2066cb --- /dev/null +++ b/src/main/java/com/shapesecurity/salvation2/Directives/TrustedTypesDirective.java @@ -0,0 +1,155 @@ +package com.shapesecurity.salvation2.Directives; + +import com.shapesecurity.salvation2.Directive; +import com.shapesecurity.salvation2.Policy; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +public class TrustedTypesDirective extends Directive { + // https://w3c.github.io/trusted-types/dist/spec/#trusted-types-csp-directive + // tt-policy-name = 1*( ALPHA / DIGIT / "-" / "#" / "=" / "_" / "/" / "@" / "." / "%" ) + private static final Pattern TT_POLICY_NAME_PATTERN = Pattern.compile("^[A-Za-z0-9\\-#=_/@.%]+$"); + + private boolean none = false; + private boolean allowDuplicates = false; + private boolean star = false; + private List policyNames = new ArrayList<>(); + + public TrustedTypesDirective(List values, DirectiveErrorConsumer errors) { + super(values); + + int index = 0; + for (String token : values) { + // Keywords are case-insensitive per ABNF spec (RFC 5234 ยง2.3). + // Note: Chromium incorrectly treats 'allow-duplicates' as case-sensitive, + // while WebKit correctly treats it as case-insensitive. We follow the spec. + // See https://issues.chromium.org/issues/472892238 + String lowcaseToken = token.toLowerCase(Locale.ENGLISH); + switch (lowcaseToken) { + case "'none'": + if (!this.none) { + this.none = true; + } else { + errors.add(Policy.Severity.Warning, "Duplicate keyword 'none'", index); + } + break; + case "'allow-duplicates'": + if (!this.allowDuplicates) { + this.allowDuplicates = true; + } else { + errors.add(Policy.Severity.Warning, "Duplicate keyword 'allow-duplicates'", index); + } + break; + case "*": + if (!this.star) { + this.star = true; + } else { + errors.add(Policy.Severity.Warning, "Duplicate wildcard *", index); + } + break; + default: + if (token.startsWith("'") && token.endsWith("'")) { + errors.add(Policy.Severity.Error, "Unrecognized trusted-types keyword " + token, index); + } else if (TT_POLICY_NAME_PATTERN.matcher(token).matches()) { + // Policy names are case-sensitive per browser behavior + if (this.policyNames.contains(token)) { + errors.add(Policy.Severity.Warning, "Duplicate policy name " + token, index); + } else { + this.policyNames.add(token); + } + } else { + errors.add(Policy.Severity.Error, "Invalid trusted-types policy name " + token, index); + } + } + ++index; + } + + // 'none' must not be combined with other values + if (this.none && (this.star || this.allowDuplicates || !this.policyNames.isEmpty())) { + errors.add(Policy.Severity.Error, "'none' must not be combined with any other trusted-types expression", -1); + } + } + + + public boolean none() { + return this.none; + } + + public void setNone(boolean none) { + if (this.none == none) { + return; + } + if (none) { + this.addValue("'none'"); + } else { + this.removeValueIgnoreCase("'none'"); + } + this.none = none; + } + + + public boolean allowDuplicates() { + return this.allowDuplicates; + } + + public void setAllowDuplicates(boolean allowDuplicates) { + if (this.allowDuplicates == allowDuplicates) { + return; + } + if (allowDuplicates) { + this.addValue("'allow-duplicates'"); + } else { + this.removeValueIgnoreCase("'allow-duplicates'"); + } + this.allowDuplicates = allowDuplicates; + } + + + public boolean star() { + return this.star; + } + + public void setStar(boolean star) { + if (this.star == star) { + return; + } + if (star) { + this.addValue("*"); + } else { + this.removeValueIgnoreCase("*"); + } + this.star = star; + } + + + public List getPolicyNames() { + return Collections.unmodifiableList(this.policyNames); + } + + public void addPolicyName(String policyName, ManipulationErrorConsumer errors) { + if (!TT_POLICY_NAME_PATTERN.matcher(policyName).matches()) { + throw new IllegalArgumentException("Invalid policy name: " + policyName); + } + // Policy names are case-sensitive per browser behavior + if (this.policyNames.contains(policyName)) { + errors.add(ManipulationErrorConsumer.Severity.Warning, "Duplicate policy name " + policyName); + return; + } + this.policyNames.add(policyName); + this.addValue(policyName); + } + + public void removePolicyName(String policyName) { + // Policy names are case-sensitive per browser behavior + this.policyNames.remove(policyName); + this.removeValueExact(policyName); + } + + private void removeValueExact(String value) { + this.values.remove(value); + } +} diff --git a/src/main/java/com/shapesecurity/salvation2/Policy.java b/src/main/java/com/shapesecurity/salvation2/Policy.java index 3087e2e..cb7154e 100644 --- a/src/main/java/com/shapesecurity/salvation2/Policy.java +++ b/src/main/java/com/shapesecurity/salvation2/Policy.java @@ -4,8 +4,10 @@ import com.shapesecurity.salvation2.Directives.HostSourceDirective; import com.shapesecurity.salvation2.Directives.PluginTypesDirective; import com.shapesecurity.salvation2.Directives.ReportUriDirective; +import com.shapesecurity.salvation2.Directives.RequireTrustedTypesForDirective; import com.shapesecurity.salvation2.Directives.SandboxDirective; import com.shapesecurity.salvation2.Directives.SourceExpressionDirective; +import com.shapesecurity.salvation2.Directives.TrustedTypesDirective; import com.shapesecurity.salvation2.URLs.GUID; import com.shapesecurity.salvation2.URLs.URI; import com.shapesecurity.salvation2.URLs.URLWithScheme; @@ -62,6 +64,8 @@ public class Policy { private ReportUriDirective reportUri; private SandboxDirective sandbox = null; + private TrustedTypesDirective trustedTypes = null; + private RequireTrustedTypesForDirective requireTrustedTypesFor = null; private boolean upgradeInsecureRequests = false; @Nonnull @@ -276,6 +280,28 @@ public Directive add(String name, List values, Directive.DirectiveErrorC newDirective = thisDirective; break; } + case "trusted-types": { + // https://w3c.github.io/trusted-types/dist/spec/#trusted-types-csp-directive + TrustedTypesDirective thisDirective = new TrustedTypesDirective(values, directiveErrorConsumer); + if (this.trustedTypes == null) { + this.trustedTypes = thisDirective; + } else { + wasDupe = true; + } + newDirective = thisDirective; + break; + } + case "require-trusted-types-for": { + // https://w3c.github.io/trusted-types/dist/spec/#require-trusted-types-for-csp-directive + RequireTrustedTypesForDirective thisDirective = new RequireTrustedTypesForDirective(values, directiveErrorConsumer); + if (this.requireTrustedTypesFor == null) { + this.requireTrustedTypesFor = thisDirective; + } else { + wasDupe = true; + } + newDirective = thisDirective; + break; + } case "upgrade-insecure-requests": { // https://www.w3.org/TR/upgrade-insecure-requests/#delivery if (!this.upgradeInsecureRequests) { @@ -373,6 +399,14 @@ public boolean remove(String name) { this.sandbox = null; break; } + case "trusted-types": { + this.trustedTypes = null; + break; + } + case "require-trusted-types-for": { + this.requireTrustedTypesFor = null; + break; + } case "upgrade-insecure-requests": { this.upgradeInsecureRequests = false; break; @@ -484,6 +518,14 @@ public Optional sandbox() { return Optional.ofNullable(this.sandbox); } + public Optional trustedTypes() { + return Optional.ofNullable(this.trustedTypes); + } + + public Optional requireTrustedTypesFor() { + return Optional.ofNullable(this.requireTrustedTypesFor); + } + public boolean upgradeInsecureRequests() { return this.upgradeInsecureRequests; } diff --git a/src/test/java/com/shapesecurity/salvation2/TrustedTypesTest.java b/src/test/java/com/shapesecurity/salvation2/TrustedTypesTest.java new file mode 100644 index 0000000..8adedc5 --- /dev/null +++ b/src/test/java/com/shapesecurity/salvation2/TrustedTypesTest.java @@ -0,0 +1,328 @@ +package com.shapesecurity.salvation2; + +import com.shapesecurity.salvation2.Directives.RequireTrustedTypesForDirective; +import com.shapesecurity.salvation2.Directives.TrustedTypesDirective; +import org.junit.Test; + +import java.util.ArrayList; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class TrustedTypesTest extends TestBase { + + // trusted-types directive tests + + @Test + public void testTrustedTypesBasic() { + Policy p; + + // Basic policy name + p = Policy.parseSerializedCSP("trusted-types myPolicy", throwIfPolicyError); + assertTrue(p.trustedTypes().isPresent()); + TrustedTypesDirective tt = p.trustedTypes().get(); + assertEquals(1, tt.getPolicyNames().size()); + assertEquals("myPolicy", tt.getPolicyNames().get(0)); + assertFalse(tt.none()); + assertFalse(tt.allowDuplicates()); + assertFalse(tt.star()); + + // Multiple policy names + p = Policy.parseSerializedCSP("trusted-types one two three", throwIfPolicyError); + tt = p.trustedTypes().get(); + assertEquals(3, tt.getPolicyNames().size()); + + // Wildcard + p = Policy.parseSerializedCSP("trusted-types *", throwIfPolicyError); + tt = p.trustedTypes().get(); + assertTrue(tt.star()); + assertEquals(0, tt.getPolicyNames().size()); + + // Allow duplicates + p = Policy.parseSerializedCSP("trusted-types myPolicy 'allow-duplicates'", throwIfPolicyError); + tt = p.trustedTypes().get(); + assertTrue(tt.allowDuplicates()); + assertEquals(1, tt.getPolicyNames().size()); + + // Wildcard with allow-duplicates + p = Policy.parseSerializedCSP("trusted-types * 'allow-duplicates'", throwIfPolicyError); + tt = p.trustedTypes().get(); + assertTrue(tt.star()); + assertTrue(tt.allowDuplicates()); + + // None keyword + p = Policy.parseSerializedCSP("trusted-types 'none'", throwIfPolicyError); + tt = p.trustedTypes().get(); + assertTrue(tt.none()); + assertEquals(0, tt.getPolicyNames().size()); + } + + @Test + public void testTrustedTypesPolicyNameCharacters() { + Policy p; + + // Policy names can contain various characters per spec: + // tt-policy-name = 1*( ALPHA / DIGIT / "-" / "#" / "=" / "_" / "/" / "@" / "." / "%" ) + p = Policy.parseSerializedCSP("trusted-types my-policy_name.v1", throwIfPolicyError); + TrustedTypesDirective tt = p.trustedTypes().get(); + assertEquals(1, tt.getPolicyNames().size()); + + p = Policy.parseSerializedCSP("trusted-types policy#1 policy@domain", throwIfPolicyError); + tt = p.trustedTypes().get(); + assertEquals(2, tt.getPolicyNames().size()); + + p = Policy.parseSerializedCSP("trusted-types path/to/policy policy=value", throwIfPolicyError); + tt = p.trustedTypes().get(); + assertEquals(2, tt.getPolicyNames().size()); + + p = Policy.parseSerializedCSP("trusted-types policy%20name", throwIfPolicyError); + tt = p.trustedTypes().get(); + assertEquals(1, tt.getPolicyNames().size()); + } + + @Test + public void testTrustedTypesRoundTrips() { + roundTrips("trusted-types myPolicy"); + roundTrips("trusted-types one two three"); + roundTrips("trusted-types *"); + roundTrips("trusted-types 'none'"); + roundTrips("trusted-types myPolicy 'allow-duplicates'"); + roundTrips("trusted-types * 'allow-duplicates'"); + } + + @Test + public void testTrustedTypesCaseInsensitiveKeywords() { + // Keywords are case-insensitive per ABNF + inTurkey(() -> { + Policy p; + + p = Policy.parseSerializedCSP("trusted-types 'NONE'", throwIfPolicyError); + assertTrue(p.trustedTypes().get().none()); + + p = Policy.parseSerializedCSP("trusted-types 'ALLOW-DUPLICATES'", throwIfPolicyError); + assertTrue(p.trustedTypes().get().allowDuplicates()); + + p = Policy.parseSerializedCSP("TRUSTED-TYPES myPolicy", throwIfPolicyError); + assertTrue(p.trustedTypes().isPresent()); + }); + } + + @Test + public void testTrustedTypesErrors() { + // 'none' combined with other values + roundTrips( + "trusted-types 'none' myPolicy", + e(Policy.Severity.Error, "'none' must not be combined with any other trusted-types expression", 0, -1) + ); + + roundTrips( + "trusted-types 'none' *", + e(Policy.Severity.Error, "'none' must not be combined with any other trusted-types expression", 0, -1) + ); + + roundTrips( + "trusted-types 'none' 'allow-duplicates'", + e(Policy.Severity.Error, "'none' must not be combined with any other trusted-types expression", 0, -1) + ); + + // Invalid keyword + roundTrips( + "trusted-types 'invalid-keyword'", + e(Policy.Severity.Error, "Unrecognized trusted-types keyword 'invalid-keyword'", 0, 0) + ); + + // Invalid policy name + roundTrips( + "trusted-types policy!name", + e(Policy.Severity.Error, "Invalid trusted-types policy name policy!name", 0, 0) + ); + + // Duplicate policy name + roundTrips( + "trusted-types myPolicy myPolicy", + e(Policy.Severity.Warning, "Duplicate policy name myPolicy", 0, 1) + ); + + // Different case policy names are NOT duplicates (case-sensitive per browser behavior) + roundTrips( + "trusted-types myPolicy MYPOLICY" + ); + + // Duplicate keyword + roundTrips( + "trusted-types 'allow-duplicates' 'allow-duplicates'", + e(Policy.Severity.Warning, "Duplicate keyword 'allow-duplicates'", 0, 1) + ); + + // Duplicate wildcard + roundTrips( + "trusted-types * *", + e(Policy.Severity.Warning, "Duplicate wildcard *", 0, 1) + ); + + // Duplicate directive + roundTrips( + "trusted-types one; trusted-types two", + e(Policy.Severity.Warning, "Duplicate directive trusted-types", 1, -1) + ); + } + + // require-trusted-types-for directive tests + + @Test + public void testRequireTrustedTypesForBasic() { + Policy p; + + p = Policy.parseSerializedCSP("require-trusted-types-for 'script'", throwIfPolicyError); + assertTrue(p.requireTrustedTypesFor().isPresent()); + RequireTrustedTypesForDirective rttf = p.requireTrustedTypesFor().get(); + assertTrue(rttf.script()); + } + + @Test + public void testRequireTrustedTypesForRoundTrips() { + roundTrips("require-trusted-types-for 'script'"); + } + + @Test + public void testRequireTrustedTypesForCaseInsensitive() { + inTurkey(() -> { + Policy p; + + p = Policy.parseSerializedCSP("require-trusted-types-for 'SCRIPT'", throwIfPolicyError); + assertTrue(p.requireTrustedTypesFor().get().script()); + + p = Policy.parseSerializedCSP("REQUIRE-TRUSTED-TYPES-FOR 'script'", throwIfPolicyError); + assertTrue(p.requireTrustedTypesFor().isPresent()); + }); + } + + @Test + public void testRequireTrustedTypesForErrors() { + // Missing value + roundTrips( + "require-trusted-types-for", + e(Policy.Severity.Error, "The require-trusted-types-for directive requires a value", 0, -1) + ); + + // Invalid keyword + roundTrips( + "require-trusted-types-for 'invalid'", + e(Policy.Severity.Error, "Unrecognized require-trusted-types-for keyword 'invalid'", 0, 0) + ); + + // Value without quotes + roundTrips( + "require-trusted-types-for script", + e(Policy.Severity.Error, "Unrecognized require-trusted-types-for value script - keywords must be wrapped in single quotes", 0, 0) + ); + + // Duplicate keyword + roundTrips( + "require-trusted-types-for 'script' 'script'", + e(Policy.Severity.Warning, "Duplicate keyword 'script'", 0, 1) + ); + + // Duplicate directive + roundTrips( + "require-trusted-types-for 'script'; require-trusted-types-for 'script'", + e(Policy.Severity.Warning, "Duplicate directive require-trusted-types-for", 1, -1) + ); + } + + // Combined tests + + @Test + public void testTrustedTypesWithRequireTrustedTypesFor() { + Policy p = Policy.parseSerializedCSP("require-trusted-types-for 'script'; trusted-types myPolicy", throwIfPolicyError); + assertTrue(p.requireTrustedTypesFor().isPresent()); + assertTrue(p.trustedTypes().isPresent()); + assertTrue(p.requireTrustedTypesFor().get().script()); + assertEquals(1, p.trustedTypes().get().getPolicyNames().size()); + } + + // Manipulation tests + + @Test + public void testTrustedTypesPolicyNamesCaseSensitive() { + // Policy names are case-sensitive per browser behavior + Policy p = Policy.parseSerializedCSP("trusted-types myPolicy MYPOLICY MyPolicy", throwIfPolicyError); + TrustedTypesDirective tt = p.trustedTypes().get(); + + // All three should be stored as separate policy names + assertEquals(3, tt.getPolicyNames().size()); + assertTrue(tt.getPolicyNames().contains("myPolicy")); + assertTrue(tt.getPolicyNames().contains("MYPOLICY")); + assertTrue(tt.getPolicyNames().contains("MyPolicy")); + } + + @Test + public void testTrustedTypesManipulation() { + Policy p = Policy.parseSerializedCSP("trusted-types one", throwIfPolicyError); + TrustedTypesDirective tt = p.trustedTypes().get(); + + // Add policy name + tt.addPolicyName("two", Directive.ManipulationErrorConsumer.ignored); + assertEquals(2, tt.getPolicyNames().size()); + assertTrue(tt.getPolicyNames().contains("one")); + assertTrue(tt.getPolicyNames().contains("two")); + + // Adding same name with different case should work (case-sensitive) + tt.addPolicyName("ONE", Directive.ManipulationErrorConsumer.ignored); + assertEquals(3, tt.getPolicyNames().size()); + assertTrue(tt.getPolicyNames().contains("ONE")); + + // Remove policy name (case-sensitive) + tt.removePolicyName("one"); + assertEquals(2, tt.getPolicyNames().size()); + assertFalse(tt.getPolicyNames().contains("one")); + assertTrue(tt.getPolicyNames().contains("ONE")); // ONE should still be there + assertTrue(tt.getPolicyNames().contains("two")); + + // Set allow-duplicates + assertFalse(tt.allowDuplicates()); + tt.setAllowDuplicates(true); + assertTrue(tt.allowDuplicates()); + tt.setAllowDuplicates(false); + assertFalse(tt.allowDuplicates()); + + // Set star + assertFalse(tt.star()); + tt.setStar(true); + assertTrue(tt.star()); + tt.setStar(false); + assertFalse(tt.star()); + } + + @Test + public void testRequireTrustedTypesForManipulation() { + Policy p = Policy.parseSerializedCSP("require-trusted-types-for 'script'", throwIfPolicyError); + RequireTrustedTypesForDirective rttf = p.requireTrustedTypesFor().get(); + + assertTrue(rttf.script()); + rttf.setScript(false); + assertFalse(rttf.script()); + rttf.setScript(true); + assertTrue(rttf.script()); + } + + // Helper methods + + private static void roundTrips(String input, PolicyError... errors) { + serializesTo(input, input, errors); + } + + private static void serializesTo(String input, String output, PolicyError... errors) { + ArrayList observedErrors = new ArrayList<>(); + Policy.PolicyErrorConsumer consumer = (severity, message, directiveIndex, valueIndex) -> { + observedErrors.add(e(severity, message, directiveIndex, valueIndex)); + }; + Policy policy = Policy.parseSerializedCSP(input, consumer); + assertEquals("should have the expected number of errors", errors.length, observedErrors.size()); + for (int i = 0; i < errors.length; ++i) { + assertEquals(errors[i], observedErrors.get(i)); + } + assertEquals(output, policy.toString()); + } +}