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());
+ }
+}