diff --git a/src/main/java/org/htmlunit/csp/Policy.java b/src/main/java/org/htmlunit/csp/Policy.java index cb41815..cc38071 100644 --- a/src/main/java/org/htmlunit/csp/Policy.java +++ b/src/main/java/org/htmlunit/csp/Policy.java @@ -33,8 +33,10 @@ import org.htmlunit.csp.directive.HostSourceDirective; import org.htmlunit.csp.directive.PluginTypesDirective; import org.htmlunit.csp.directive.ReportUriDirective; +import org.htmlunit.csp.directive.RequireTrustedTypesForDirective; import org.htmlunit.csp.directive.SandboxDirective; import org.htmlunit.csp.directive.SourceExpressionDirective; +import org.htmlunit.csp.directive.TrustedTypesDirective; import org.htmlunit.csp.url.GUID; import org.htmlunit.csp.url.URI; import org.htmlunit.csp.url.URLWithScheme; @@ -70,6 +72,8 @@ public final class Policy { private RFC7230Token reportTo_; private ReportUriDirective reportUri_; private SandboxDirective sandbox_; + private TrustedTypesDirective trustedTypes_; + private RequireTrustedTypesForDirective requireTrustedTypes_; private boolean upgradeInsecureRequests_; private final EnumMap fetchDirectives_ @@ -318,6 +322,32 @@ else if (values.size() == 1) { newDirective = sandboxDirective; break; + case "trusted-types": + // https://www.w3.org/TR/trusted-types/ + final TrustedTypesDirective trustedTypesDirective = new TrustedTypesDirective( + values, directiveErrorConsumer); + if (trustedTypes_ == null) { + trustedTypes_ = trustedTypesDirective; + } + else { + wasDupe = true; + } + newDirective = trustedTypesDirective; + break; + + case "require-trusted-types-for": + // https://www.w3.org/TR/trusted-types/#require-trusted-types-for-csp-directive + final RequireTrustedTypesForDirective requireTrustedTypesDirective = new RequireTrustedTypesForDirective( + values, directiveErrorConsumer); + if (requireTrustedTypes_ == null) { + requireTrustedTypes_ = requireTrustedTypesDirective; + } + else { + wasDupe = true; + } + newDirective = requireTrustedTypesDirective; + break; + case "upgrade-insecure-requests": // https://www.w3.org/TR/upgrade-insecure-requests/#delivery if (upgradeInsecureRequests_) { diff --git a/src/main/java/org/htmlunit/csp/directive/RequireTrustedTypesForDirective.java b/src/main/java/org/htmlunit/csp/directive/RequireTrustedTypesForDirective.java new file mode 100644 index 0000000..c1a3a6a --- /dev/null +++ b/src/main/java/org/htmlunit/csp/directive/RequireTrustedTypesForDirective.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023-2026 Ronald Brill. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.htmlunit.csp.directive; + +import org.htmlunit.csp.Directive; +import org.htmlunit.csp.Policy; +import java.util.List; + +/** + * Directive implementation for `require-trusted-types-for`. + */ +public class RequireTrustedTypesForDirective extends Directive { + + public RequireTrustedTypesForDirective(final List values, final DirectiveErrorConsumer errors) { + super(values); + if (!values.isEmpty()) { + for (int i = 0; i < values.size(); i++) { + String value = values.get(i); + if (!"script".equalsIgnoreCase(value)) { + errors.add(Policy.Severity.Error, "`require-trusted-types-for` only accepts 'script' as a value.", i); + } + } + } + } +} \ No newline at end of file diff --git a/src/main/java/org/htmlunit/csp/directive/TrustedTypesDirective.java b/src/main/java/org/htmlunit/csp/directive/TrustedTypesDirective.java new file mode 100644 index 0000000..17f9ed4 --- /dev/null +++ b/src/main/java/org/htmlunit/csp/directive/TrustedTypesDirective.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2023-2026 Ronald Brill. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.htmlunit.csp.directive; + +import org.htmlunit.csp.Directive; +import org.htmlunit.csp.Policy; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * Directive implementation for `trusted-types`. + */ +public class TrustedTypesDirective extends Directive { + + private boolean allowDuplicates_; + private boolean allowAnyPolicyName_; + private Set allowedPolicyNames_ = new HashSet<>(); + + public TrustedTypesDirective(final List values, final DirectiveErrorConsumer errors) { + super(values); + boolean hasNone = values.stream().anyMatch(x -> x.toLowerCase(Locale.ROOT).equals("'none'")); + + if (hasNone && values.size() > 1) { + errors.add(Policy.Severity.Error, + "Specifying trusted-types 'none' along with other values is invalid", -1); + } + + for (int i = 0; i < values.size(); i++) { + String value = values.get(i); + final String lowercaseValue = value.toLowerCase(Locale.ROOT); + + if ("*".equals(value)) { + allowAnyPolicyName_ = true; + continue; + } else if (value.isBlank()) { + errors.add(Policy.Severity.Error, "Empty or whitespace-only policy name is not allowed.", i); + continue; + } + + // Check for quoted keywords + if (value.startsWith("'") && value.endsWith("'") && value.length() > 2) { // Minimum "'x'" + String inner = lowercaseValue.substring(1, value.length() - 1); + if (inner.equals("allow-duplicates")) { + allowDuplicates_ = true; + } else if (inner.equals("none")) { + // 'none' is ignored here, nNo action needed + } else { + errors.add(Policy.Severity.Error, "Unknown keyword in trusted-types: " + value, i); + } + continue; + } + + // Validate and add policy name (unquoted) + if (isValidPolicyName(value)) { + if (!allowedPolicyNames_.add(value)) { // False when attempting to add duplicate + // The spec treats the policy name as a JavaScript DOMString identifier, and comparisons are done as exact string matches. + // There is no case normalization step. + // https://www.w3.org/TR/trusted-types/#should-trusted-type-policy-creation-be-blocked-by-content-security-policy + // https://www.w3.org/TR/trusted-types/#create-a-trusted-type-policy + // Case-sensitive storage + errors.add(Policy.Severity.Warning, "Second attempt to add trusted-types policy: " + value, i); + }; + } else { + errors.add(Policy.Severity.Error, "Invalid policy name in trusted-types: " + value + + " (must be alphanumeric or -#=_/@.%)", i); + } + } + } + + private static boolean isValidPolicyName(String name) { + if (name.isEmpty()) { + return false; + } + for (char c : name.toCharArray()) { + if (!Character.isLetterOrDigit(c) && "-#=_/@.%".indexOf(c) == -1) { + return false; + } + } + return true; + } + + public boolean isAllowDuplicates() { + return allowDuplicates_; + } + + public boolean isAllowAnyPolicyName() { + return allowAnyPolicyName_; + } + + public Set getAllowedPolicyNames() { + return Collections.unmodifiableSet(allowedPolicyNames_); + } +} \ No newline at end of file diff --git a/src/test/java/org/htmlunit/csp/directive/RequireTrustedTypesForDirectiveTest.java b/src/test/java/org/htmlunit/csp/directive/RequireTrustedTypesForDirectiveTest.java new file mode 100644 index 0000000..1e7a768 --- /dev/null +++ b/src/test/java/org/htmlunit/csp/directive/RequireTrustedTypesForDirectiveTest.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2023-2026 Ronald Brill. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.htmlunit.csp.directive; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; + +class RequireTrustedTypesForDirectiveTest { + + @Test + void testValidScriptValue() { + // Given + List errors = new ArrayList<>(); + // When + // Valid value: 'script' + RequireTrustedTypesForDirective directive = new RequireTrustedTypesForDirective( + List.of("script"), (severity, message, valueIndex) -> errors.add(message)); + // Then + assertTrue(directive.getValues().contains("script")); + assertTrue(errors.isEmpty()); // No errors should be reported + } + + @Test + void testInvalidValue() { + List errors = new ArrayList<>(); + // Then + // Invalid value: 'invalid' + RequireTrustedTypesForDirective directive = new RequireTrustedTypesForDirective( + List.of("invalid"), (severity, message, valueIndex) -> errors.add(message)); + // Then + assertTrue(directive.getValues().contains("invalid")); + assertEquals(1, errors.size()); // One error should be reported + assertEquals("`require-trusted-types-for` only accepts 'script' as a value.", + errors.get(0)); + } + + @Test + void testInvalidValueAmonstMany() { + // Given + List errors = new ArrayList<>(); + // When + // Invalid value: 'invalid' + RequireTrustedTypesForDirective directive = new RequireTrustedTypesForDirective( + List.of("script", "invalid"), (severity, message, valueIndex) -> errors.add(message)); + // Then + assertTrue(directive.getValues().contains("script")); + assertTrue(directive.getValues().contains("invalid")); + assertEquals(1, errors.size()); // One error should be reported + assertEquals("`require-trusted-types-for` only accepts 'script' as a value.", + errors.get(0)); + } +} \ No newline at end of file diff --git a/src/test/java/org/htmlunit/csp/directive/TrustedTypesDirectiveTest.java b/src/test/java/org/htmlunit/csp/directive/TrustedTypesDirectiveTest.java new file mode 100644 index 0000000..7c7e40b --- /dev/null +++ b/src/test/java/org/htmlunit/csp/directive/TrustedTypesDirectiveTest.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2023-2026 Ronald Brill. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.htmlunit.csp.directive; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +class TrustedTypesDirectiveTest { + + private List errors; + + @BeforeEach + void setup() { + this.errors = new ArrayList<>(); + } + + @Test + void testWildcardAllowed() { + // Given / When + // Wildcard policy + new TrustedTypesDirective(List.of("*"), + (severity, message, valueIndex) -> errors.add(message)); + // Then + assertTrue(errors.isEmpty()); + } + + @Test + void testEmptyPolicyNameError() { + // Given / When // Then + // Whitespace-only policy name + assertThrows(IllegalArgumentException.class, + () -> new TrustedTypesDirective(List.of("policy1", " "), + (severity, message, valueIndex) -> errors.add(message))); + } + + @Test + void testSimplePolicy() { + // Given / When + // Ex: trusted-types myPolicy default; + new TrustedTypesDirective(List.of("mypolicy", "default"), + (severity, message, valueIndex) -> errors.add(message)); + // Then + assertTrue(errors.isEmpty()); + } + + @Test + void testAllowDuplicatesKeyword() { + // Given / When + TrustedTypesDirective directive = new TrustedTypesDirective(List.of("'allow-duplicates'"), + (severity, message, valueIndex) -> errors.add(message)); + // Then + assertTrue(directive.isAllowDuplicates()); + assertFalse(directive.isAllowAnyPolicyName()); + assertTrue(directive.getAllowedPolicyNames().isEmpty()); + assertTrue(errors.isEmpty()); + } + + @Test + void testNoneKeywordAlone() { + // Given / When + TrustedTypesDirective directive = new TrustedTypesDirective(List.of("'none'"), + (severity, message, valueIndex) -> errors.add(message)); + // Then + assertFalse(directive.isAllowAnyPolicyName()); + assertFalse(directive.isAllowDuplicates()); + assertTrue(directive.getAllowedPolicyNames().isEmpty()); + assertTrue(errors.isEmpty()); + } + + @Test + void testNoneKeywordMixedWithPolicies() { + // Given / When + TrustedTypesDirective directive = new TrustedTypesDirective(List.of("'none'", "my-policy"), + (severity, message, valueIndex) -> errors.add(message)); + // Then + assertFalse(directive.isAllowAnyPolicyName()); + assertFalse(directive.isAllowDuplicates()); + assertEquals(Set.of("my-policy"), directive.getAllowedPolicyNames()); + assertEquals("Specifying trusted-types 'none' along with other values is invalid", + errors.get(0)); + } + + @Test + void testValidPolicyNames() { + // Given / When + TrustedTypesDirective directive = new TrustedTypesDirective( + List.of("policy1", "policy-2_@.%", "#=/"), + (severity, message, valueIndex) -> errors.add(message)); + // Then + assertEquals(Set.of("policy1", "policy-2_@.%", "#=/"), directive.getAllowedPolicyNames()); + assertTrue(errors.isEmpty()); + } + + @Test + void testCaseSensitivity() { + // Given / When + TrustedTypesDirective directive = new TrustedTypesDirective( + List.of("Policy", "policy", "'Allow-Duplicates'"), + (severity, message, valueIndex) -> errors.add(message)); + // Then + // Policy names case-sensitive (both added separately) + assertEquals(Set.of("Policy", "policy"), directive.getAllowedPolicyNames()); + // Keywords case-insensitive for inner content + assertTrue(directive.isAllowDuplicates()); + assertTrue(errors.isEmpty()); + } + + @Test + void testUnknownKeyword() { + // Given / When + new TrustedTypesDirective(List.of("'unknown'"), + (severity, message, valueIndex) -> errors.add(message)); + // Then + assertTrue(errors.size() == 1 && errors.get(0).contains("Unknown keyword")); + } + + @Test + void testMixedAll() { + // Given / When + TrustedTypesDirective directive = new TrustedTypesDirective( + List.of("*", "'allow-duplicates'", "policy1", "'none'", "invalid!"), + (severity, message, valueIndex) -> errors.add(message)); + // Then + assertTrue(directive.isAllowAnyPolicyName()); + assertTrue(directive.isAllowDuplicates()); + assertEquals(Set.of("policy1"), directive.getAllowedPolicyNames()); + assertEquals(2, errors.size()); + assertTrue(errors + .contains("Specifying trusted-types 'none' along with other values is invalid")); + assertTrue(errors.contains( + "Invalid policy name in trusted-types: invalid! (must be alphanumeric or -#=_/@.%)")); + } + + @Test + void testDuplicatesWhenNotAllowed() { + // Given / When + new TrustedTypesDirective(List.of("mypolicy", "mypolicy"), + (severity, message, valueIndex) -> errors.add(message)); + // Then + assertFalse(errors.isEmpty()); + assertEquals(errors.get(0), "Second attempt to add trusted-types policy: mypolicy"); + } +}