diff --git a/core/src/main/java/hudson/markup/MarkupFormatter.java b/core/src/main/java/hudson/markup/MarkupFormatter.java index dd144c410139..ac99b317de42 100644 --- a/core/src/main/java/hudson/markup/MarkupFormatter.java +++ b/core/src/main/java/hudson/markup/MarkupFormatter.java @@ -35,11 +35,8 @@ import java.io.Writer; import java.util.Collections; import java.util.Map; -import java.util.function.Function; import java.util.logging.Level; import java.util.logging.Logger; -import java.util.stream.Collectors; -import java.util.stream.Stream; import jenkins.util.SystemProperties; import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.restrictions.NoExternalUse; @@ -133,7 +130,7 @@ public HttpResponse doPreviewDescription(@QueryParameter String text) throws IOE translate(text, w); Map extraHeaders = Collections.emptyMap(); if (PREVIEWS_SET_CSP) { - extraHeaders = Stream.of("Content-Security-Policy", "X-WebKit-CSP", "X-Content-Security-Policy").collect(Collectors.toMap(Function.identity(), v -> "default-src 'none';")); + extraHeaders = Map.of("Content-Security-Policy", "default-src 'none';"); } return html(200, w.toString(), extraHeaders); } diff --git a/core/src/main/java/hudson/model/DirectoryBrowserSupport.java b/core/src/main/java/hudson/model/DirectoryBrowserSupport.java index fff02d56f871..07be207fc012 100644 --- a/core/src/main/java/hudson/model/DirectoryBrowserSupport.java +++ b/core/src/main/java/hudson/model/DirectoryBrowserSupport.java @@ -64,6 +64,7 @@ import jenkins.security.MasterToSlaveCallable; import jenkins.security.ResourceDomainConfiguration; import jenkins.security.ResourceDomainRootAction; +import jenkins.security.csp.CspHeader; import jenkins.util.SystemProperties; import jenkins.util.VirtualFile; import org.apache.commons.io.IOUtils; @@ -398,13 +399,14 @@ private void serveFile(StaplerRequest2 req, StaplerResponse2 rsp, VirtualFile ro rsp.sendRedirect(302, ResourceDomainRootAction.get().getRedirectUrl(resourceToken, req.getRestOfPath())); } else { if (!ResourceDomainConfiguration.isResourceRequest(req)) { - // if we're serving this from the main domain, set CSP headers + // If we're serving this from the main domain, set CSP headers. These override the default CSP headers. String csp = SystemProperties.getString(CSP_PROPERTY_NAME, DEFAULT_CSP_VALUE); if (!csp.trim().isEmpty()) { // allow users to prevent sending this header by setting empty system property - for (String header : new String[]{"Content-Security-Policy", "X-WebKit-CSP", "X-Content-Security-Policy"}) { - rsp.setHeader(header, csp); - } + rsp.setHeader(CspHeader.ContentSecurityPolicy.getHeaderName(), csp); + } else { + // Clear the header value if configured by the user. + rsp.setHeader(CspHeader.ContentSecurityPolicy.getHeaderName(), null); } } InputStream in; diff --git a/core/src/main/java/hudson/model/UsageStatistics.java b/core/src/main/java/hudson/model/UsageStatistics.java index 34f08e454341..3ee8e8ec1c02 100644 --- a/core/src/main/java/hudson/model/UsageStatistics.java +++ b/core/src/main/java/hudson/model/UsageStatistics.java @@ -67,6 +67,8 @@ import jenkins.security.FIPS140; import jenkins.util.SystemProperties; import net.sf.json.JSONObject; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; import org.kohsuke.stapler.StaplerRequest2; /** @@ -103,8 +105,7 @@ public UsageStatistics(String keyImage) { * Returns true if it's time for us to check for new version. */ public boolean isDue() { - // user opted out (explicitly or FIPS is requested). no data collection - if (!Jenkins.get().isUsageStatisticsCollected() || DISABLED || FIPS140.useCompliantAlgorithms()) { + if (!isEnabled()) { return false; } @@ -116,6 +117,19 @@ public boolean isDue() { return false; } + /** + * Returns whether between UI configuration, system property, and environment, + * usage statistics should be submitted. + * + * @return true if and only if usage stats should be submitted + * @since TODO + */ + @Restricted(NoExternalUse.class) + public static boolean isEnabled() { + // user opted out (explicitly or FIPS is requested). no data collection + return Jenkins.get().isUsageStatisticsCollected() && !DISABLED && !FIPS140.useCompliantAlgorithms(); + } + private RSAPublicKey getKey() { try { if (key == null) { diff --git a/core/src/main/java/hudson/util/FormFieldValidator.java b/core/src/main/java/hudson/util/FormFieldValidator.java index a0c2a08fb91c..df347bf6ed9c 100644 --- a/core/src/main/java/hudson/util/FormFieldValidator.java +++ b/core/src/main/java/hudson/util/FormFieldValidator.java @@ -231,9 +231,7 @@ private void _errorWithMarkup(String message, String cssClass) throws IOExceptio } else { response.setContentType("text/html;charset=UTF-8"); if (APPLY_CONTENT_SECURITY_POLICY_HEADERS) { - for (String header : new String[]{"Content-Security-Policy", "X-WebKit-CSP", "X-Content-Security-Policy"}) { - response.setHeader(header, "sandbox; default-src 'none';"); - } + response.setHeader("Content-Security-Policy", "sandbox; default-src 'none';"); } response.getWriter().print("
" + message + "
"); diff --git a/core/src/main/java/hudson/util/FormValidation.java b/core/src/main/java/hudson/util/FormValidation.java index f61b95883782..6f5ea9653327 100644 --- a/core/src/main/java/hudson/util/FormValidation.java +++ b/core/src/main/java/hudson/util/FormValidation.java @@ -612,9 +612,7 @@ public void generateResponse(StaplerRequest2 req, StaplerResponse2 rsp, Object n protected void respond(StaplerResponse2 rsp, String html) throws IOException, ServletException { rsp.setContentType("text/html;charset=UTF-8"); if (APPLY_CONTENT_SECURITY_POLICY_HEADERS) { - for (String header : new String[]{"Content-Security-Policy", "X-WebKit-CSP", "X-Content-Security-Policy"}) { - rsp.setHeader(header, "sandbox; default-src 'none';"); - } + rsp.setHeader("Content-Security-Policy", "sandbox; default-src 'none';"); } rsp.getWriter().print(html); } diff --git a/core/src/main/java/jenkins/model/navigation/UserAction.java b/core/src/main/java/jenkins/model/navigation/UserAction.java index c85829f6e5a4..0a36ac6da76b 100644 --- a/core/src/main/java/jenkins/model/navigation/UserAction.java +++ b/core/src/main/java/jenkins/model/navigation/UserAction.java @@ -42,6 +42,9 @@ @Extension(ordinal = -1) public class UserAction implements RootAction { + @Restricted(NoExternalUse.class) + public static final String AVATAR_SIZE = "96x96"; + @Override public String getIconFileName() { User current = User.current(); @@ -50,7 +53,7 @@ public String getIconFileName() { return null; } - return getAvatar(current, "96x96"); + return getAvatar(current, AVATAR_SIZE); } @Override diff --git a/core/src/main/java/jenkins/security/csp/AdvancedConfiguration.java b/core/src/main/java/jenkins/security/csp/AdvancedConfiguration.java new file mode 100644 index 000000000000..a61911380ee6 --- /dev/null +++ b/core/src/main/java/jenkins/security/csp/AdvancedConfiguration.java @@ -0,0 +1,61 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.security.csp; + +import hudson.DescriptorExtensionList; +import hudson.ExtensionList; +import hudson.ExtensionPoint; +import hudson.model.Describable; +import java.util.Optional; +import jenkins.model.Jenkins; +import jenkins.security.csp.impl.CspConfiguration; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; + +/** + * Add more advanced options to the {@link jenkins.security.csp.impl.CspConfiguration} UI. + * + * @since TODO + */ +@Restricted(Beta.class) +public abstract class AdvancedConfiguration implements Describable, ExtensionPoint { + public static DescriptorExtensionList all() { + return Jenkins.get().getDescriptorList(AdvancedConfiguration.class); + } + + /** + * Return the currently configured {@link jenkins.security.csp.AdvancedConfiguration}, if any. + * + * @param clazz the {@link jenkins.security.csp.AdvancedConfiguration} type to look up + * @param the {@link jenkins.security.csp.AdvancedConfiguration} type to look up + * @return the configured instance, if any + */ + public static Optional getCurrent(Class clazz) { + return ExtensionList.lookupSingleton(CspConfiguration.class).getAdvanced().stream() + .filter(a -> a.getClass() == clazz) + .map(clazz::cast) + .findFirst(); + } +} diff --git a/core/src/main/java/jenkins/security/csp/AdvancedConfigurationDescriptor.java b/core/src/main/java/jenkins/security/csp/AdvancedConfigurationDescriptor.java new file mode 100644 index 000000000000..d360c5d5af9a --- /dev/null +++ b/core/src/main/java/jenkins/security/csp/AdvancedConfigurationDescriptor.java @@ -0,0 +1,38 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.security.csp; + +import hudson.model.Descriptor; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; + +/** + * Descriptor for {@link jenkins.security.csp.AdvancedConfiguration}. + * + * @since TODO + */ +@Restricted(Beta.class) +public abstract class AdvancedConfigurationDescriptor extends Descriptor { +} diff --git a/core/src/main/java/jenkins/security/csp/AvatarContributor.java b/core/src/main/java/jenkins/security/csp/AvatarContributor.java new file mode 100644 index 000000000000..2d0e8b1e227e --- /dev/null +++ b/core/src/main/java/jenkins/security/csp/AvatarContributor.java @@ -0,0 +1,129 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.security.csp; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import hudson.Extension; +import hudson.ExtensionList; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; + +/** + * This is a general extension for use by implementations of {@link hudson.tasks.UserAvatarResolver} + * and {@code AvatarMetadataAction} from {@code scm-api} plugin, or other "avatar-like" use cases. + * It simplifies allowlisting safe sources of avatars by offering simple APIs that take a complete URL. + */ +@Restricted(Beta.class) +@Extension +public class AvatarContributor implements Contributor { + private static final Logger LOGGER = Logger.getLogger(AvatarContributor.class.getName()); + + private final Set domains = ConcurrentHashMap.newKeySet(); + + @Override + public void apply(CspBuilder cspBuilder) { + domains.forEach(d -> cspBuilder.add("img-src", d)); + } + + /** + * Request addition of the domain of the specified URL to the allowed set of avatar image domains. + *

+ * This is a utility method intended to accept any avatar URL from an undetermined, but trusted (for images) domain. + * If the specified URL is not {@code null}, has a host, and {@code http} or {@code https} scheme, its domain will + * be added to the set of allowed domains. + *

+ *

+ * Important: Only implementations restricting specification of avatar URLs to at least somewhat + * privileged users to should invoke this method, for example users with at least {@link hudson.model.Item#CONFIGURE} + * permission. Note that this guidance may change over time and require implementation changes. + *

+ * + * @param url The avatar image URL whose domain should be added to the list of allowed domains + */ + public static void allow(@CheckForNull String url) { + String domain = extractDomainFromUrl(url); + + if (domain == null) { + LOGGER.log(Level.FINE, "Skipping null domain in avatar URL: " + url); + return; + } + + if (ExtensionList.lookupSingleton(AvatarContributor.class).domains.add(domain)) { + LOGGER.log(Level.CONFIG, "Adding domain '" + domain + "' from avatar URL: " + url); + } else { + LOGGER.log(Level.FINEST, "Skipped adding duplicate domain '" + domain + "' from avatar URL: " + url); + } + } + + /** + * Utility method extracting the domain specification for CSP fetch directives from a specified URL. + * If the specified URL is not {@code null}, has a host, and {@code http} or {@code https} scheme, this method + * will return its domain. + * This can be used by implementations of {@link jenkins.security.csp.Contributor} for which {@link #allow(String)} + * is not flexible enough (e.g., requesting administrator approval for a domain). + * + * @param url the URL + * @return the domain from the specified URL, or {@code null} if the URL does not satisfy the stated conditions + */ + @CheckForNull + public static String extractDomainFromUrl(@CheckForNull String url) { + if (url == null) { + return null; + } + try { + final URI uri = new URI(url); + final String host = uri.getHost(); + if (host == null) { + // If there's no host, assume a local path + LOGGER.log(Level.FINER, "Ignoring URI without host: " + url); + return null; + } + String domain = host; + final String scheme = uri.getScheme(); + if (scheme != null) { + if (scheme.equals("http") || scheme.equals("https")) { + domain = scheme + "://" + domain; + } else { + LOGGER.log(Level.FINER, "Ignoring URI with unsupported scheme: " + url); + return null; + } + } + final int port = uri.getPort(); + if (port != -1) { + domain = domain + ":" + port; + } + return domain; + } catch (URISyntaxException e) { + LOGGER.log(Level.FINE, "Failed to parse avatar URI: " + url, e); + return null; + } + } +} diff --git a/core/src/main/java/jenkins/security/csp/Contributor.java b/core/src/main/java/jenkins/security/csp/Contributor.java new file mode 100644 index 000000000000..507ce6caee35 --- /dev/null +++ b/core/src/main/java/jenkins/security/csp/Contributor.java @@ -0,0 +1,52 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.security.csp; + +import hudson.ExtensionList; +import hudson.ExtensionPoint; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; + +/** + * Contribute to the Content-Security-Policy rules. + * + * @since TODO + */ +@Restricted(Beta.class) +public interface Contributor extends ExtensionPoint { + + /** + * Contribute to the builder's rules by adding to or + * removing from the provided {@link jenkins.security.csp.CspBuilder}. + * + * @param cspBuilder the builder + */ + default void apply(CspBuilder cspBuilder) { + } + + static ExtensionList all() { + return ExtensionList.lookup(Contributor.class); + } +} diff --git a/core/src/main/java/jenkins/security/csp/CspBuilder.java b/core/src/main/java/jenkins/security/csp/CspBuilder.java new file mode 100644 index 000000000000..1bba7e105c6d --- /dev/null +++ b/core/src/main/java/jenkins/security/csp/CspBuilder.java @@ -0,0 +1,254 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.security.csp; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * Builder for a CSP rule set. + * + * @see jenkins.security.csp.Contributor + * @since TODO + */ +@Restricted(Beta.class) +public class CspBuilder { + private static final Logger LOGGER = Logger.getLogger(CspBuilder.class.getName()); + + /** + * This list contains directives that accept 'none' as a value and are not fetch directives. + */ + private static final List NONE_DIRECTIVES = List.of(Directive.BASE_URI, Directive.FRAME_ANCESTORS, Directive.FORM_ACTION); + private final Map> directives = new HashMap<>(); + private final EnumSet initializedFDs = EnumSet.noneOf(FetchDirective.class); + + /** + * These keys cannot be set explicitly, as they're set by Jenkins. + */ + @Restricted(NoExternalUse.class) + static final Set PROHIBITED_KEYS = Set.of(Directive.REPORT_URI, Directive.REPORT_TO); + + public CspBuilder withDefaultContributions() { + Contributor.all().forEach(c -> { + try { + c.apply(this); + } catch (RuntimeException ex) { + LOGGER.log(Level.WARNING, "Failed to apply CSP contributions from " + c, ex); + } + }); + return this; + } + + /** + * Add the given directive and values. If the directive is already present, merge the values. + * If this is a fetch directive, {@code #add} does not disable inheritance from fallback directives. + * To disable inheritance for fetch directives, call {@link #initialize(FetchDirective, String...)} instead. + *

+ * The directives {@link jenkins.security.csp.Directive#REPORT_URI} and + * {@link jenkins.security.csp.Directive#REPORT_TO} cannot be set manually, so will be skipped. + *

+ *

+ * Similarly, the value {@link jenkins.security.csp.Directive#NONE} cannot be set and will be skipped. + * Instead, call {@link #remove(String, String...)} with a single argument to reset the directive, then + * call {@link #initialize(FetchDirective, String...)} with just the {@link jenkins.security.csp.FetchDirective} + * argument to disable inheritance. + *

+ * + * @param directive the directive to add + * @param values the values to add to the directive. {@code null} values are ignored. If only {@code null} values + * are passed, the directive will not be added. This is different from calling this with only the + * {@code directive} argument (i.e., an empty list of values), which will add the directive with no + * additional values, potentially resulting in an effective {@link jenkins.security.csp.Directive#NONE} + * value. + * @return this builder + */ + public CspBuilder add(String directive, String... values) { + if (PROHIBITED_KEYS.contains(directive)) { + LOGGER.config("Directive " + directive + " cannot be set manually"); + return this; + } + directives.compute(directive, (k, current) -> { + final List additions = new ArrayList<>(Arrays.stream(values).toList()); + if (additions.contains(Directive.NONE)) { + LOGGER.config("Cannot explicitly add 'none'. See " + Directive.class.getName() + "#NONE Javadoc."); + additions.remove(Directive.NONE); + } + + Set nonNullAdditions = additions.stream().filter(Objects::nonNull).collect(Collectors.toSet()); + + if (nonNullAdditions.isEmpty() != additions.isEmpty()) { + return current; + } + + if (current == null) { + return new HashSet<>(nonNullAdditions); + } else { + nonNullAdditions.addAll(current); + return nonNullAdditions; + } + }); + return this; + } + + /** + * Remove the given values from the directive, if present. If the directive does not exist, do nothing. + * If no values are provided, removes the entire directive. + * + * @param directive the directive to remove + * @param values the values to remove from the directive, or none if the entire directive should be removed. + * @return this builder + */ + public CspBuilder remove(String directive, String... values) { + if (values.length == 0) { + if (FetchDirective.isFetchDirective(directive)) { + initializedFDs.remove(FetchDirective.fromKey(directive)); + } + directives.remove(directive); + } else { + directives.compute(directive, (k, v) -> { + if (v == null) { + return null; + } else { + Arrays.asList(values).forEach(v::remove); + return v; + } + }); + } + return this; + } + + /** + * Adds an initial value for the specified {@code *-src} directive. + * Unlike calls to {@link #add(String, String...)}, this disables inheriting from (fetch directive) fallbacks. + * This can be invoked multiple times, and the merged set of values will be used. + * + * @param fetchDirective the directive + * @param values Its initial values. If this is an empty list, will initialize as {@link jenkins.security.csp.Directive#NONE}. + * {@code null} values in the list are ignored. If this is a non-empty list with only {@code null} + * values, this invocation has no effect. + * @return this builder + */ + public CspBuilder initialize(FetchDirective fetchDirective, String... values) { + add(fetchDirective.toKey(), values); + if (directives.containsKey(fetchDirective.toKey())) { + initializedFDs.add(fetchDirective); + } else { + // Handle the special case of values being a non-empty array with only null values + LOGGER.log(Level.CONFIG, "Ignoring initialization call with no-op null values list for " + fetchDirective.toKey()); + } + return this; + } + + /** + * Determine the current effective directives. + * This can be used to inform potential callers of {@link #remove(String, String...)} what to remove. + * + * @return the current effective directives + */ + public List getMergedDirectives() { + List result = new ArrayList<>(); + for (Map.Entry> entry : directives.entrySet()) { + Set effectiveValues = new HashSet<>(); + final String name = entry.getKey(); + + // Calculate inherited values from fallback chain + if (FetchDirective.isFetchDirective(name)) { + FetchDirective current = FetchDirective.fromKey(name); + + // Check if this directive was initialized + boolean wasInitialized = initializedFDs.contains(current); + + // If NOT initialized, traverse fallback chain + if (!wasInitialized) { + FetchDirective fallback = current.getFallback(); + while (fallback != null && !initializedFDs.contains(fallback)) { + fallback = fallback.getFallback(); + } + if (fallback == null) { + // This could happen if nothing was initialized, in that case, fallback is default-src + fallback = FetchDirective.DEFAULT_SRC; + } + // If we found an initialized fallback, inherit its values + if (directives.containsKey(fallback.toKey())) { + effectiveValues.addAll(directives.get(fallback.toKey())); + } + } + + effectiveValues.addAll(entry.getValue()); + result.add(new Directive(name, !wasInitialized, List.copyOf(effectiveValues))); + } else { + // Non-fetch directives don't inherit + effectiveValues.addAll(entry.getValue()); + result.add(new Directive(name, null, List.copyOf(effectiveValues))); + } + } + return List.copyOf(result); + } + + /** + * Build the final CSP string. Any directives with no values left will have the 'none' value set. + * + * @return the CSP string + */ + public String build() { + return buildDirectives().entrySet().stream().map(e -> { + if (e.getValue().isEmpty()) { + return e.getKey() + ";"; + } + return e.getKey() + " " + e.getValue() + ";"; + }).collect(Collectors.joining(" ")); + } + + /** + * Compiles the directives into a map from key (e.g., {@code default-src}) to values (e.g., {@code 'self' 'unsafe-inline'}). + * + * @return a map from directive name to its value for all specified directives. + */ + public Map buildDirectives() { + return getMergedDirectives().stream().sorted(Comparator.comparing(Directive::name)).map(directive -> { + String name = directive.name(); + List values = directive.values().stream().sorted(String::compareTo).toList(); + if (values.isEmpty() && (FetchDirective.isFetchDirective(name) || NONE_DIRECTIVES.contains(name))) { + values = List.of(Directive.NONE); + } + return Map.entry(name, String.join(" ", values)); + }).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a, TreeMap::new)); + } +} diff --git a/core/src/main/java/jenkins/security/csp/CspHeader.java b/core/src/main/java/jenkins/security/csp/CspHeader.java new file mode 100644 index 000000000000..9822499e185d --- /dev/null +++ b/core/src/main/java/jenkins/security/csp/CspHeader.java @@ -0,0 +1,49 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.security.csp; + +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; + +/** + * The possible CSP headers. + * + * @since TODO + */ +@Restricted(Beta.class) +public enum CspHeader { + ContentSecurityPolicy("Content-Security-Policy"), + ContentSecurityPolicyReportOnly("Content-Security-Policy-Report-Only"); + + private final String headerName; + + CspHeader(String headerName) { + this.headerName = headerName; + } + + public String getHeaderName() { + return headerName; + } +} diff --git a/core/src/main/java/jenkins/security/csp/CspHeaderDecider.java b/core/src/main/java/jenkins/security/csp/CspHeaderDecider.java new file mode 100644 index 000000000000..37e5b97dd8b0 --- /dev/null +++ b/core/src/main/java/jenkins/security/csp/CspHeaderDecider.java @@ -0,0 +1,62 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.security.csp; + +import hudson.ExtensionList; +import hudson.ExtensionPoint; +import java.util.Optional; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; + +/** + * Extension point to decide which {@link jenkins.security.csp.CspHeader} will be set. + * The highest priority implementation returning a value will be chosen both to + * show the configuration UI for {@link jenkins.security.csp.impl.CspConfiguration}, + * if any, and to select the header during request processing. + * As a result, implementations must have fairly consistent behavior and not, e.g., + * inspect the current HTTP request to decide between providing a value (any value) + * or not (inspecting the request and deciding which header to choose is fine, + * as long as an implementation always returns a header). + * + * @since TODO + */ +@Restricted(Beta.class) +public interface CspHeaderDecider extends ExtensionPoint { + Optional decide(); + + static ExtensionList all() { + return ExtensionList.lookup(CspHeaderDecider.class); + } + + static Optional getCurrentDecider() { + for (CspHeaderDecider decider : all()) { + final Optional decision = decider.decide(); + if (decision.isPresent()) { + return Optional.of(decider); + } + } + return Optional.empty(); + } +} diff --git a/core/src/main/java/jenkins/security/csp/CspReceiver.java b/core/src/main/java/jenkins/security/csp/CspReceiver.java new file mode 100644 index 000000000000..e582ba635085 --- /dev/null +++ b/core/src/main/java/jenkins/security/csp/CspReceiver.java @@ -0,0 +1,46 @@ +/* + * The MIT License + * + * Copyright (c) 2021-2025 Daniel Beck, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.security.csp; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.ExtensionPoint; +import net.sf.json.JSONObject; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; + +/** + * Extension point for receivers of Content Security Policy reports. + * + * @since TODO + */ +@Restricted(Beta.class) +public interface CspReceiver extends ExtensionPoint { + + void report(@NonNull ViewContext viewContext, @CheckForNull String userId, @NonNull JSONObject report); + + record ViewContext(String className, String viewName) { + } +} diff --git a/core/src/main/java/jenkins/security/csp/Directive.java b/core/src/main/java/jenkins/security/csp/Directive.java new file mode 100644 index 000000000000..cbd5a3552724 --- /dev/null +++ b/core/src/main/java/jenkins/security/csp/Directive.java @@ -0,0 +1,126 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.security.csp; + +import java.util.List; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * Represents a defined Content Security Policy directive + * @param name {@code default-src}, {@code frame-ancestors}, etc. + * @param inheriting whether the directive is inheriting or not. Only applies to + * directives based on {@link jenkins.security.csp.FetchDirective}. + * @param values {@code 'self'}, {@code data:}, {@code jenkins.io}, etc. + * + * @since TODO + */ +@Restricted(Beta.class) +public record Directive(String name, Boolean inheriting, List values) { + + /* Fetch directives */ + public static final String DEFAULT_SRC = "default-src"; + public static final String CHILD_SRC = "child-src"; + public static final String CONNECT_SRC = "connect-src"; + public static final String FONT_SRC = "font-src"; + public static final String FRAME_SRC = "frame-src"; + public static final String IMG_SRC = "img-src"; + public static final String MANIFEST_SRC = "manifest-src"; + public static final String MEDIA_SRC = "media-src"; + public static final String OBJECT_SRC = "object-src"; + public static final String PREFETCH_SRC = "prefetch-src"; + public static final String SCRIPT_SRC = "script-src"; + public static final String SCRIPT_SRC_ELEM = "script-src-elem"; + public static final String SCRIPT_SRC_ATTR = "script-src-attr"; + public static final String STYLE_SRC = "style-src"; + public static final String STYLE_SRC_ELEM = "style-src-elem"; + public static final String STYLE_SRC_ATTR = "style-src-attr"; + public static final String WORKER_SRC = "worker-src"; + /* Fetch directives end */ + + + /* Other directives */ + public static final String BASE_URI = "base-uri"; + + /** + * Deprecated directive. + * + * @see MDN + * @deprecated by CSP spec + */ + @Deprecated + public static final String BLOCK_ALL_MIXED_CONTENT = "block-all-mixed-content"; + public static final String FORM_ACTION = "form-action"; + public static final String FRAME_ANCESTORS = "frame-ancestors"; + + /** + * Unsupported for use in plugins. + * + * @see CspBuilder#PROHIBITED_KEYS + */ + @Restricted(NoExternalUse.class) + public static final String REPORT_TO = "report-to"; + + /** + * Deprecated directive. Intended to be replaced by {@link #REPORT_TO}. Unsupported for use in plugins. + * + * @see MDN + * @see CspBuilder#PROHIBITED_KEYS + * @deprecated by CSP spec + */ + @Restricted(NoExternalUse.class) + @Deprecated + public static final String REPORT_URI = "report-uri"; + public static final String REQUIRE_TRUSTED_TYPES_FOR = "require-trusted-types-for"; + public static final String SANDBOX = "sandbox"; + public static final String TRUSTED_TYPES = "trusted-types"; + public static final String UPGRADE_INSECURE_REQUESTS = "upgrade-insecure-requests"; + /* Other directives end */ + + + /* Values */ + public static final String SELF = "'self'"; + /** + * Disallow all. + * Note that this is not a valid argument for {@link CspBuilder#add(String, String...)}. + * To initialize a previously undefined fetch directive, call {@link CspBuilder#initialize(FetchDirective, String...)} and pass no values. + * To remove all other values, call {@link CspBuilder#remove(String, String...)}. + */ + public static final String NONE = "'none'"; + public static final String UNSAFE_INLINE = "'unsafe-inline'"; + + /** + * Probably should not be used. + * + * @deprecated This should not be used. + */ + @Deprecated // Indicator for discouraged use. + public static final String UNSAFE_EVAL = "'unsafe-eval'"; + public static final String DATA = "data:"; + public static final String BLOB = "blob:"; + public static final String REPORT_SAMPLE = "'report-sample'"; + /* Values end */ +} diff --git a/core/src/main/java/jenkins/security/csp/FetchDirective.java b/core/src/main/java/jenkins/security/csp/FetchDirective.java new file mode 100644 index 000000000000..9035772b9415 --- /dev/null +++ b/core/src/main/java/jenkins/security/csp/FetchDirective.java @@ -0,0 +1,135 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.security.csp; + +import java.util.Optional; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; + +/** + * The fetch directives and their inheritance rules (in {@link #getFallback()}). + * + * @see MDN + * @since TODO + */ +@Restricted(Beta.class) +public enum FetchDirective { + DEFAULT_SRC(Directive.DEFAULT_SRC), + CHILD_SRC(Directive.CHILD_SRC), + CONNECT_SRC(Directive.CONNECT_SRC), + FONT_SRC(Directive.FONT_SRC), + FRAME_SRC(Directive.FRAME_SRC), + IMG_SRC(Directive.IMG_SRC), + MANIFEST_SRC(Directive.MANIFEST_SRC), + MEDIA_SRC(Directive.MEDIA_SRC), + OBJECT_SRC(Directive.OBJECT_SRC), + PREFETCH_SRC(Directive.PREFETCH_SRC), + SCRIPT_SRC(Directive.SCRIPT_SRC), + SCRIPT_SRC_ELEM(Directive.SCRIPT_SRC_ELEM), + SCRIPT_SRC_ATTR(Directive.SCRIPT_SRC_ATTR), + STYLE_SRC(Directive.STYLE_SRC), + STYLE_SRC_ELEM(Directive.STYLE_SRC_ELEM), + STYLE_SRC_ATTR(Directive.STYLE_SRC_ATTR), + WORKER_SRC(Directive.WORKER_SRC); + + private final String key; + + FetchDirective(String s) { + this.key = s; + } + + public String toKey() { + return key; + } + + /** + * Returns the {@link jenkins.security.csp.FetchDirective} corresponding to + * the specified key. For example, the parameter {@code default-src} will + * return {@link #DEFAULT_SRC}. + * + * @param s the key for the directive + * @return the {@link jenkins.security.csp.FetchDirective} corresponding to the key + */ + public static FetchDirective fromKey(String s) { + for (FetchDirective e : FetchDirective.values()) { + if (e.key.equals(s)) { + return e; + } + } + throw new IllegalArgumentException("Key not found: " + s); + } + + /** + * Returns true if and only if the specified key is a {@link jenkins.security.csp.FetchDirective}. + * Returns {@code true} for {@code script-src}, {@code false} for {@code sandbox}. + */ + public static boolean isFetchDirective(String key) { + return toFetchDirective(key).isPresent(); + } + + /** + * Similar to {@link #fromKey(String)}, this returns the corresponding + * {@link jenkins.security.csp.FetchDirective} wrapped in {@link java.util.Optional}. + * If the specified key does not correspond to a fetch directive, instead leaves the Optional empty. + * + * @param key the key for the directive + * @return an {@link java.util.Optional} containing the corresponding {@link jenkins.security.csp.FetchDirective}, or left empty if there is none. + */ + public static Optional toFetchDirective(String key) { + try { + return Optional.of(fromKey(key)); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } + + /** + * Which element is used as fallback if one is undefined. + * For {@code *-src-elem} and {@code *-src-attr} this is the corresponding + * {@code *-src}, for {@code frame-src} and {@code worker-src} this is + * {@code child-src}, for everything else, except {@code default-src}, it's + * {@code default-src}. + * + * @see MDN + * + * @return The fallback directive if this one is unspecified, or {@code null} + * if there is no fallback. + */ + public FetchDirective getFallback() { + if (this == SCRIPT_SRC_ATTR || this == SCRIPT_SRC_ELEM) { + return SCRIPT_SRC; + } + if (this == STYLE_SRC_ATTR || this == STYLE_SRC_ELEM) { + return STYLE_SRC; + } + if (this == FRAME_SRC || this == WORKER_SRC) { + return CHILD_SRC; + } + if (this != DEFAULT_SRC) { + return DEFAULT_SRC; + } + return null; + } +} diff --git a/core/src/main/java/jenkins/security/csp/ReportingContext.java b/core/src/main/java/jenkins/security/csp/ReportingContext.java new file mode 100644 index 000000000000..e43ce918205c --- /dev/null +++ b/core/src/main/java/jenkins/security/csp/ReportingContext.java @@ -0,0 +1,102 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.security.csp; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Util; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import jenkins.security.HMACConfidentialKey; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; +import org.springframework.security.core.Authentication; + +/** + * The context (user and page) from which a CSP report is received. + * + * @since TODO + */ +@Restricted(Beta.class) +public class ReportingContext { + private static final HMACConfidentialKey KEY = new HMACConfidentialKey(ReportingContext.class, "key"); + + private ReportingContext() {} + + private static String toBase64(String utf8) { + return Base64.getUrlEncoder().encodeToString(utf8.getBytes(StandardCharsets.UTF_8)); + } + + private static String fromBase64(String b64) { + return new String(Base64.getUrlDecoder().decode(b64), StandardCharsets.UTF_8); + } + + public static String encodeContext( + @CheckForNull final Class ancestorClass, + @CheckForNull final Authentication authentication, + @NonNull final String restOfPath) { + final String userId = authentication == null ? "" : authentication.getName(); + final String encodedContext = + toBase64(userId) + ":" + toBase64(ancestorClass == null ? "" : ancestorClass.getName()) + ":" + toBase64(restOfPath); + final String mac = + Base64.getUrlEncoder().encodeToString(KEY.mac(encodedContext.getBytes(StandardCharsets.UTF_8))); + return mac + ":" + encodedContext; + } + + public static DecodedContext decodeContext(final String rawContext) { + String[] macAndContext = rawContext.split(":", 2); + if (macAndContext.length != 2) { + throw new IllegalArgumentException( + "Unexpected number of split entries, expected 2, got " + macAndContext.length); + } + String mac = macAndContext[0]; + String encodedContext = macAndContext[1]; + + if (!KEY.checkMac( + encodedContext.getBytes(StandardCharsets.UTF_8), + Base64.getUrlDecoder().decode(mac))) { + throw new IllegalArgumentException("Mac check failed for " + encodedContext); + } + + String[] encodedContextParts = encodedContext.split(":", 3); + if (encodedContextParts.length != 3) { + throw new IllegalArgumentException( + "Unexpected number of split entries, expected 3, got " + encodedContextParts.length); + } + return new DecodedContext( + fromBase64(encodedContextParts[0]), + fromBase64(encodedContextParts[1]), + fromBase64(encodedContextParts[2])); + } + + public record DecodedContext(String userId, String contextClassName, String restOfPath) { + public DecodedContext( + @CheckForNull String userId, @NonNull String contextClassName, @NonNull String restOfPath) { + this.userId = Util.fixEmpty(userId); + this.contextClassName = contextClassName; + this.restOfPath = restOfPath; + } + } +} diff --git a/core/src/main/java/jenkins/security/csp/SimpleContributor.java b/core/src/main/java/jenkins/security/csp/SimpleContributor.java new file mode 100644 index 000000000000..a81e1ca40a41 --- /dev/null +++ b/core/src/main/java/jenkins/security/csp/SimpleContributor.java @@ -0,0 +1,51 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.security.csp; + +import java.util.ArrayList; +import java.util.List; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; + +/** + * Convenient base class for CSP contributors only adding individual domains to a fetch directive. + * Plugins may need to do this, likely for {@code img-src}, to allow loading avatars and similar resources. + * + * @since TODO + */ +@Restricted(Beta.class) +public abstract class SimpleContributor implements Contributor { + private final List allowlist = new ArrayList<>(); + + protected void allow(FetchDirective directive, String... domain) { + // Inheritance is unused, so doesn't matter despite being a FetchDirective + this.allowlist.add(new Directive(directive.toKey(), null, List.of(domain))); + } + + @Override + public final void apply(CspBuilder cspBuilder) { + allowlist.forEach(entry -> cspBuilder.add(entry.name(), entry.values().toArray(new String[0]))); + } +} diff --git a/core/src/main/java/jenkins/security/csp/impl/BaseContributor.java b/core/src/main/java/jenkins/security/csp/impl/BaseContributor.java new file mode 100644 index 000000000000..100f6f68690d --- /dev/null +++ b/core/src/main/java/jenkins/security/csp/impl/BaseContributor.java @@ -0,0 +1,54 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.security.csp.impl; + +import static jenkins.security.csp.Directive.REPORT_SAMPLE; +import static jenkins.security.csp.Directive.SELF; + +import hudson.Extension; +import jenkins.security.csp.Contributor; +import jenkins.security.csp.CspBuilder; +import jenkins.security.csp.Directive; +import jenkins.security.csp.FetchDirective; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * Add a minimal set of CSP directives not expected to be removed later. + */ +@Extension(ordinal = Integer.MAX_VALUE) +@Restricted(NoExternalUse.class) +public final class BaseContributor implements Contributor { + @Override + public void apply(CspBuilder cspBuilder) { + cspBuilder + .initialize(FetchDirective.DEFAULT_SRC, SELF) + .add(Directive.STYLE_SRC, REPORT_SAMPLE) + .add(Directive.SCRIPT_SRC, REPORT_SAMPLE) + .add(Directive.FORM_ACTION, SELF) + .add(Directive.BASE_URI) // 'none' + .add(Directive.FRAME_ANCESTORS, SELF); + } +} diff --git a/core/src/main/java/jenkins/security/csp/impl/CompatibleContributor.java b/core/src/main/java/jenkins/security/csp/impl/CompatibleContributor.java new file mode 100644 index 000000000000..87d1c79d1924 --- /dev/null +++ b/core/src/main/java/jenkins/security/csp/impl/CompatibleContributor.java @@ -0,0 +1,60 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.security.csp.impl; + +import static jenkins.security.csp.Directive.DATA; +import static jenkins.security.csp.Directive.IMG_SRC; +import static jenkins.security.csp.Directive.SCRIPT_SRC; +import static jenkins.security.csp.Directive.STYLE_SRC; +import static jenkins.security.csp.Directive.UNSAFE_INLINE; + +import hudson.Extension; +import hudson.model.UsageStatistics; +import jenkins.security.csp.Contributor; +import jenkins.security.csp.CspBuilder; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * Add the CSP directives currently required for Jenkins to work properly. + * The long-term goal is for these directives to become unnecessary. + * Any for which we determine with high confidence they can never be fixed + * should be moved into {@link BaseContributor} instead. + */ +@Extension(ordinal = Integer.MAX_VALUE / 2.0) +@Restricted(NoExternalUse.class) +public final class CompatibleContributor implements Contributor { + @Override + public void apply(CspBuilder cspBuilder) { + cspBuilder + .add(STYLE_SRC, UNSAFE_INLINE) // Infeasible for now given inline styles in SVGs + .add(IMG_SRC, DATA); // TODO https://github.com/jenkinsci/csp-plugin/issues/60 + if (UsageStatistics.isEnabled()) { + // For transparency, always do this while usage stats submission is enabled, rather than checking #isDue. + // TODO https://issues.jenkins.io/browse/JENKINS-76268 + cspBuilder.add(SCRIPT_SRC, "usage.jenkins.io"); + } + } +} diff --git a/core/src/main/java/jenkins/security/csp/impl/CspConfiguration.java b/core/src/main/java/jenkins/security/csp/impl/CspConfiguration.java new file mode 100644 index 000000000000..3725a4c4966a --- /dev/null +++ b/core/src/main/java/jenkins/security/csp/impl/CspConfiguration.java @@ -0,0 +1,159 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.security.csp.impl; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.DescriptorExtensionList; +import hudson.Extension; +import hudson.ExtensionList; +import hudson.XmlFile; +import hudson.model.PersistentDescriptor; +import hudson.util.DescribableList; +import hudson.util.FormValidation; +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import jenkins.model.GlobalConfiguration; +import jenkins.model.GlobalConfigurationCategory; +import jenkins.model.Jenkins; +import jenkins.security.ResourceDomainConfiguration; +import jenkins.security.csp.AdvancedConfiguration; +import jenkins.security.csp.AdvancedConfigurationDescriptor; +import jenkins.security.csp.CspHeader; +import jenkins.security.csp.CspHeaderDecider; +import org.jenkinsci.Symbol; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.DoNotUse; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.DataBoundSetter; +import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.verb.POST; + +@Extension +@Symbol("contentSecurityPolicy") +@Restricted(NoExternalUse.class) +public class CspConfiguration extends GlobalConfiguration implements PersistentDescriptor { + + /** + * Package-private to allow setting by {@link CspRecommendation} without saving. + * Also exposes the "unconfigured" state as {@code null}, unlike the public getter used by Jelly. + */ + protected Boolean enforce; + + private final DescribableList advanced = new DescribableList<>(this); + + @NonNull + @Override + public GlobalConfigurationCategory getCategory() { + return GlobalConfigurationCategory.get(GlobalConfigurationCategory.Security.class); + } + + public boolean isEnforce() { + return enforce != null && enforce; + } + + @DataBoundSetter + public void setEnforce(boolean enforce) { + this.enforce = enforce; + save(); + } + + // Make available to test in same package + @Override + protected XmlFile getConfigFile() { + return super.getConfigFile(); + } + + @Restricted(DoNotUse.class) // Jelly + public boolean isShowHeaderConfiguration() { + final Optional currentDecider = CspHeaderDecider.getCurrentDecider(); + return currentDecider.filter(cspHeaderDecider -> cspHeaderDecider instanceof ConfigurationHeaderDecider).isPresent(); + } + + @Restricted(DoNotUse.class) // Jelly + public CspHeaderDecider getCurrentDecider() { + return CspHeaderDecider.getCurrentDecider().orElse(null); + } + + @POST + public FormValidation doCheckEnforce(@QueryParameter boolean enforce) { + if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) { + return FormValidation.ok(); + } + if (!getConfigFile().exists()) { + // The configuration has not been saved yet + if (enforce) { + if (ResourceDomainConfiguration.isResourceDomainConfigured()) { + return FormValidation.ok(Messages.CspConfiguration_UndefinedToTrueWithResourceDomain()); + } + return FormValidation.okWithMarkup(Messages.CspConfiguration_UndefinedToTrueWithoutResourceDomain(Jenkins.get().getRootUrlFromRequest())); + } + // Warning because we're here just after the admin confirmed they wanted to set it up. + return FormValidation.warning(Messages.CspConfiguration_UndefinedToFalse()); + } + if (enforce && !ResourceDomainConfiguration.isResourceDomainConfigured()) { + if (ExtensionList.lookupSingleton(CspConfiguration.class).isEnforce()) { + return FormValidation.warningWithMarkup(Messages.CspConfiguration_TrueToTrueWithoutResourceDomain(Jenkins.get().getRootUrlFromRequest())); + } + return FormValidation.okWithMarkup(Messages.CspConfiguration_FalseToTrueWithoutResourceDomain(Jenkins.get().getRootUrlFromRequest())); + } + return FormValidation.ok(); + } + + public List getAdvanced() { + return advanced; + } + + @DataBoundSetter + public void setAdvanced(List advanced) throws IOException { + this.advanced.replaceBy(advanced); + save(); + } + + /** + * For Jelly + * */ + public DescriptorExtensionList getAdvancedDescriptors() { + return AdvancedConfiguration.all(); + } + + @Extension + public static class ConfigurationHeaderDecider implements CspHeaderDecider { + + @Override + public Optional decide() { + Boolean enforce = ExtensionList.lookupSingleton(CspConfiguration.class).enforce; + + if (enforce == null) { + // If no configuration is present, use FallbackDecider to show initial UI + return Optional.empty(); + } + if (enforce) { + return Optional.of(CspHeader.ContentSecurityPolicy); + } + return Optional.of(CspHeader.ContentSecurityPolicyReportOnly); + } + } +} diff --git a/core/src/main/java/jenkins/security/csp/impl/CspDecorator.java b/core/src/main/java/jenkins/security/csp/impl/CspDecorator.java new file mode 100644 index 000000000000..1b4f7522a2da --- /dev/null +++ b/core/src/main/java/jenkins/security/csp/impl/CspDecorator.java @@ -0,0 +1,127 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.security.csp.impl; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import hudson.Extension; +import hudson.model.PageDecorator; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; +import jenkins.model.Jenkins; +import jenkins.security.csp.CspBuilder; +import jenkins.security.csp.CspHeader; +import jenkins.security.csp.CspHeaderDecider; +import jenkins.security.csp.ReportingContext; +import org.apache.commons.lang.StringUtils; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.Ancestor; +import org.kohsuke.stapler.Stapler; +import org.kohsuke.stapler.StaplerRequest2; + +@Restricted(NoExternalUse.class) +@Extension +public class CspDecorator extends PageDecorator { + + private static final String REPORTING_ENDPOINT_NAME = "content-security-policy"; + private static final Logger LOGGER = Logger.getLogger(CspDecorator.class.getName()); + + public String getContentSecurityPolicyHeaderValue(HttpServletRequest req) { + String cspDirectives = new CspBuilder().withDefaultContributions().build(); + + final String reportingEndpoint = getReportingEndpoint(req); + if (reportingEndpoint != null) { + cspDirectives += " report-to " + REPORTING_ENDPOINT_NAME + "; report-uri " + reportingEndpoint; + } + return cspDirectives; + } + + // Also used in httpHeaders.jelly + @CheckForNull + public String getReportingEndpointsHeaderValue(HttpServletRequest req) { + final String reportingEndpoint = getReportingEndpoint(req); + if (reportingEndpoint == null) { + return null; + } + return REPORTING_ENDPOINT_NAME + ": " + reportingEndpoint; + } + + @CheckForNull + /* package */ static String getReportingEndpoint(HttpServletRequest req) { + Class modelObjectClass = null; + String restOfPath = StringUtils.removeStart(req.getRequestURI(), req.getContextPath()); + final StaplerRequest2 staplerRequest2 = Stapler.getCurrentRequest2(); + if (staplerRequest2 != null) { + final List ancestors = staplerRequest2.getAncestors(); + if (!ancestors.isEmpty()) { + final Ancestor nearest = ancestors.get(ancestors.size() - 1); + restOfPath = nearest.getRestOfUrl(); + modelObjectClass = nearest.getObject().getClass(); + } + } + + String rootUrl; + try { + rootUrl = Jenkins.get().getRootUrlFromRequest(); + } catch (IllegalStateException e) { + LOGGER.log(Level.FINEST, "Cannot get root URL from request", e); + // Outside a Stapler request, probably in the CspFilter, so get configured root URL + try { + rootUrl = Jenkins.get().getRootUrl(); + } catch (IllegalStateException ise) { + LOGGER.log(Level.FINEST, "Cannot get root URL from configuration", ise); + return null; + } + } + if (rootUrl == null) { + return null; + } + return rootUrl + ReportingAction.URL + "/" + ReportingContext.encodeContext(modelObjectClass, Jenkins.getAuthentication2(), restOfPath); + } + + /** + * Determines the name of the HTTP header to set. + * + * @return the name of the HTTP header to set. + */ + public String getContentSecurityPolicyHeaderName() { + final Optional decider = CspHeaderDecider.getCurrentDecider(); + if (decider.isPresent()) { + final CspHeaderDecider presentDecider = decider.get(); + LOGGER.log(Level.FINEST, "Choosing header from decider " + presentDecider.getClass().getName()); + final Optional decision = presentDecider.decide(); + if (decision.isPresent()) { + return decision.get().getHeaderName(); + } + LOGGER.log(Level.FINE, "Decider changed its mind after selection: " + presentDecider.getClass().getName()); + } + + LOGGER.log(Level.WARNING, "Failed to find a CspHeaderDecider, falling back to default"); + return CspHeader.ContentSecurityPolicyReportOnly.getHeaderName(); + } +} diff --git a/core/src/main/java/jenkins/security/csp/impl/CspFilter.java b/core/src/main/java/jenkins/security/csp/impl/CspFilter.java new file mode 100644 index 000000000000..4a832be2985f --- /dev/null +++ b/core/src/main/java/jenkins/security/csp/impl/CspFilter.java @@ -0,0 +1,104 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.security.csp.impl; + +import hudson.ExtensionList; +import hudson.Functions; +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.logging.Level; +import java.util.logging.Logger; +import jenkins.security.ResourceDomainConfiguration; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +@Restricted(NoExternalUse.class) +public class CspFilter implements Filter { + + private static final Logger LOGGER = Logger.getLogger(CspFilter.class.getName()); + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + if (!(request instanceof HttpServletRequest req) || !(response instanceof HttpServletResponse rsp)) { + chain.doFilter(request, response); + return; + } + + if (!Functions.isExtensionsAvailable()) { + // TODO Implement CSP protection while extensions are not available + LOGGER.log(Level.FINER, "Extensions are not available, so skipping CSP enforcement for: " + req.getRequestURI()); + chain.doFilter(request, response); + return; + } + + CspDecorator cspDecorator = ExtensionList.lookupSingleton(CspDecorator.class); + final String headerName = cspDecorator.getContentSecurityPolicyHeaderName(); + + // This is the preliminary value outside Stapler request handling (and providing a context object) + final String headerValue = cspDecorator.getContentSecurityPolicyHeaderValue(req); + + final boolean isResourceRequest = ResourceDomainConfiguration.isResourceRequest(req); + if (!isResourceRequest) { + // The Filter/Decorator approach needs us to "set" headers rather than "add", so no additional endpoints are supported at the moment. + final String reportingEndpoints = cspDecorator.getReportingEndpointsHeaderValue(req); + if (reportingEndpoints != null) { + rsp.setHeader("Reporting-Endpoints", reportingEndpoints); + } + + rsp.setHeader(headerName, headerValue); + } + try { + chain.doFilter(req, rsp); + } finally { + try { + final String actualHeader = rsp.getHeader(headerName); + if (!isResourceRequest && hasUnexpectedDifference(headerValue, actualHeader)) { + LOGGER.log(Level.FINE, "CSP header has unexpected differences: Expected '" + headerValue + "' but got '" + actualHeader + "'"); + } + } catch (RuntimeException e) { + // Be defensive just in case + LOGGER.log(Level.FINER, "Error checking CSP header after request processing", e); + } + } + } + + private static boolean hasUnexpectedDifference(String headerByFilter, String actualHeader) { + if (actualHeader == null) { + return true; + } + String expectedPrefix = headerByFilter.substring(0, headerByFilter.indexOf(" report-uri ")); // cf. CspDecorator + if (!actualHeader.contains(" report-uri ")) { + return true; + } + String actualPrefix = actualHeader.substring(0, actualHeader.indexOf(" report-uri ")); + return !expectedPrefix.equals(actualPrefix); + } +} diff --git a/core/src/main/java/jenkins/security/csp/impl/CspRecommendation.java b/core/src/main/java/jenkins/security/csp/impl/CspRecommendation.java new file mode 100644 index 000000000000..0d95f0ded86f --- /dev/null +++ b/core/src/main/java/jenkins/security/csp/impl/CspRecommendation.java @@ -0,0 +1,84 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.security.csp.impl; + +import hudson.Extension; +import hudson.ExtensionList; +import hudson.model.AdministrativeMonitor; +import java.io.IOException; +import jenkins.security.csp.CspHeaderDecider; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.HttpResponses; +import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.verb.POST; + +/** + * This administrative monitor recommends that admins set up CSP. + * It is shown when no higher priority {@link jenkins.security.csp.CspHeaderDecider} + * determines the header, but {@link CspConfiguration#enforce} + * is {@code null}, indicating admins didn't configure it yet. + */ +@Restricted(NoExternalUse.class) +@Extension +public class CspRecommendation extends AdministrativeMonitor { + + @Override + public String getDisplayName() { + return Messages.CspRecommendation_DisplayName(); + } + + @Override + public boolean isActivated() { + if (ExtensionList.lookupSingleton(CspConfiguration.class).enforce != null) { + // already being configured + return false; + } + // If the current decider is the fallback one that advertises setting up configuration, this should show + return CspHeaderDecider.getCurrentDecider().filter(d -> d instanceof FallbackDecider).isPresent(); + } + + @Override + public boolean isSecurity() { + return true; + } + + @POST + public void doAct(@QueryParameter String setup, @QueryParameter String more, @QueryParameter String dismiss, @QueryParameter String defer) throws IOException { + if (more != null) { + throw HttpResponses.redirectViaContextPath("manage/administrativeMonitor/jenkins.security.csp.impl.CspRecommendation"); + } + if (setup != null) { + // Go through field to not call #save in case the admin abandons configuration + ExtensionList.lookupSingleton(CspConfiguration.class).enforce = false; + throw HttpResponses.redirectViaContextPath("manage/configureSecurity/#contentSecurityPolicy"); + } + if (dismiss != null) { + disable(true); + } + // Since we no longer show admins monitors just everywhere, we can explicitly navigate here for the monitor and take care of the index view at the same time + throw HttpResponses.redirectViaContextPath("manage/"); + } +} diff --git a/core/src/main/java/jenkins/security/csp/impl/DevelopmentHeaderDecider.java b/core/src/main/java/jenkins/security/csp/impl/DevelopmentHeaderDecider.java new file mode 100644 index 000000000000..f212d21acd52 --- /dev/null +++ b/core/src/main/java/jenkins/security/csp/impl/DevelopmentHeaderDecider.java @@ -0,0 +1,60 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.security.csp.impl; + +import hudson.Extension; +import hudson.Main; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; +import jenkins.security.csp.CspHeader; +import jenkins.security.csp.CspHeaderDecider; +import jenkins.util.SystemProperties; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * During development and tests, use {@link jenkins.security.csp.CspHeader#ContentSecurityPolicy}. + */ +@Restricted(NoExternalUse.class) +@Extension(ordinal = Double.MAX_VALUE / 2) // High precedence +public class DevelopmentHeaderDecider implements CspHeaderDecider { + private static final Logger LOGGER = Logger.getLogger(DevelopmentHeaderDecider.class.getName()); + + static /* non-final for script console */ boolean DISABLED = SystemProperties.getBoolean(DevelopmentHeaderDecider.class.getName() + ".DISABLED"); + + @Override + public Optional decide() { + if (DISABLED) { + LOGGER.log(Level.FINEST, "DevelopmentHeaderDecider disabled by system property"); + return Optional.empty(); + } + LOGGER.log(Level.FINEST, "Main.isUnitTest: {0}, Main.isDevelopmentMode: {1}", new Object[] { Main.isUnitTest, Main.isDevelopmentMode }); + if (Main.isUnitTest || Main.isDevelopmentMode) { + return Optional.of(CspHeader.ContentSecurityPolicy); + } + return Optional.empty(); + } +} diff --git a/core/src/main/java/jenkins/security/csp/impl/FallbackDecider.java b/core/src/main/java/jenkins/security/csp/impl/FallbackDecider.java new file mode 100644 index 000000000000..e179f91b96b2 --- /dev/null +++ b/core/src/main/java/jenkins/security/csp/impl/FallbackDecider.java @@ -0,0 +1,45 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.security.csp.impl; + +import hudson.Extension; +import java.util.Optional; +import jenkins.security.csp.CspHeader; +import jenkins.security.csp.CspHeaderDecider; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * Fallback implementation: Choose {@link jenkins.security.csp.CspHeader#ContentSecurityPolicyReportOnly}. + * The UI for this lets the user enable {@link CspConfiguration} via {@link CspRecommendation}. + */ +@Restricted(NoExternalUse.class) +@Extension(ordinal = -100000) +public class FallbackDecider implements CspHeaderDecider { + @Override + public Optional decide() { + return Optional.of(CspHeader.ContentSecurityPolicyReportOnly); + } +} diff --git a/core/src/main/java/jenkins/security/csp/impl/LoggingReceiver.java b/core/src/main/java/jenkins/security/csp/impl/LoggingReceiver.java new file mode 100644 index 000000000000..1652718f6eaa --- /dev/null +++ b/core/src/main/java/jenkins/security/csp/impl/LoggingReceiver.java @@ -0,0 +1,52 @@ +/* + * The MIT License + * + * Copyright (c) 2025 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.security.csp.impl; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import java.util.logging.Level; +import java.util.logging.Logger; +import jenkins.security.csp.CspReceiver; +import net.sf.json.JSONObject; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * Basic {@link jenkins.security.csp.CspReceiver} that just logs received reports. + */ +@Restricted(NoExternalUse.class) +@Extension +public class LoggingReceiver implements CspReceiver { + private static final Logger LOGGER = Logger.getLogger(jenkins.security.csp.impl.LoggingReceiver.class.getName()); + + @Override + public void report(@NonNull ViewContext viewContext, String userId, @NonNull JSONObject report) { + if (userId == null) { + LOGGER.log(Level.FINEST, "Received anonymous report for context {0}: {1}", new Object[]{viewContext, report}); + } else { + LOGGER.log(Level.FINE, "Received report from {0} for context {1}: {2}", new Object[]{userId, viewContext, report}); + } + } +} diff --git a/core/src/main/java/jenkins/security/csp/impl/ReportingAction.java b/core/src/main/java/jenkins/security/csp/impl/ReportingAction.java new file mode 100644 index 000000000000..fedb06aad1f5 --- /dev/null +++ b/core/src/main/java/jenkins/security/csp/impl/ReportingAction.java @@ -0,0 +1,135 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.security.csp.impl; + +import hudson.Extension; +import hudson.ExtensionList; +import hudson.model.InvisibleAction; +import hudson.model.UnprotectedRootAction; +import hudson.model.User; +import hudson.security.csrf.CrumbExclusion; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.InputStream; +import java.util.logging.Level; +import java.util.logging.Logger; +import jenkins.security.csp.CspReceiver; +import jenkins.security.csp.ReportingContext; +import net.sf.json.JSONException; +import net.sf.json.JSONObject; +import org.apache.commons.io.IOUtils; +import org.apache.commons.io.input.BoundedInputStream; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.HttpResponse; +import org.kohsuke.stapler.HttpResponses; +import org.kohsuke.stapler.StaplerRequest2; +import org.kohsuke.stapler.verb.POST; + +/** + * This action receives reports of Content-Security-Policy violations. + * It needs to be an {@link hudson.model.UnprotectedRootAction} because these requests do not have cookies. + * If we wanted to restrict submissions by unprivileged users, we'd not generate the Content-Security-Policy header + * for them, or removed the report-uri / report-to directives. + */ +@Restricted(NoExternalUse.class) +@Extension +public class ReportingAction extends InvisibleAction implements UnprotectedRootAction { + public static final String URL = "content-security-policy-reporting-endpoint"; + private static final Logger LOGGER = Logger.getLogger(ReportingAction.class.getName()); + + // In limited testing, reports seem to be a few hundred bytes (mostly the actual policy), so this seems plenty. + // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP#violation_reporting for an example. + private static /* non-final for script console */ int MAX_REPORT_LENGTH = 20 * 1024; + + @Override + public String getUrlName() { + return URL; + } + + @POST + public HttpResponse doDynamic(StaplerRequest2 req) { + final String requestRestOfPath = req.getRestOfPath(); + String restOfPath = requestRestOfPath.startsWith("/") ? requestRestOfPath.substring(1) : requestRestOfPath; + + try { + final ReportingContext.DecodedContext context = ReportingContext.decodeContext(restOfPath); + + CspReceiver.ViewContext viewContext = + new CspReceiver.ViewContext(context.contextClassName(), context.restOfPath()); + final boolean[] maxReached = new boolean[1]; + try (InputStream is = req.getInputStream(); BoundedInputStream bis = BoundedInputStream.builder().setMaxCount(MAX_REPORT_LENGTH).setOnMaxCount((x, y) -> maxReached[0] = true).setInputStream(is).get()) { + String report = IOUtils.toString(bis, req.getCharacterEncoding()); + if (maxReached[0]) { + LOGGER.log(Level.FINE, () -> "Report for " + viewContext + " exceeded max length of " + MAX_REPORT_LENGTH); + return HttpResponses.ok(); + } + LOGGER.log(Level.FINEST, () -> "Report for " + viewContext + " length: " + report.length()); + LOGGER.log(Level.FINER, () -> viewContext + " " + report); + JSONObject jsonObject; + try { + jsonObject = JSONObject.fromObject(report); + } catch (JSONException ex) { + LOGGER.log(Level.FINE, ex, () -> "Failed to parse JSON report for " + viewContext + ": " + report); + return HttpResponses.ok(); + } + + User user = context.userId() != null ? User.getById(context.userId(), false) : null; + + for (CspReceiver receiver : + ExtensionList.lookup(CspReceiver.class)) { + try { + receiver.report(viewContext, user == null ? null : context.userId(), jsonObject); + } catch (Exception ex) { + LOGGER.log(Level.WARNING, ex, () -> "Error reporting CSP for " + viewContext + " to " + receiver); + } + } + } catch (IOException e) { + LOGGER.log(Level.FINE, e, () -> "Failed to read request body for " + viewContext); + } + return HttpResponses.ok(); + } catch (RuntimeException ex) { + LOGGER.log(Level.FINE, ex, () -> "Unexpected rest of path failed to decode: " + restOfPath); + return HttpResponses.ok(); + } + } + + @Extension + public static class CrumbExclusionImpl extends CrumbExclusion { + @Override + public boolean process(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws IOException, ServletException { + String pathInfo = request.getPathInfo(); + if (pathInfo != null && pathInfo.startsWith("/" + URL + "/")) { + chain.doFilter(request, response); + return true; + } + return false; + } + } +} diff --git a/core/src/main/java/jenkins/security/csp/impl/SystemPropertyHeaderDecider.java b/core/src/main/java/jenkins/security/csp/impl/SystemPropertyHeaderDecider.java new file mode 100644 index 000000000000..02eb8994fdcd --- /dev/null +++ b/core/src/main/java/jenkins/security/csp/impl/SystemPropertyHeaderDecider.java @@ -0,0 +1,63 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.security.csp.impl; + +import hudson.Extension; +import java.util.Arrays; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; +import jenkins.security.csp.CspHeader; +import jenkins.security.csp.CspHeaderDecider; +import jenkins.util.SystemProperties; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * Specify the {@link jenkins.security.csp.CspHeader} for {@link CspDecorator} via system property. + */ +@Restricted(NoExternalUse.class) +@Extension(ordinal = Double.MAX_VALUE) // Highest precedence +public class SystemPropertyHeaderDecider implements CspHeaderDecider { + + private static final Logger LOGGER = Logger.getLogger(SystemPropertyHeaderDecider.class.getName()); + static final String SYSTEM_PROPERTY_NAME = CspHeader.class.getName() + ".headerName"; + + @Override + public Optional decide() { + final String systemProperty = SystemProperties.getString(SYSTEM_PROPERTY_NAME); + if (systemProperty != null) { + LOGGER.log(Level.FINEST, "Using system property: {0}", new Object[]{ systemProperty }); + return Arrays.stream(CspHeader.values()).filter(h -> h.getHeaderName().equals(systemProperty)).findFirst(); + } + return Optional.empty(); + } + + // Jelly + public String getHeaderName() { + final Optional decision = decide(); + return decision.map(CspHeader::getHeaderName).orElse(null); + } +} diff --git a/core/src/main/java/jenkins/security/csp/impl/UserAvatarContributor.java b/core/src/main/java/jenkins/security/csp/impl/UserAvatarContributor.java new file mode 100644 index 000000000000..0fc5e910c598 --- /dev/null +++ b/core/src/main/java/jenkins/security/csp/impl/UserAvatarContributor.java @@ -0,0 +1,65 @@ +/* + * The MIT License + * + * Copyright (c) 2025, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.security.csp.impl; + +import hudson.Extension; +import hudson.model.User; +import hudson.tasks.UserAvatarResolver; +import java.util.logging.Level; +import java.util.logging.Logger; +import jenkins.model.navigation.UserAction; +import jenkins.security.csp.AvatarContributor; +import jenkins.security.csp.Contributor; +import jenkins.security.csp.CspBuilder; +import jenkins.security.csp.Directive; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * This extension automatically allows loading images from the domain hosting the current user's avatar + * as determined via {@link hudson.tasks.UserAvatarResolver}. + * Note that this will not make avatars of other users work, if they're from different domains. + */ +@Restricted(NoExternalUse.class) +@Extension +public class UserAvatarContributor implements Contributor { + + private static final Logger LOGGER = Logger.getLogger(UserAvatarContributor.class.getName()); + + @Override + public void apply(CspBuilder cspBuilder) { + User user = User.current(); + if (user == null) { + return; + } + final String url = UserAvatarResolver.resolveOrNull(user, UserAction.AVATAR_SIZE); + if (url == null) { + LOGGER.log(Level.FINE, "No avatar image found for user " + user.getId()); + return; + } + + cspBuilder.add(Directive.IMG_SRC, AvatarContributor.extractDomainFromUrl(url)); + } +} diff --git a/core/src/main/resources/META-INF/web-fragment.xml b/core/src/main/resources/META-INF/web-fragment.xml index c9acb6e1513c..8f967e749864 100644 --- a/core/src/main/resources/META-INF/web-fragment.xml +++ b/core/src/main/resources/META-INF/web-fragment.xml @@ -73,6 +73,11 @@ THE SOFTWARE. hudson.security.HudsonFilter true + + csp-filter + jenkins.security.csp.impl.CspFilter + true + csrf-filter hudson.security.csrf.CrumbFilter @@ -154,6 +159,10 @@ THE SOFTWARE. authentication-filter /* + + csp-filter + /* + csrf-filter /* diff --git a/core/src/main/resources/jenkins/security/ResourceDomainConfiguration/config.jelly b/core/src/main/resources/jenkins/security/ResourceDomainConfiguration/config.jelly index 6114d2ca35a6..022a4b20c8ac 100644 --- a/core/src/main/resources/jenkins/security/ResourceDomainConfiguration/config.jelly +++ b/core/src/main/resources/jenkins/security/ResourceDomainConfiguration/config.jelly @@ -24,6 +24,7 @@ THE SOFTWARE. --> + diff --git a/core/src/main/resources/jenkins/security/ResourceDomainRecommendation/description.properties b/core/src/main/resources/jenkins/security/ResourceDomainRecommendation/description.properties index f582443e009b..63c20f9ce401 100644 --- a/core/src/main/resources/jenkins/security/ResourceDomainRecommendation/description.properties +++ b/core/src/main/resources/jenkins/security/ResourceDomainRecommendation/description.properties @@ -1 +1,2 @@ -blurb = Informs about the resource root URL option if the Content-Security-Policy HTTP header for user-controlled resources served by Jenkins has been set to a custom value. +blurb = Informs about the resource root URL option if the Content-Security-Policy HTTP header for user-controlled resources served by Jenkins has been set to a custom value. \ + Learn more. diff --git a/core/src/main/resources/jenkins/security/csp/impl/CspConfiguration/config.jelly b/core/src/main/resources/jenkins/security/csp/impl/CspConfiguration/config.jelly new file mode 100644 index 000000000000..7c202e7303c7 --- /dev/null +++ b/core/src/main/resources/jenkins/security/csp/impl/CspConfiguration/config.jelly @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/src/main/resources/jenkins/security/csp/impl/CspConfiguration/help-enabled.html b/core/src/main/resources/jenkins/security/csp/impl/CspConfiguration/help-enabled.html new file mode 100644 index 000000000000..cf869ae11f1e --- /dev/null +++ b/core/src/main/resources/jenkins/security/csp/impl/CspConfiguration/help-enabled.html @@ -0,0 +1,8 @@ +
+ When checked, Jenkins will set the + Content-Security-Policy + header that enforces its configuration. Otherwise, it will set the + Content-Security-Policy-Report-Only + header, which only requests that browsers report violations, but does not + enforce them. +
diff --git a/core/src/main/resources/jenkins/security/csp/impl/CspDecorator/httpHeaders.jelly b/core/src/main/resources/jenkins/security/csp/impl/CspDecorator/httpHeaders.jelly new file mode 100644 index 000000000000..3595aef7a90c --- /dev/null +++ b/core/src/main/resources/jenkins/security/csp/impl/CspDecorator/httpHeaders.jelly @@ -0,0 +1,28 @@ + + + + + + diff --git a/core/src/main/resources/jenkins/security/csp/impl/CspRecommendation/description.jelly b/core/src/main/resources/jenkins/security/csp/impl/CspRecommendation/description.jelly new file mode 100644 index 000000000000..3d2753b69058 --- /dev/null +++ b/core/src/main/resources/jenkins/security/csp/impl/CspRecommendation/description.jelly @@ -0,0 +1,27 @@ + + + + ${%blurb} + diff --git a/core/src/main/resources/jenkins/security/csp/impl/CspRecommendation/description.properties b/core/src/main/resources/jenkins/security/csp/impl/CspRecommendation/description.properties new file mode 100644 index 000000000000..730e3c11a9f8 --- /dev/null +++ b/core/src/main/resources/jenkins/security/csp/impl/CspRecommendation/description.properties @@ -0,0 +1 @@ +blurb = Recommend setting up Content Security Policy for the Jenkins UI. diff --git a/core/src/main/resources/jenkins/security/csp/impl/CspRecommendation/index.jelly b/core/src/main/resources/jenkins/security/csp/impl/CspRecommendation/index.jelly new file mode 100644 index 000000000000..dd3bda3ded3d --- /dev/null +++ b/core/src/main/resources/jenkins/security/csp/impl/CspRecommendation/index.jelly @@ -0,0 +1,48 @@ + + + + + + +

+ ${%paragraph1} +

+

+ ${%paragraph2} +

+

+ ${%paragraph3} +

+
+
+ + + + +
+
+
+
+
diff --git a/core/src/main/resources/jenkins/security/csp/impl/CspRecommendation/index.properties b/core/src/main/resources/jenkins/security/csp/impl/CspRecommendation/index.properties new file mode 100644 index 000000000000..6222e3081c88 --- /dev/null +++ b/core/src/main/resources/jenkins/security/csp/impl/CspRecommendation/index.properties @@ -0,0 +1,7 @@ +# TODO Add better explanation +paragraph1 = Content Security Policy is a security mechanism that can reduce or eliminate the impact of web security vulnerabilities like cross-site-scripting (XSS). +paragraph2 = Most popular Jenkins plugins are compatible with Jenkins''s default rule set, but not everything currently installed may be. \ + If you choose to enforce Content Security Policy and it causes the Jenkins UI to break, you can start Jenkins with the Java system property \ + jenkins.security.csp.CspHeader.headerName set to Content-Security-Policy-Report-Only to disable protections again. +paragraph3 = For resources on determining whether your current setup is compatible with Content Security Policy enforcement, visit
the documentation website. +# TODO Is this even useful advice, given they could just delete the configuration file? diff --git a/core/src/main/resources/jenkins/security/csp/impl/CspRecommendation/message.jelly b/core/src/main/resources/jenkins/security/csp/impl/CspRecommendation/message.jelly new file mode 100644 index 000000000000..02aed15ae431 --- /dev/null +++ b/core/src/main/resources/jenkins/security/csp/impl/CspRecommendation/message.jelly @@ -0,0 +1,33 @@ + + + +
+
+ + + + ${%blurb} +
+
diff --git a/core/src/main/resources/jenkins/security/csp/impl/CspRecommendation/message.properties b/core/src/main/resources/jenkins/security/csp/impl/CspRecommendation/message.properties new file mode 100644 index 000000000000..368f83b9ebe3 --- /dev/null +++ b/core/src/main/resources/jenkins/security/csp/impl/CspRecommendation/message.properties @@ -0,0 +1,4 @@ +blurb = Jenkins can enforce Content Security Policy (CSP). \ + CSP tells web browsers what they are allowed to do while rendering a web page. \ + This limits or even eliminates the impact of vulnerabilities like cross-site scripting (XSS). \ + CSP is disabled by default for backward compatibility, but it is recommended to enable it, if possible. diff --git a/core/src/main/resources/jenkins/security/csp/impl/DevelopmentHeaderDecider/message.jelly b/core/src/main/resources/jenkins/security/csp/impl/DevelopmentHeaderDecider/message.jelly new file mode 100644 index 000000000000..9c6e4c4dbe3b --- /dev/null +++ b/core/src/main/resources/jenkins/security/csp/impl/DevelopmentHeaderDecider/message.jelly @@ -0,0 +1,27 @@ + + + + ${%blurb} + diff --git a/core/src/main/resources/jenkins/security/csp/impl/DevelopmentHeaderDecider/message.properties b/core/src/main/resources/jenkins/security/csp/impl/DevelopmentHeaderDecider/message.properties new file mode 100644 index 000000000000..79eebc874f93 --- /dev/null +++ b/core/src/main/resources/jenkins/security/csp/impl/DevelopmentHeaderDecider/message.properties @@ -0,0 +1,2 @@ +blurb = The Content Security Policy header is currently set to Content-Security-Policy because Jenkins is running in development mode. \ + This behavior can be disabled by setting the Java system property jenkins.security.csp.impl.DevelopmentHeaderDecider.DISABLED to true. diff --git a/core/src/main/resources/jenkins/security/csp/impl/FallbackDecider/message.jelly b/core/src/main/resources/jenkins/security/csp/impl/FallbackDecider/message.jelly new file mode 100644 index 000000000000..98e5d084a828 --- /dev/null +++ b/core/src/main/resources/jenkins/security/csp/impl/FallbackDecider/message.jelly @@ -0,0 +1,27 @@ + + + + ${%blurb(rootURL)} + diff --git a/core/src/main/resources/jenkins/security/csp/impl/FallbackDecider/message.properties b/core/src/main/resources/jenkins/security/csp/impl/FallbackDecider/message.properties new file mode 100644 index 000000000000..a0b8a8ba6db6 --- /dev/null +++ b/core/src/main/resources/jenkins/security/csp/impl/FallbackDecider/message.properties @@ -0,0 +1,2 @@ +blurb = Content-Security-Policy enforcement is currently disabled. \ + Learn more. diff --git a/core/src/main/resources/jenkins/security/csp/impl/Messages.properties b/core/src/main/resources/jenkins/security/csp/impl/Messages.properties new file mode 100644 index 000000000000..ededc889fe75 --- /dev/null +++ b/core/src/main/resources/jenkins/security/csp/impl/Messages.properties @@ -0,0 +1,11 @@ +CspRecommendation.DisplayName = Recommend Content Security Policy +CspConfiguration.UndefinedToTrueWithResourceDomain = Content Security Policy will be enforced once you save this configuration. +CspConfiguration.UndefinedToTrueWithoutResourceDomain = Content Security Policy will be enforced once you save this configuration. \ + The resource root URL should be configured for better protection. \ + Learn more. +CspConfiguration.UndefinedToFalse = Saving this configuration as is will leave Content Security Policy enforcement disabled. You can enable it now, or at any later time. +CspConfiguration.FalseToTrueWithoutResourceDomain = Content Security Policy will be enforced once you save this configuration. \ + The resource root URL should be configured for better protection. \ + Learn more. +CspConfiguration.TrueToTrueWithoutResourceDomain = Content Security Policy is currently enforced, but the resource root URL should be configured for better protection. \ + Learn more. diff --git a/core/src/main/resources/jenkins/security/csp/impl/SystemPropertyHeaderDecider/message.jelly b/core/src/main/resources/jenkins/security/csp/impl/SystemPropertyHeaderDecider/message.jelly new file mode 100644 index 000000000000..617088bc2497 --- /dev/null +++ b/core/src/main/resources/jenkins/security/csp/impl/SystemPropertyHeaderDecider/message.jelly @@ -0,0 +1,27 @@ + + + + ${%blurb(it.headerName)} + diff --git a/core/src/main/resources/jenkins/security/csp/impl/SystemPropertyHeaderDecider/message.properties b/core/src/main/resources/jenkins/security/csp/impl/SystemPropertyHeaderDecider/message.properties new file mode 100644 index 000000000000..4af31279ed3a --- /dev/null +++ b/core/src/main/resources/jenkins/security/csp/impl/SystemPropertyHeaderDecider/message.properties @@ -0,0 +1,2 @@ +blurb = Content Security Policy is configured to use the HTTP header {0} based on the system property jenkins.security.csp.CspHeader.headerName. \ + It cannot be configured through the UI while this system property specifies a header name. diff --git a/core/src/test/java/jenkins/security/csp/AvatarContributorTest.java b/core/src/test/java/jenkins/security/csp/AvatarContributorTest.java new file mode 100644 index 000000000000..cece923ff87c --- /dev/null +++ b/core/src/test/java/jenkins/security/csp/AvatarContributorTest.java @@ -0,0 +1,83 @@ +package jenkins.security.csp; + +import static jenkins.security.csp.AvatarContributor.extractDomainFromUrl; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; + +import org.junit.jupiter.api.Test; +import org.jvnet.hudson.test.For; + +@For(AvatarContributor.class) +public class AvatarContributorTest { + + @Test + void testExtractDomainFromUrl_Https() { + assertThat(extractDomainFromUrl("https://example.com/path/to/avatar.png"), is("https://example.com")); + } + + @Test + void testExtractDomainFromUrl_Http() { + assertThat(extractDomainFromUrl("http://example.com/path/to/avatar.png"), is("http://example.com")); + } + + @Test + void testExtractDomainFromUrl_WithPort() { + assertThat(extractDomainFromUrl("https://example.com:8080/avatar.png"), is("https://example.com:8080")); + } + + @Test + void testExtractDomainFromUrl_WithQueryParameters() { + assertThat(extractDomainFromUrl("https://example.com/avatar.png?size=64&format=png"), is("https://example.com")); + } + + @Test + void testExtractDomainFromUrl_WithFragment() { + assertThat(extractDomainFromUrl("https://example.com/avatar.png#section"), is("https://example.com")); + } + + @Test + void testExtractDomainFromUrl_NullUrl() { + assertThat(extractDomainFromUrl(null), is(nullValue())); + } + + @Test + void testExtractDomainFromUrl_NoHost() { + assertThat(extractDomainFromUrl("/local/path/avatar.png"), is(nullValue())); + } + + @Test + void testExtractDomainFromUrl_UnsupportedScheme() { + assertThat(extractDomainFromUrl("ftp://example.com/avatar.png"), is(nullValue())); + } + + @Test + void testExtractDomainFromUrl_FileScheme() { + assertThat(extractDomainFromUrl("file:///path/to/avatar.png"), is(nullValue())); + } + + @Test + void testExtractDomainFromUrl_DataUri() { + assertThat(extractDomainFromUrl("data:image/png;base64,iVBORw0KG..."), is(nullValue())); + } + + @Test + void testExtractDomainFromUrl_InvalidUri() { + assertThat(extractDomainFromUrl("not a valid uri:::"), is(nullValue())); + } + + @Test + void testExtractDomainFromUrl_Subdomain() { + assertThat(extractDomainFromUrl("https://cdn.example.com/avatar.png"), is("https://cdn.example.com")); + } + + @Test + void testExtractDomainFromUrl_Ipv4Address() { + assertThat(extractDomainFromUrl("https://192.168.1.1/avatar.png"), is("https://192.168.1.1")); + } + + @Test + void testExtractDomainFromUrl_Ipv4WithPort() { + assertThat(extractDomainFromUrl("https://192.168.1.1:8080/avatar.png"), is("https://192.168.1.1:8080")); + } +} diff --git a/core/src/test/java/jenkins/security/csp/BaseContributorTest.java b/core/src/test/java/jenkins/security/csp/BaseContributorTest.java new file mode 100644 index 000000000000..733a3ca50b5a --- /dev/null +++ b/core/src/test/java/jenkins/security/csp/BaseContributorTest.java @@ -0,0 +1,24 @@ +package jenkins.security.csp; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; + +import jenkins.security.csp.impl.BaseContributor; +import org.junit.jupiter.api.Test; +import org.jvnet.hudson.test.For; + +@For(BaseContributor.class) +public class BaseContributorTest { + @Test + void testRules() { + CspBuilder cspBuilder = new CspBuilder(); + new BaseContributor().apply(cspBuilder); + String csp = cspBuilder.build(); + assertThat(csp, containsString("default-src 'self';")); + assertThat(csp, containsString("style-src 'report-sample' 'self';")); + assertThat(csp, containsString("script-src 'report-sample' 'self';")); + assertThat(csp, containsString("form-action 'self';")); + assertThat(csp, containsString("base-uri 'none';")); + assertThat(csp, containsString("frame-ancestors 'self';")); + } +} diff --git a/core/src/test/java/jenkins/security/csp/CspBuilderTest.java b/core/src/test/java/jenkins/security/csp/CspBuilderTest.java new file mode 100644 index 000000000000..847ef6eebd73 --- /dev/null +++ b/core/src/test/java/jenkins/security/csp/CspBuilderTest.java @@ -0,0 +1,455 @@ +package jenkins.security.csp; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import hudson.util.RingBufferLogHandler; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class CspBuilderTest { + private RingBufferLogHandler logHandler; + private Logger logger; + private List logRecords; + + @BeforeEach + void setUp() { + logHandler = new RingBufferLogHandler(50); + logger = Logger.getLogger(CspBuilder.class.getName()); + logger.addHandler(logHandler); + logger.setLevel(Level.CONFIG); + logRecords = logHandler.getView(); + } + + @AfterEach + void tearDown() { + logger.removeHandler(logHandler); + } + + @Test + void nothingInitializedFallsBackToDefaultSrc() { + final CspBuilder builder = new CspBuilder(); + builder.add(Directive.DEFAULT_SRC, Directive.SELF); + assertThat(builder.build(), is("default-src 'self';")); + + builder.add(Directive.IMG_SRC, Directive.SELF); + assertThat(builder.build(), is("default-src 'self'; img-src 'self';")); + + builder.add(Directive.DEFAULT_SRC, Directive.DATA); + assertThat(builder.build(), is("default-src 'self' data:; img-src 'self' data:;")); + } + + @Test + void testInitializedDirectiveDoesNotInherit() { + CspBuilder builder = new CspBuilder(); + builder.initialize(FetchDirective.DEFAULT_SRC, Directive.SELF); + builder.initialize(FetchDirective.IMG_SRC); + + // img-src was initialized, so it should NOT inherit from default-src + assertThat(builder.build(), is("default-src 'self'; img-src 'none';")); + } + + @Test + void testSimpleFallback() { + final CspBuilder builder = new CspBuilder(); + builder.add(Directive.DEFAULT_SRC, Directive.SELF); + builder.add(Directive.IMG_SRC); + + // This seems a little weird, but it's harmless and allows #initialize to reuse #add + assertThat(builder.build(), is("default-src 'self'; img-src 'self';")); + } + + @Test + void testFallbackComposition() { + CspBuilder builder = new CspBuilder(); + builder.initialize(FetchDirective.SCRIPT_SRC, Directive.SELF); + builder.add(Directive.SCRIPT_SRC_ELEM, Directive.UNSAFE_INLINE); + + // script-src-elem should inherit from script-src (not default-src) + assertThat(builder.build(), is("script-src 'self'; script-src-elem 'self' 'unsafe-inline';")); + } + + @Test + void fallbackToNone() { + final CspBuilder builder = new CspBuilder(); + builder.add(Directive.DEFAULT_SRC); + assertThat(builder.build(), is("default-src 'none';")); + + // Add img-src directive, but inherit from default-src + builder.add(Directive.IMG_SRC); + assertThat(builder.build(), is("default-src 'none'; img-src 'none';")); + + // Still inherit (no-op right now), but have a new value + builder.add(Directive.IMG_SRC, Directive.SELF); + assertThat(builder.build(), is("default-src 'none'; img-src 'self';")); + + // Now actually inherit something + builder.add(Directive.DEFAULT_SRC, Directive.DATA); + assertThat(builder.build(), is("default-src data:; img-src 'self' data:;")); + + // Initialized empty value means we don't inherit and are 'none' valued + builder.initialize(FetchDirective.SCRIPT_SRC); + assertThat(builder.build(), is("default-src data:; img-src 'self' data:; script-src 'none';")); + } + + @Test + void emptyAddThenInit() { + CspBuilder builder = new CspBuilder(); + builder.initialize(FetchDirective.DEFAULT_SRC, Directive.SELF); + assertThat(builder.build(), is("default-src 'self';")); + + builder.add(Directive.IMG_SRC); + assertThat(builder.build(), is("default-src 'self'; img-src 'self';")); + + builder.initialize(FetchDirective.IMG_SRC); + assertThat(builder.build(), is("default-src 'self'; img-src 'none';")); + } + + @Test + void testRemoveDirective() { + CspBuilder builder = new CspBuilder(); + builder.initialize(FetchDirective.DEFAULT_SRC, Directive.SELF); + builder.initialize(FetchDirective.IMG_SRC, Directive.DATA); + builder.remove(Directive.IMG_SRC); + + // After removal, img-src should be gone entirely + assertThat(builder.build(), is("default-src 'self';")); + } + + @Test + void nonFetchEmptyTest() { + CspBuilder builder = new CspBuilder(); + builder.add(Directive.SANDBOX); + + assertThat(builder.build(), is("sandbox;")); + } + + @Test + void nonFetchNonEmptyTestFrameAncestors() { + CspBuilder builder = new CspBuilder(); + builder.add(Directive.FRAME_ANCESTORS); + + assertThat(builder.build(), is("frame-ancestors 'none';")); + } + + @Test + void nonFetchNonEmptyTestFormAction() { + CspBuilder builder = new CspBuilder(); + builder.add(Directive.FORM_ACTION); + + assertThat(builder.build(), is("form-action 'none';")); + } + + @Test + void testProhibitedDirective_reportUri() { + CspBuilder builder = new CspBuilder(); + builder.add(Directive.REPORT_URI, "https://example.com/csp-report"); + assertThat(logRecords, hasItem(logMessageContainsString("Directive report-uri cannot be set manually"))); + String csp = builder.build(); + assertThat(csp, is("")); + } + + @Test + void testProhibitedDirective_reportTo() { + CspBuilder builder = new CspBuilder(); + builder.add(Directive.REPORT_TO, "csp-endpoint"); + assertThat(logRecords, hasItem(logMessageContainsString("Directive report-to cannot be set manually"))); + String csp = builder.build(); + assertThat(csp, is("")); + } + + @Test + void testExplicitNoneValue_isSkipped() { + CspBuilder builder = new CspBuilder(); + builder.add(Directive.DEFAULT_SRC, Directive.SELF); + builder.add(Directive.DEFAULT_SRC, Directive.NONE); + assertThat(logRecords, hasItem(logMessageContainsString("Cannot explicitly add 'none'"))); + assertThat(logRecords, hasItem(logMessageContainsString(Directive.class.getName() + "#NONE Javadoc"))); + String csp = builder.build(); + assertThat(csp, is("default-src 'self';")); + } + + @Test + void testExplicitNoneValue_mixedWithValidValues() { + CspBuilder builder = new CspBuilder(); + builder.add(Directive.SCRIPT_SRC, Directive.SELF, Directive.NONE, Directive.UNSAFE_INLINE); + assertThat(logRecords, hasItem(logMessageContainsString("Cannot explicitly add 'none'"))); + String csp = builder.build(); + assertThat(csp, containsString("script-src")); + assertThat(csp, containsString("'self'")); + assertThat(csp, containsString("'unsafe-inline'")); + } + + @Test + void testBuilderReturnsThis_whenProhibitedDirectiveUsed() { + CspBuilder builder = new CspBuilder(); + + // Should return builder for chaining even when directive is prohibited + CspBuilder result = builder.add(Directive.REPORT_URI, "https://example.com"); + assertThat(result, is(builder)); // Same instance + } + + @Test + void testNoLogging_whenValidDirectivesUsed() { + CspBuilder builder = new CspBuilder(); + builder.add(Directive.DEFAULT_SRC, Directive.SELF); + builder.add(Directive.SCRIPT_SRC, Directive.UNSAFE_INLINE); + assertThat(logRecords.isEmpty(), is(true)); + } + + @Test + void testRemoveMultipleValuesFromDirective() { + CspBuilder builder = new CspBuilder(); + builder.add(Directive.DEFAULT_SRC, Directive.SELF, Directive.DATA, Directive.BLOB); + assertThat(builder.build(), is("default-src 'self' blob: data:;")); + + // Remove specific values + builder.remove(Directive.DEFAULT_SRC, Directive.DATA); + assertThat(builder.build(), is("default-src 'self' blob:;")); + + // Remove another value + builder.remove(Directive.DEFAULT_SRC, Directive.BLOB); + assertThat(builder.build(), is("default-src 'self';")); + } + + @Test + void testRemoveMultipleValuesAtOnce() { + CspBuilder builder = new CspBuilder(); + builder.add(Directive.SCRIPT_SRC, Directive.SELF, Directive.UNSAFE_INLINE, Directive.UNSAFE_EVAL, Directive.DATA); + assertThat(builder.build(), is("script-src 'self' 'unsafe-eval' 'unsafe-inline' data:;")); + + // Remove multiple values at once + builder.remove(Directive.SCRIPT_SRC, Directive.UNSAFE_INLINE, Directive.UNSAFE_EVAL); + assertThat(builder.build(), is("script-src 'self' data:;")); + } + + @Test + void testRemoveNonExistentValue() { + CspBuilder builder = new CspBuilder(); + builder.add(Directive.DEFAULT_SRC, Directive.SELF); + assertThat(builder.build(), is("default-src 'self';")); + + // Try to remove a value that doesn't exist - should be no-op + builder.remove(Directive.DEFAULT_SRC, Directive.DATA); + assertThat(builder.build(), is("default-src 'self';")); + } + + @Test + void testRemoveValuesFromNonExistentDirective() { + CspBuilder builder = new CspBuilder(); + builder.add(Directive.DEFAULT_SRC, Directive.SELF); + + // Try to remove values from a directive that was never added - should be no-op + builder.remove(Directive.IMG_SRC, Directive.DATA); + assertThat(builder.build(), is("default-src 'self';")); + } + + @Test + void testRemoveValuesFromNonFetchDirective() { + CspBuilder builder = new CspBuilder(); + builder.add(Directive.FORM_ACTION, Directive.SELF, "https://example.com"); + assertThat(builder.build(), containsString("form-action")); + + // Remove specific value from non-fetch directive + builder.remove(Directive.FORM_ACTION, "https://example.com"); + assertThat(builder.build(), is("form-action 'self';")); + } + + @Test + void testRemoveEntireNonFetchDirective() { + CspBuilder builder = new CspBuilder(); + builder.add(Directive.FORM_ACTION, Directive.SELF); + builder.add(Directive.FRAME_ANCESTORS, Directive.SELF); + assertThat(builder.build(), containsString("form-action")); + assertThat(builder.build(), containsString("frame-ancestors")); + + // Remove entire non-fetch directive + builder.remove(Directive.FORM_ACTION); + assertThat(builder.build(), is("frame-ancestors 'self';")); + } + + @Test + void testGetMergedDirectivesReturnsInheritanceInfo() { + CspBuilder builder = new CspBuilder(); + builder.initialize(FetchDirective.DEFAULT_SRC, Directive.SELF); + builder.add(Directive.IMG_SRC, Directive.DATA); + builder.initialize(FetchDirective.SCRIPT_SRC, Directive.UNSAFE_INLINE); + + List merged = builder.getMergedDirectives(); + + // Find each directive and check inheritance flag + Directive defaultSrc = merged.stream() + .filter(d -> d.name().equals(Directive.DEFAULT_SRC)) + .findFirst() + .orElse(null); + assertThat(defaultSrc, is(notNullValue())); + assertThat(defaultSrc.inheriting(), is(false)); // initialized + + Directive imgSrc = merged.stream() + .filter(d -> d.name().equals(Directive.IMG_SRC)) + .findFirst() + .orElse(null); + assertThat(imgSrc, is(notNullValue())); + assertThat(imgSrc.inheriting(), is(true)); // not initialized, inherits from default-src + + Directive scriptSrc = merged.stream() + .filter(d -> d.name().equals(Directive.SCRIPT_SRC)) + .findFirst() + .orElse(null); + assertThat(scriptSrc, is(notNullValue())); + assertThat(scriptSrc.inheriting(), is(false)); // initialized + } + + @Test + void testGetMergedDirectivesValuesAreImmutable() { + CspBuilder builder = new CspBuilder(); + builder.add(Directive.DEFAULT_SRC, Directive.SELF); + + List merged = builder.getMergedDirectives(); + Directive directive = merged.get(0); + + assertThrows(UnsupportedOperationException.class, () -> directive.values().add("should-fail")); + } + + @Test + void testMultipleInitializeSameDirective() { + CspBuilder builder = new CspBuilder(); + builder.initialize(FetchDirective.DEFAULT_SRC, Directive.SELF); + builder.initialize(FetchDirective.DEFAULT_SRC, Directive.DATA); + + // Both values should be present + assertThat(builder.build(), is("default-src 'self' data:;")); + } + + @Test + void testInitializeAfterAdd() { + CspBuilder builder = new CspBuilder(); + builder.add(Directive.IMG_SRC, Directive.SELF); + builder.add(Directive.DEFAULT_SRC, Directive.BLOB); + + // At this point, img-src inherits from default-src + assertThat(builder.build(), is("default-src blob:; img-src 'self' blob:;")); + + builder.initialize(FetchDirective.IMG_SRC, Directive.DATA); + + // Now img-src is initialized and no longer inherits blob: from default-src + assertThat(builder.build(), is("default-src blob:; img-src 'self' data:;")); + } + + @Test + void testRemoveDirectiveRemovesInitializationFlag() { + CspBuilder builder = new CspBuilder(); + builder.initialize(FetchDirective.IMG_SRC, Directive.DATA); + assertThat(builder.build(), is("img-src data:;")); + + // Remove the directive entirely + builder.remove(Directive.IMG_SRC); + + // Add default-src + builder.add(Directive.DEFAULT_SRC, Directive.SELF); + + // Add img-src again - should now inherit because initialization flag was cleared + builder.add(Directive.IMG_SRC, Directive.DATA); + assertThat(builder.build(), is("default-src 'self'; img-src 'self' data:;")); + } + + @Test + void testFallbackToUninitializedDefaultSrc() { + CspBuilder builder = new CspBuilder(); + builder.add(Directive.DEFAULT_SRC, Directive.SELF); + builder.add(Directive.IMG_SRC, Directive.DATA); + + // Both should be present, img-src should inherit from default-src + assertThat(builder.build(), is("default-src 'self'; img-src 'self' data:;")); + } + + @Test + void testNullValue() { + CspBuilder builder = new CspBuilder(); + builder.add(Directive.IMG_SRC, (String) null); + assertThat(builder.build(), is("")); + } + + @Test + void testNullValueInheriting() { + CspBuilder builder = new CspBuilder(); + builder.add(Directive.DEFAULT_SRC, Directive.SELF); + builder.add(Directive.IMG_SRC, (String) null); + assertThat(builder.build(), is("default-src 'self';")); + } + + @Test + void testNoValueInheriting() { + CspBuilder builder = new CspBuilder(); + builder.add(Directive.DEFAULT_SRC, Directive.SELF); + builder.add(Directive.IMG_SRC); + assertThat(builder.build(), is("default-src 'self'; img-src 'self';")); + } + + @Test + void testNoValueNoninheriting() { + CspBuilder builder = new CspBuilder(); + builder.add(Directive.DEFAULT_SRC, Directive.SELF); + builder.initialize(FetchDirective.IMG_SRC); + assertThat(builder.build(), is("default-src 'self'; img-src 'none';")); + + builder.add(Directive.IMG_SRC, "example.org"); + assertThat(builder.build(), is("default-src 'self'; img-src example.org;")); + } + + @Test + void testNullValueNoninheriting() { + CspBuilder builder = new CspBuilder(); + builder.add(Directive.DEFAULT_SRC, Directive.SELF); + builder.initialize(FetchDirective.IMG_SRC, (String) null); + assertThat(builder.build(), is("default-src 'self';")); + builder.add(Directive.IMG_SRC, "example.org"); + // Initialization was ignored + assertThat(builder.build(), is("default-src 'self'; img-src 'self' example.org;")); + assertThat(logRecords, hasItem(logMessageContainsString("Ignoring initialization call with no-op null values list for img-src"))); + } + + @Test + void testNullValueAddition() { + CspBuilder builder = new CspBuilder(); + builder.add(Directive.IMG_SRC, "example.org"); + builder.add(Directive.IMG_SRC, (String) null); + assertThat(builder.build(), is("img-src example.org;")); + } + + private static Matcher logMessageContainsString(String needle) { + return new LogMessageContainsString(containsString(needle)); + } + + private static final class LogMessageContainsString extends TypeSafeMatcher { + private final Matcher stringMatcher; + + LogMessageContainsString(Matcher stringMatcher) { + this.stringMatcher = stringMatcher; + } + + @Override + protected boolean matchesSafely(LogRecord item) { + return stringMatcher.matches(item.getMessage()); + } + + @Override + public void describeTo(Description description) { + description.appendText("a LogRecord with a message matching "); + stringMatcher.describeTo(description); + } + } + +} diff --git a/test/src/test/java/hudson/markup/MarkupFormatterTest.java b/test/src/test/java/hudson/markup/MarkupFormatterTest.java index b8c70062c748..291225de516a 100644 --- a/test/src/test/java/hudson/markup/MarkupFormatterTest.java +++ b/test/src/test/java/hudson/markup/MarkupFormatterTest.java @@ -116,7 +116,5 @@ void security2153SetsCSP() throws Exception { assertEquals(200, response.getStatusCode()); assertThat(response.getContentAsString(), containsString("lolwut")); assertThat(response.getResponseHeaderValue("Content-Security-Policy"), containsString("default-src 'none';")); - assertThat(response.getResponseHeaderValue("X-Content-Security-Policy"), containsString("default-src 'none';")); - assertThat(response.getResponseHeaderValue("X-WebKit-CSP"), containsString("default-src 'none';")); } } diff --git a/test/src/test/java/hudson/model/DirectoryBrowserSupportTest.java b/test/src/test/java/hudson/model/DirectoryBrowserSupportTest.java index e6c77af60417..0a29ea69b086 100644 --- a/test/src/test/java/hudson/model/DirectoryBrowserSupportTest.java +++ b/test/src/test/java/hudson/model/DirectoryBrowserSupportTest.java @@ -323,9 +323,7 @@ void contentSecurityPolicy() throws Exception { j.buildAndAssertSuccess(p); HtmlPage page = getWebClient().goTo("job/" + p.getName() + "/lastSuccessfulBuild/artifact/test.html"); - for (String header : new String[]{"Content-Security-Policy", "X-WebKit-CSP", "X-Content-Security-Policy"}) { - assertEquals(DirectoryBrowserSupport.DEFAULT_CSP_VALUE, page.getWebResponse().getResponseHeaderValue(header), "Header set: " + header); - } + assertEquals(DirectoryBrowserSupport.DEFAULT_CSP_VALUE, page.getWebResponse().getResponseHeaderValue("Content-Security-Policy")); String propName = DirectoryBrowserSupport.class.getName() + ".CSP"; String initialValue = System.getProperty(propName); @@ -333,9 +331,7 @@ void contentSecurityPolicy() throws Exception { System.setProperty(propName, ""); page = getWebClient().goTo("job/" + p.getName() + "/lastSuccessfulBuild/artifact/test.html"); List headers = page.getWebResponse().getResponseHeaders().stream().map(NameValuePair::getName).collect(Collectors.toList()); - for (String header : new String[]{"Content-Security-Policy", "X-WebKit-CSP", "X-Content-Security-Policy"}) { - assertThat(headers, not(hasItem(header))); - } + assertThat(headers, not(hasItem("Content-Security-Policy"))); } finally { if (initialValue == null) { System.clearProperty(DirectoryBrowserSupport.class.getName() + ".CSP"); diff --git a/test/src/test/java/hudson/model/FileParameterValueTest.java b/test/src/test/java/hudson/model/FileParameterValueTest.java index 7008fa548505..5439fca86021 100644 --- a/test/src/test/java/hudson/model/FileParameterValueTest.java +++ b/test/src/test/java/hudson/model/FileParameterValueTest.java @@ -409,9 +409,7 @@ void contentSecurityPolicy() throws Exception { var wc = getWebClient(); HtmlPage page = wc.goTo("job/" + p.getName() + "/lastSuccessfulBuild/parameters/parameter/html.html/html.html"); - for (String header : new String[]{"Content-Security-Policy", "X-WebKit-CSP", "X-Content-Security-Policy"}) { - assertEquals(DirectoryBrowserSupport.DEFAULT_CSP_VALUE, page.getWebResponse().getResponseHeaderValue(header), "Header set: " + header); - } + assertEquals(DirectoryBrowserSupport.DEFAULT_CSP_VALUE, page.getWebResponse().getResponseHeaderValue("Content-Security-Policy")); String propName = DirectoryBrowserSupport.class.getName() + ".CSP"; String initialValue = System.getProperty(propName); @@ -419,9 +417,7 @@ void contentSecurityPolicy() throws Exception { System.setProperty(propName, ""); page = wc.goTo("job/" + p.getName() + "/lastSuccessfulBuild/parameters/parameter/html.html/html.html"); List headers = page.getWebResponse().getResponseHeaders().stream().map(NameValuePair::getName).collect(Collectors.toList()); - for (String header : new String[]{"Content-Security-Policy", "X-WebKit-CSP", "X-Content-Security-Policy"}) { - assertThat(headers, not(hasItem(header))); - } + assertThat(headers, not(hasItem("Content-Security-Policy"))); } finally { if (initialValue == null) { System.clearProperty(DirectoryBrowserSupport.class.getName() + ".CSP"); diff --git a/test/src/test/java/hudson/util/FormValidationSecurity1893Test.java b/test/src/test/java/hudson/util/FormValidationSecurity1893Test.java index 8b3e89f2cae5..6582868d2197 100644 --- a/test/src/test/java/hudson/util/FormValidationSecurity1893Test.java +++ b/test/src/test/java/hudson/util/FormValidationSecurity1893Test.java @@ -52,7 +52,5 @@ void checkHeaderPresence() throws Exception { assertEquals(200, response.getStatusCode()); assertThat(response.getContentAsString(), containsString(Messages.AbstractProject_CustomWorkspaceEmpty())); assertThat(response.getResponseHeaderValue("Content-Security-Policy"), containsString("default-src 'none';")); - assertThat(response.getResponseHeaderValue("X-Content-Security-Policy"), containsString("default-src 'none';")); - assertThat(response.getResponseHeaderValue("X-WebKit-CSP"), containsString("default-src 'none';")); } } diff --git a/test/src/test/java/jenkins/security/csp/AvatarContributorTest.java b/test/src/test/java/jenkins/security/csp/AvatarContributorTest.java new file mode 100644 index 000000000000..857f5a17d50a --- /dev/null +++ b/test/src/test/java/jenkins/security/csp/AvatarContributorTest.java @@ -0,0 +1,142 @@ +package jenkins.security.csp; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.jvnet.hudson.test.LoggerRule.recorded; + +import hudson.ExtensionList; +import java.util.logging.Level; +import org.junit.jupiter.api.Test; +import org.jvnet.hudson.test.For; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.LoggerRule; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; + +@For(AvatarContributor.class) +@WithJenkins +public class AvatarContributorTest { + + @Test + void testAllowWithDefaults_ValidUrl(JenkinsRule j) { + LoggerRule loggerRule = new LoggerRule().record(AvatarContributor.class, Level.CONFIG).capture(100); + AvatarContributor.allow("https://avatars.example.com/user/avatar.png"); + String csp = new CspBuilder().withDefaultContributions().build(); + assertThat(csp, is("base-uri 'none'; default-src 'self'; form-action 'self'; frame-ancestors 'self'; img-src 'self' data: https://avatars.example.com; script-src 'report-sample' 'self'; style-src 'report-sample' 'self' 'unsafe-inline';")); + assertThat(loggerRule, recorded(Level.CONFIG, is("Adding domain 'https://avatars.example.com' from avatar URL: https://avatars.example.com/user/avatar.png"))); + } + + @Test + void testAllowWithDefaults_NullUrl(JenkinsRule j) { + LoggerRule loggerRule = new LoggerRule().record(AvatarContributor.class, Level.FINEST).capture(100); + AvatarContributor.allow(null); + String csp = new CspBuilder().withDefaultContributions().build(); + assertThat(csp, is("base-uri 'none'; default-src 'self'; form-action 'self'; frame-ancestors 'self'; img-src 'self' data:; script-src 'report-sample' 'self'; style-src 'report-sample' 'self' 'unsafe-inline';")); + assertThat(loggerRule, recorded(Level.FINE, is("Skipping null domain in avatar URL: null"))); + assertThat(loggerRule, not(recorded(containsString("Adding domain ")))); + assertThat(loggerRule, not(recorded(containsString("Skipped adding duplicate domain ")))); + } + + @Test + void testAllowWithDefaults_InvalidUrl(JenkinsRule j) { + LoggerRule loggerRule = new LoggerRule().record(AvatarContributor.class, Level.FINE).capture(100); + AvatarContributor.allow("not a valid url:::"); + + CspBuilder cspBuilder = new CspBuilder().withDefaultContributions(); + String csp = cspBuilder.build(); + + assertThat(csp, is("base-uri 'none'; default-src 'self'; form-action 'self'; frame-ancestors 'self'; img-src 'self' data:; script-src 'report-sample' 'self'; style-src 'report-sample' 'self' 'unsafe-inline';")); + assertThat(loggerRule, recorded(Level.FINE, is("Failed to parse avatar URI: not a valid url:::"))); + } + + @Test + void testAllowWithDefaults_MultipleDomains(JenkinsRule j) { + AvatarContributor.allow("https://avatars1.example.com/avatar1.png"); + AvatarContributor.allow("https://avatars2.example.com/avatar2.png"); + AvatarContributor.allow("https://avatars3.example.com/avatar3.png"); + + String csp = new CspBuilder().withDefaultContributions().build(); + + assertThat(csp, is("base-uri 'none'; default-src 'self'; form-action 'self'; frame-ancestors 'self'; img-src 'self' data: https://avatars1.example.com https://avatars2.example.com https://avatars3.example.com; script-src 'report-sample' 'self'; style-src 'report-sample' 'self' 'unsafe-inline';")); + } + + @Test + void testAllowWithDefaults_DuplicateDomain(JenkinsRule j) { + LoggerRule loggerRule = new LoggerRule().record(AvatarContributor.class, Level.FINEST).capture(100); + AvatarContributor.allow("https://avatars.example.com/avatar1.png"); + AvatarContributor.allow("https://avatars.example.com/avatar2.png"); + + CspBuilder cspBuilder = new CspBuilder().withDefaultContributions(); + String csp = cspBuilder.build(); + + assertThat(csp, is("base-uri 'none'; default-src 'self'; form-action 'self'; frame-ancestors 'self'; img-src 'self' data: https://avatars.example.com; script-src 'report-sample' 'self'; style-src 'report-sample' 'self' 'unsafe-inline';")); + assertThat(loggerRule, recorded(Level.FINEST, is("Skipped adding duplicate domain 'https://avatars.example.com' from avatar URL: https://avatars.example.com/avatar2.png"))); + } + + @Test + void testAllowWithDefaults_WithPort(JenkinsRule j) { + AvatarContributor.allow("https://example.com:3000/avatar.png"); + + CspBuilder cspBuilder = new CspBuilder().withDefaultContributions(); + String csp = cspBuilder.build(); + + assertThat(csp, is("base-uri 'none'; default-src 'self'; form-action 'self'; frame-ancestors 'self'; img-src 'self' data: https://example.com:3000; script-src 'report-sample' 'self'; style-src 'report-sample' 'self' 'unsafe-inline';")); + } + + @Test + void testAllowWithDefaults_HttpAndHttpsSameDomain(JenkinsRule j) { + AvatarContributor.allow("http://example.com/avatar.png"); + AvatarContributor.allow("https://example.com/avatar.png"); + + CspBuilder cspBuilder = new CspBuilder().withDefaultContributions(); + String csp = cspBuilder.build(); + + assertThat(csp, is("base-uri 'none'; default-src 'self'; form-action 'self'; frame-ancestors 'self'; img-src 'self' data: http://example.com https://example.com; script-src 'report-sample' 'self'; style-src 'report-sample' 'self' 'unsafe-inline';")); + } + + @Test + void testAllow_EmptyDomains(JenkinsRule j) { + // Should produce empty CSP when no domains are added and no base contributors run + assertThat(new CspBuilder().build(), is("")); + } + + @Test + void testAllow_SingleDomain(JenkinsRule j) { + AvatarContributor.allow("https://cdn.example.org/avatars/user.png"); + + CspBuilder cspBuilder = new CspBuilder().initialize(FetchDirective.DEFAULT_SRC, Directive.SELF); + ExtensionList.lookupSingleton(AvatarContributor.class).apply(cspBuilder); + + String csp = cspBuilder.build(); + assertThat(csp, is("default-src 'self'; img-src 'self' https://cdn.example.org;")); + } + + @Test + void testAllow_MultipleDomainsInImgSrc(JenkinsRule j) { + AvatarContributor.allow("https://avatars-a.example.com/user1.png"); + AvatarContributor.allow("https://avatars-b.example.net/user2.png"); + AvatarContributor.allow("http://insecure.example.org:8080/user3.png"); + AvatarContributor.allow("https://avatars-b.example.net/user4.png"); + AvatarContributor.allow("http://insecure.example.org:8080/user5.png"); + + CspBuilder cspBuilder = new CspBuilder().initialize(FetchDirective.DEFAULT_SRC, Directive.SELF); + ExtensionList.lookupSingleton(AvatarContributor.class).apply(cspBuilder); + + assertThat(cspBuilder.build(), is("default-src 'self'; img-src 'self' http://insecure.example.org:8080 https://avatars-a.example.com https://avatars-b.example.net;")); + } + + @Test + void testAllow_UnsupportedSchemeDoesNotAddToCsp(JenkinsRule j) { + LoggerRule loggerRule = new LoggerRule().record(AvatarContributor.class, Level.FINER).capture(100); + AvatarContributor.allow("ftp://files.example.com/avatar.png"); + AvatarContributor.allow("data:image/png;base64,iVBORw0KG..."); + + CspBuilder cspBuilder = new CspBuilder().initialize(FetchDirective.DEFAULT_SRC, Directive.SELF); + ExtensionList.lookupSingleton(AvatarContributor.class).apply(cspBuilder); + String csp = cspBuilder.build(); + assertThat(csp, is("default-src 'self';")); + assertThat(loggerRule, recorded(Level.FINER, is("Ignoring URI with unsupported scheme: ftp://files.example.com/avatar.png"))); + assertThat(loggerRule, recorded(Level.FINER, is("Ignoring URI without host: data:image/png;base64,iVBORw0KG..."))); + } +} diff --git a/test/src/test/java/jenkins/security/csp/ContentSecurityPolicyTest.java b/test/src/test/java/jenkins/security/csp/ContentSecurityPolicyTest.java new file mode 100644 index 000000000000..b13300a94a82 --- /dev/null +++ b/test/src/test/java/jenkins/security/csp/ContentSecurityPolicyTest.java @@ -0,0 +1,268 @@ +package jenkins.security.csp; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; + +import hudson.model.DirectoryBrowserSupport; +import hudson.model.FreeStyleProject; +import hudson.tasks.ArtifactArchiver; +import java.net.URL; +import java.util.logging.Level; +import jenkins.security.ResourceDomainConfiguration; +import jenkins.security.csp.impl.LoggingReceiver; +import jenkins.security.csp.impl.ReportingAction; +import net.sf.json.JSONObject; +import org.htmlunit.HttpMethod; +import org.htmlunit.Page; +import org.htmlunit.WebRequest; +import org.htmlunit.WebResponse; +import org.htmlunit.html.HtmlPage; +import org.htmlunit.util.NameValuePair; +import org.junit.jupiter.api.Test; +import org.jvnet.hudson.test.CreateFileBuilder; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.LoggerRule; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; + +@WithJenkins +class ContentSecurityPolicyTest { + + private static final String RESOURCE_ROOT_URL_DOMAIN = "127.0.0.1"; + private static final String BASIC_HTML = "test"; + private static final String HTML_CONTENT_TYPE = "text/html"; + + private final LoggerRule logger = new LoggerRule().record(LoggingReceiver.class, Level.FINEST).capture(100); + + @Test + void anonymousUserCspReporting(JenkinsRule j) throws Exception { + try (JenkinsRule.WebClient wc = j.createWebClient()) { + submitCspReport(j, wc); + } + + assertThat(String.join("\n", logger.getMessages()), containsString("Received anonymous report for context ViewContext[className=hudson.model.AllView, viewName=]: {\"csp-report\":{}}")); + } + + @Test + void authenticatedUserCspReporting(JenkinsRule j) throws Exception { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + + try (JenkinsRule.WebClient wc = j.createWebClient().login("alice")) { + submitCspReport(j, wc); + } + + String logMessages = String.join("\n", logger.getMessages()); + assertThat(logMessages, not(containsString("Received anonymous report for context ViewContext[className"))); + } + + private void submitCspReport(JenkinsRule j, JenkinsRule.WebClient wc) throws Exception { + // Retrieve a Jenkins page and inspect the CSP header + HtmlPage page = wc.goTo(""); + WebResponse response = page.getWebResponse(); + + String cspHeader = getCspHeaderFromResponse(response); + assertThat("CSP header should be present", cspHeader, notNullValue()); + + String reportingEndpoint = extractReportingEndpoint(cspHeader); + assertThat("Reporting endpoint should be present in CSP header", reportingEndpoint, notNullValue()); + + JSONObject report = new JSONObject(); + report.put("csp-report", new JSONObject()); + + wc.setThrowExceptionOnFailingStatusCode(false); + + WebRequest request = new WebRequest( + new URL(reportingEndpoint), + HttpMethod.POST + ); + request.setRequestBody(report.toString()); + request.setAdditionalHeader("Content-Type", "application/csp-report"); + + WebResponse reportResponse = wc.getPage(request).getWebResponse(); + + // Verify the report was accepted (should return 200 OK) + assertThat("Report submission should succeed", reportResponse.getStatusCode(), org.hamcrest.Matchers.is(200)); + } + + @Test + void reportUriAndReportingEndpointMatch(JenkinsRule j) throws Exception { + try (JenkinsRule.WebClient wc = j.createWebClient()) { + HtmlPage page = wc.goTo(""); + WebResponse response = page.getWebResponse(); + + String cspHeader = getCspHeaderFromResponse(response); + String reportUriValue = extractReportingEndpoint(cspHeader); + String reportingEndpointsHeader = getHeaderFromResponse(response, "Reporting-Endpoints"); + assertThat(reportingEndpointsHeader, notNullValue()); + String reportToValue = extractReportToEndpoint(reportingEndpointsHeader); + assertThat(reportToValue, containsString(ReportingAction.URL)); + assertThat(reportUriValue, equalTo(reportToValue)); + } + } + + @Test + void reportUriEncodesContextCorrectly(JenkinsRule j) throws Exception { + FreeStyleProject project = j.createFreeStyleProject("test-job"); + + try (JenkinsRule.WebClient wc = j.createWebClient()) { + // Just a URL with context class and restOfPath + HtmlPage page = wc.goTo("job/" + project.getName() + "/changes"); + WebResponse response = page.getWebResponse(); + + String cspHeader = getCspHeaderFromResponse(response); + String reportUri = extractReportingEndpoint(cspHeader); + + // Extract the context parameter from the report-uri URL + // Format: http://.../csp-reports/ + String[] uriParts = reportUri.split("/"); + String encodedContext = uriParts[uriParts.length - 1]; + + ReportingContext.DecodedContext context = ReportingContext.decodeContext(encodedContext); + assertThat(context.contextClassName(), equalTo(FreeStyleProject.class.getName())); + assertThat(context.restOfPath(), equalTo("changes")); + } + } + + @Test + void workspaceAndArtifactCspDiffersFromGlobalCsp(JenkinsRule j) throws Exception { + FreeStyleProject project = j.createFreeStyleProject(); + project.getBuildersList().add(new CreateFileBuilder("test.html", BASIC_HTML)); + project.getPublishersList().add(new ArtifactArchiver("*")); + + j.buildAndAssertSuccess(project); + + try (JenkinsRule.WebClient wc = j.createWebClient()) { + HtmlPage regularPage = wc.goTo("job/" + project.getName() + "/ws/"); + String regularCsp = getCspHeaderFromResponse(regularPage.getWebResponse()); + assertThat(regularCsp, notNullValue()); + + Page workspacePage = wc.goTo("job/" + project.getName() + "/ws/test.html", HTML_CONTENT_TYPE); + String workspaceCsp = getCspHeaderFromResponse(workspacePage.getWebResponse()); + assertThat(workspaceCsp, equalTo(DirectoryBrowserSupport.DEFAULT_CSP_VALUE)); + + Page artifactPage = wc.goTo("job/" + project.getName() + "/lastSuccessfulBuild/artifact/test.html", HTML_CONTENT_TYPE); + String artifactCsp = getCspHeaderFromResponse(artifactPage.getWebResponse()); + assertThat(artifactCsp, equalTo(DirectoryBrowserSupport.DEFAULT_CSP_VALUE)); + + assertThat(artifactCsp, not(equalTo(regularCsp))); + } + } + + @Test + void workspaceCspCanBeDisabledIndependently(JenkinsRule j) throws Exception { + FreeStyleProject project = j.createFreeStyleProject(); + project.getBuildersList().add(new CreateFileBuilder("test.html", BASIC_HTML)); + j.buildAndAssertSuccess(project); + + try (JenkinsRule.WebClient wc = j.createWebClient()) { + HtmlPage regularPage = wc.goTo(""); + String regularCsp = getCspHeaderFromResponse(regularPage.getWebResponse()); + assertThat(regularCsp, notNullValue()); + + String originalValue = System.getProperty(DirectoryBrowserSupport.CSP_PROPERTY_NAME); + try { + System.setProperty(DirectoryBrowserSupport.CSP_PROPERTY_NAME, ""); + + // Get workspace file (DirectoryBrowserSupport CSP should be absent) + Page workspacePage = wc.goTo("job/" + project.getName() + "/ws/test.html", HTML_CONTENT_TYPE); + String workspaceCsp = getCspHeaderFromResponse(workspacePage.getWebResponse()); + + assertThat(workspaceCsp, nullValue()); + + // Verify regular CSP is unaffected + regularPage = wc.goTo(""); + String regularCspAfter = getCspHeaderFromResponse(regularPage.getWebResponse()); + assertThat(regularCspAfter, notNullValue()); + assertThat(regularCspAfter, equalTo(regularCsp)); + } finally { + if (originalValue == null) { + System.clearProperty(DirectoryBrowserSupport.CSP_PROPERTY_NAME); + } else { + System.setProperty(DirectoryBrowserSupport.CSP_PROPERTY_NAME, originalValue); + } + } + } + } + + @Test + void workspaceCspTakesPrecedenceOverRegularCsp(JenkinsRule j) throws Exception { + FreeStyleProject project = j.createFreeStyleProject(); + project.getBuildersList().add(new CreateFileBuilder("test.html", BASIC_HTML)); + j.buildAndAssertSuccess(project); + + try (JenkinsRule.WebClient wc = j.createWebClient()) { + Page workspacePage = wc.goTo("job/" + project.getName() + "/ws/test.html", HTML_CONTENT_TYPE); + WebResponse response = workspacePage.getWebResponse(); + + long cspHeaderCount = response.getResponseHeaders().stream().filter(header -> header.getName().equals("Content-Security-Policy")).count(); + assertThat(cspHeaderCount, equalTo(1L)); + + String csp = getCspHeaderFromResponse(response); + assertThat(csp, equalTo(DirectoryBrowserSupport.DEFAULT_CSP_VALUE)); + } + } + + private static String getCspHeaderFromResponse(WebResponse response) { + return getHeaderFromResponse(response, "Content-Security-Policy"); + } + + private static String getHeaderFromResponse(WebResponse response, String headerName) { + for (NameValuePair pair : response.getResponseHeaders()) { + if (pair.getName().equals(headerName)) { + return pair.getValue(); + } + } + return null; + } + + private static String extractReportingEndpoint(String cspHeader) { + // CSP header format: "... report-uri " + String[] parts = cspHeader.split("report-uri"); + if (parts.length < 2) { + return null; + } + // Get the endpoint URL (everything after "report-uri" until semicolon or end) + String endpointPart = parts[1].trim(); + int semicolonIndex = endpointPart.indexOf(';'); + if (semicolonIndex > 0) { + endpointPart = endpointPart.substring(0, semicolonIndex).trim(); + } + return endpointPart; + } + + private static String extractReportToEndpoint(String reportingEndpointsHeader) { + // Reporting-Endpoints header format: "content-security-policy: " + // See jenkins.security.Filter for where the header is set + String[] parts = reportingEndpointsHeader.split(":", 2); + if (parts.length < 2) { + return null; + } + return parts[1].trim(); + } + + @Test + void resourceDomainHasNoCspHeaders(JenkinsRule j) throws Exception { + String resourceDomainUrl = j.getURL().toExternalForm().replace("localhost", RESOURCE_ROOT_URL_DOMAIN); + ResourceDomainConfiguration.get().setUrl(resourceDomainUrl); + + FreeStyleProject project = j.createFreeStyleProject(); + project.getBuildersList().add(new CreateFileBuilder("test.html", BASIC_HTML)); + j.buildAndAssertSuccess(project); + + try (JenkinsRule.WebClient wc = j.createWebClient().withRedirectEnabled(true)) { + // Access workspace file - should redirect to resource domain + Page workspacePage = wc.goTo("job/" + project.getName() + "/ws/test.html", HTML_CONTENT_TYPE); + WebResponse response = workspacePage.getWebResponse(); + assertThat(response.getWebRequest().getUrl().getHost(), equalTo(RESOURCE_ROOT_URL_DOMAIN)); + + assertThat(response.getStatusCode(), is(200)); + assertThat(getCspHeaderFromResponse(response), nullValue()); + assertThat(getHeaderFromResponse(response, "Reporting-Endpoints"), nullValue()); + } + } + +} diff --git a/test/src/test/java/jenkins/security/csp/impl/CspFilterTest.java b/test/src/test/java/jenkins/security/csp/impl/CspFilterTest.java new file mode 100644 index 000000000000..38353b83e254 --- /dev/null +++ b/test/src/test/java/jenkins/security/csp/impl/CspFilterTest.java @@ -0,0 +1,173 @@ +package jenkins.security.csp.impl; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.startsWith; +import static org.jvnet.hudson.test.LoggerRule.recorded; + +import hudson.security.csrf.CrumbExclusion; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URL; +import java.util.logging.Level; +import jenkins.model.JenkinsLocationConfiguration; +import jenkins.util.HttpServletFilter; +import org.hamcrest.Matcher; +import org.htmlunit.HttpMethod; +import org.htmlunit.WebRequest; +import org.htmlunit.WebResponse; +import org.junit.jupiter.api.Test; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.LoggerRule; +import org.jvnet.hudson.test.TestExtension; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; + +@WithJenkins +public class CspFilterTest { + @Test + void testFilterHandlesRequestWithoutRootUrl(JenkinsRule j) throws Exception { + // Reset URL so we do not fall back to configured #getRootUrl + JenkinsLocationConfiguration.get().setUrl(null); + assertCspHeadersForUrl(j, HttpMethod.GET, "test-filter/some-path", + equalTo("This is a test filter response."), + equalTo("base-uri 'none'; default-src 'self'; form-action 'self'; frame-ancestors 'self'; img-src 'self' data:; script-src 'report-sample' 'self'; style-src 'report-sample' 'self' 'unsafe-inline';"), + nullValue(String.class)); + } + + @Test + void testFilterHandlesRequestWithRootUrl(JenkinsRule j) throws Exception { + assertCspHeadersForUrl(j, HttpMethod.GET, "test-filter/some-path", + equalTo("This is a test filter response."), + startsWith( + "base-uri 'none'; default-src 'self'; form-action 'self'; frame-ancestors 'self'; img-src 'self' data:; " + + "script-src 'report-sample' 'self'; style-src 'report-sample' 'self' 'unsafe-inline'; report-to content-security-policy; report-uri http://localhost"), + startsWith("content-security-policy: http://localhost")); + } + + @Test + void testCrumbExclusionHandlesRequestWithoutRootUrl(JenkinsRule j) throws Exception { + // Reset URL so we do not fall back to configured #getRootUrl + JenkinsLocationConfiguration.get().setUrl(null); + + assertCspHeadersForUrl(j, HttpMethod.POST, "test-crumb-exclusion/some-path", + equalTo("This is a test crumb exclusion response."), + equalTo("base-uri 'none'; default-src 'self'; form-action 'self'; frame-ancestors 'self'; img-src 'self' data:; script-src 'report-sample' 'self'; style-src 'report-sample' 'self' 'unsafe-inline';"), + nullValue(String.class)); + } + + @Test + void testCrumbExclusionHandlesRequestWithRootUrl(JenkinsRule j) throws Exception { + assertCspHeadersForUrl(j, HttpMethod.POST, "test-crumb-exclusion/some-path", + equalTo("This is a test crumb exclusion response."), + startsWith( + "base-uri 'none'; default-src 'self'; form-action 'self'; frame-ancestors 'self'; img-src 'self' data:; " + + "script-src 'report-sample' 'self'; style-src 'report-sample' 'self' 'unsafe-inline'; report-to content-security-policy; report-uri http://localhost"), + startsWith("content-security-policy: http://localhost")); + } + + @Test + void testFilterWithCustomCsp(JenkinsRule j) throws Exception { + LoggerRule loggerRule = new LoggerRule().record(CspFilter.class, Level.FINE).capture(100); + assertCspHeadersForUrl(j, HttpMethod.GET, "test-filter-with-csp/some-path", + equalTo("This is a test filter response with custom CSP."), + equalTo("default-src 'self';"), + startsWith("content-security-policy: http://localhost")); + assertThat(loggerRule, recorded(Level.FINE, allOf( + startsWith( + "CSP header has unexpected differences: Expected 'base-uri 'none'; default-src 'self'; form-action 'self'; frame-ancestors 'self'; img-src 'self' data:; " + + "script-src 'report-sample' 'self'; style-src 'report-sample' 'self' 'unsafe-inline'; report-to content-security-policy; report-uri http://localhost:"), + endsWith(":YW5vbnltb3Vz::L3Rlc3QtZmlsdGVyLXdpdGgtY3NwL3NvbWUtcGF0aA==' but got 'default-src 'self';'")))); + } + + @Test + void testFilterWithoutCsp(JenkinsRule j) throws Exception { + LoggerRule loggerRule = new LoggerRule().record(CspFilter.class, Level.FINE).capture(100); + assertCspHeadersForUrl(j, HttpMethod.GET, "test-filter-without-csp/some-path", + equalTo("This is a test filter response without CSP."), + nullValue(String.class), + startsWith("content-security-policy: http://localhost")); + assertThat(loggerRule, recorded(Level.FINE, allOf( + startsWith( + "CSP header has unexpected differences: Expected 'base-uri 'none'; default-src 'self'; form-action 'self'; frame-ancestors 'self'; img-src 'self' data:; " + + "script-src 'report-sample' 'self'; style-src 'report-sample' 'self' 'unsafe-inline'; report-to content-security-policy; report-uri http://localhost:"), + endsWith(":YW5vbnltb3Vz::L3Rlc3QtZmlsdGVyLXdpdGhvdXQtY3NwL3NvbWUtcGF0aA==' but got 'null'")))); + } + + @TestExtension + public static class TestFilter implements HttpServletFilter { + @Override + public boolean handle(HttpServletRequest req, HttpServletResponse rsp) throws IOException { + if (req.getRequestURI().substring(req.getContextPath().length()).startsWith("/test-filter/")) { + rsp.setStatus(200); + rsp.setContentType("text/plain"); + rsp.getWriter().write("This is a test filter response."); + return true; + } + return false; + } + } + + @TestExtension + public static class TestCrumbExclusion extends CrumbExclusion { + @Override + public boolean process(HttpServletRequest req, HttpServletResponse rsp, FilterChain chain) throws IOException { + if (req.getRequestURI().substring(req.getContextPath().length()).startsWith("/test-crumb-exclusion/")) { + rsp.setStatus(200); + rsp.setContentType("text/plain"); + rsp.getWriter().write("This is a test crumb exclusion response."); + return true; + } + return false; + } + } + + @TestExtension + public static class TestFilterWithCustomCsp implements HttpServletFilter { + @Override + public boolean handle(HttpServletRequest req, HttpServletResponse rsp) throws IOException, ServletException { + if (req.getRequestURI().substring(req.getContextPath().length()).startsWith("/test-filter-with-csp/")) { + rsp.setHeader("Content-Security-Policy", "default-src 'self';"); + rsp.setStatus(200); + rsp.setContentType("text/plain"); + rsp.getWriter().write("This is a test filter response with custom CSP."); + return true; + } + return false; + } + } + + @TestExtension + public static class TestFilterWithoutCsp implements HttpServletFilter { + @Override + public boolean handle(HttpServletRequest req, HttpServletResponse rsp) throws IOException, ServletException { + if (req.getRequestURI().substring(req.getContextPath().length()).startsWith("/test-filter-without-csp/")) { + rsp.setHeader("Content-Security-Policy", null); + rsp.setStatus(200); + rsp.setContentType("text/plain"); + rsp.getWriter().write("This is a test filter response without CSP."); + return true; + } + return false; + } + } + + private static void assertCspHeadersForUrl(JenkinsRule jenkinsRule, HttpMethod method, String path, + Matcher responseBodyMatcher, Matcher cspHeaderMatcher, Matcher reportingEndpointsHeaderMatcher) throws IOException { + try (JenkinsRule.WebClient wc = jenkinsRule.createWebClient()) { + WebRequest webRequest = new WebRequest(new URL(jenkinsRule.getURL() + path), method); + WebResponse response = wc.getPage(webRequest).getWebResponse(); + assertThat(response.getStatusCode(), equalTo(200)); + assertThat(response.getContentAsString().trim(), responseBodyMatcher); + + final String cspHeader = response.getResponseHeaderValue("Content-Security-Policy"); + assertThat(cspHeader, cspHeaderMatcher); + assertThat(response.getResponseHeaderValue("Reporting-Endpoints"), reportingEndpointsHeaderMatcher); + } + } +} diff --git a/test/src/test/java/jenkins/security/csp/impl/CspHeaderDeciderTest.java b/test/src/test/java/jenkins/security/csp/impl/CspHeaderDeciderTest.java new file mode 100644 index 000000000000..ef2e6c79f710 --- /dev/null +++ b/test/src/test/java/jenkins/security/csp/impl/CspHeaderDeciderTest.java @@ -0,0 +1,230 @@ +package jenkins.security.csp.impl; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import hudson.ExtensionList; +import hudson.Main; +import java.io.IOException; +import java.util.Optional; +import java.util.Properties; +import jenkins.security.csp.CspHeaderDecider; +import org.hamcrest.Matcher; +import org.htmlunit.Page; +import org.htmlunit.html.DomElement; +import org.htmlunit.html.HtmlCheckBoxInput; +import org.htmlunit.html.HtmlFormUtil; +import org.htmlunit.html.HtmlPage; +import org.junit.jupiter.api.Test; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; +import org.xml.sax.SAXException; + +@WithJenkins +public class CspHeaderDeciderTest { + + @Test + public void testDefaultInTest(JenkinsRule j) { + try (JenkinsRule.WebClient webClient = j.createWebClient()) { + final Optional decider = CspHeaderDecider.getCurrentDecider(); + assertTrue(decider.isPresent()); + assertThat(decider.get(), instanceOf(DevelopmentHeaderDecider.class)); + + assertFalse(ExtensionList.lookupSingleton(CspRecommendation.class).isActivated()); + + final HtmlPage htmlPage = webClient.goTo("configureSecurity"); + assertThat( + htmlPage.getWebResponse().getContentAsString(), + hasBlurb(jellyResource(DevelopmentHeaderDecider.class, "message.properties"))); + assertThat(htmlPage.getWebResponse().getResponseHeaderValue("Content-Security-Policy"), not(nullValue())); + assertThat(htmlPage.getWebResponse().getResponseHeaderValue("Content-Security-Policy-Report-Only"), nullValue()); + + // submit form and confirm this didn't create a config file + htmlPage.getFormByName("config").submit(htmlPage.getFormByName("config").getButtonByName("Submit")); + assertFalse(ExtensionList.lookupSingleton(CspConfiguration.class).getConfigFile().exists()); + } catch (IOException | SAXException e) { + throw new RuntimeException(e); + } + } + + @Test + public void testDefaultWithSystemPropertyEnforce(JenkinsRule j) throws IOException, SAXException { + System.setProperty(SystemPropertyHeaderDecider.SYSTEM_PROPERTY_NAME, "Content-Security-Policy"); + try (JenkinsRule.WebClient webClient = j.createWebClient()) { + final Optional decider = CspHeaderDecider.getCurrentDecider(); + assertTrue(decider.isPresent()); + assertThat(decider.get(), instanceOf(SystemPropertyHeaderDecider.class)); + + assertFalse(ExtensionList.lookupSingleton(CspRecommendation.class).isActivated()); + + final HtmlPage htmlPage = webClient.goTo("configureSecurity"); + assertThat( + htmlPage.getWebResponse().getContentAsString().replace("Content-Security-Policy", "{0}"), + hasBlurb(jellyResource(SystemPropertyHeaderDecider.class, "message.properties"))); + assertThat(htmlPage.getWebResponse().getResponseHeaderValue("Content-Security-Policy"), not(nullValue())); + assertThat(htmlPage.getWebResponse().getResponseHeaderValue("Content-Security-Policy-Report-Only"), nullValue()); + + // submit form and confirm this didn't create a config file + htmlPage.getFormByName("config").submit(htmlPage.getFormByName("config").getButtonByName("Submit")); + assertFalse(ExtensionList.lookupSingleton(CspConfiguration.class).getConfigFile().exists()); + } finally { + System.clearProperty(SystemPropertyHeaderDecider.SYSTEM_PROPERTY_NAME); + } + } + + @Test + public void testDefaultWithSystemPropertyUnenforce(JenkinsRule j) throws IOException, SAXException { + System.setProperty(SystemPropertyHeaderDecider.SYSTEM_PROPERTY_NAME, "Content-Security-Policy-Report-Only"); + try (JenkinsRule.WebClient webClient = j.createWebClient()) { + final Optional decider = CspHeaderDecider.getCurrentDecider(); + assertTrue(decider.isPresent()); + assertThat(decider.get(), instanceOf(SystemPropertyHeaderDecider.class)); + + assertFalse(ExtensionList.lookupSingleton(CspRecommendation.class).isActivated()); + + final HtmlPage htmlPage = webClient.goTo("configureSecurity"); + assertThat( + htmlPage.getWebResponse().getContentAsString().replace("Content-Security-Policy-Report-Only", "{0}"), + hasBlurb(jellyResource(SystemPropertyHeaderDecider.class, "message.properties"))); + assertThat(htmlPage.getWebResponse().getResponseHeaderValue("Content-Security-Policy"), nullValue()); + assertThat(htmlPage.getWebResponse().getResponseHeaderValue("Content-Security-Policy-Report-Only"), not(nullValue())); + + // submit form and confirm this didn't create a config file + htmlPage.getFormByName("config").submit(htmlPage.getFormByName("config").getButtonByName("Submit")); + assertFalse(ExtensionList.lookupSingleton(CspConfiguration.class).getConfigFile().exists()); + } finally { + System.clearProperty(SystemPropertyHeaderDecider.SYSTEM_PROPERTY_NAME); + } + } + + @Test + public void testDefaultWithSystemPropertyWrong(JenkinsRule j) throws IOException, SAXException { + System.setProperty(SystemPropertyHeaderDecider.SYSTEM_PROPERTY_NAME, "Some-Other-Value"); + try (JenkinsRule.WebClient webClient = j.createWebClient()) { + final Optional decider = CspHeaderDecider.getCurrentDecider(); + assertTrue(decider.isPresent()); + assertThat(decider.get(), instanceOf(DevelopmentHeaderDecider.class)); + + assertFalse(ExtensionList.lookupSingleton(CspRecommendation.class).isActivated()); + + final HtmlPage htmlPage = webClient.goTo("configureSecurity"); + assertThat( + htmlPage.getWebResponse().getContentAsString(), + allOf( + hasBlurb(jellyResource(DevelopmentHeaderDecider.class, "message.properties")), + not(hasBlurb(jellyResource(SystemPropertyHeaderDecider.class, "message.properties"))))); + assertThat(htmlPage.getWebResponse().getResponseHeaderValue("Content-Security-Policy"), not(nullValue())); + assertThat(htmlPage.getWebResponse().getResponseHeaderValue("Content-Security-Policy-Report-Only"), nullValue()); + + // submit form and confirm this didn't create a config file + htmlPage.getFormByName("config").submit(htmlPage.getFormByName("config").getButtonByName("Submit")); + assertFalse(ExtensionList.lookupSingleton(CspConfiguration.class).getConfigFile().exists()); + } finally { + System.clearProperty(SystemPropertyHeaderDecider.SYSTEM_PROPERTY_NAME); + } + } + + @Test + public void testFallback(JenkinsRule j) throws IOException, SAXException { + // This would be more convincing with a "real" Jenkins run, but it doesn't look like JTH allows for not setting these flags. + Main.isDevelopmentMode = false; + Main.isUnitTest = false; + try (JenkinsRule.WebClient webClient = j.createWebClient()) { + final Optional decider = CspHeaderDecider.getCurrentDecider(); + assertTrue(decider.isPresent()); + assertThat(decider.get(), instanceOf(FallbackDecider.class)); + + assertTrue(ExtensionList.lookupSingleton(CspRecommendation.class).isActivated()); + + final HtmlPage htmlPage = webClient.goTo("configureSecurity"); + assertThat( + // Workaround to placeholder for context path in this string + htmlPage.getWebResponse().getContentAsString().replace("/jenkins/", "{0}/"), + hasBlurb(jellyResource(FallbackDecider.class, "message.properties"))); + assertThat(htmlPage.getWebResponse().getResponseHeaderValue("Content-Security-Policy"), nullValue()); + assertThat(htmlPage.getWebResponse().getResponseHeaderValue("Content-Security-Policy-Report-Only"), not(nullValue())); + + // submit form and confirm this didn't create a config file + htmlPage.getFormByName("config").submit(htmlPage.getFormByName("config").getButtonByName("Submit")); + assertFalse(ExtensionList.lookupSingleton(CspConfiguration.class).getConfigFile().exists()); + } finally { + Main.isDevelopmentMode = true; + Main.isUnitTest = true; + } + } + + @Test + public void testFallbackAdminMonitorAndSetup(JenkinsRule j) throws IOException, SAXException { + // This needs to be done by disabling DevelopmentHeaderDecider, otherwise HtmlUnit will throw in + // https://github.com/jenkinsci/jenkins/blob/320a149f7640d31f4fb7c4ee8eee81124cd6c588/src/main/js/components/search-bar/index.js#L93 + DevelopmentHeaderDecider.DISABLED = true; + try (JenkinsRule.WebClient webClient = j.createWebClient()) { + final Optional decider = CspHeaderDecider.getCurrentDecider(); + assertTrue(decider.isPresent()); + assertThat(decider.get(), instanceOf(FallbackDecider.class)); + + assertTrue(ExtensionList.lookupSingleton(CspRecommendation.class).isActivated()); + + final HtmlPage htmlPage = webClient.goTo("manage/"); + final Page recommendationClick = htmlPage.getElementByName("more").click(); + assertThat(recommendationClick, instanceOf(HtmlPage.class)); + HtmlPage recommendationPage = (HtmlPage) recommendationClick; + assertThat( + // Workaround to placeholder for context path in this string + recommendationPage.getUrl().getPath(), + is(j.contextPath + "/manage/administrativeMonitor/jenkins.security.csp.impl.CspRecommendation/")); + assertThat(htmlPage.getWebResponse().getResponseHeaderValue("Content-Security-Policy"), nullValue()); + assertThat(htmlPage.getWebResponse().getResponseHeaderValue("Content-Security-Policy-Report-Only"), not(nullValue())); + + assertTrue(ExtensionList.lookupSingleton(CspRecommendation.class).isActivated()); + + final Page setupClick = recommendationPage.getElementByName("setup").click(); + assertThat(setupClick, instanceOf(HtmlPage.class)); + final HtmlPage setupPage = (HtmlPage) setupClick; + + // Once we select an option on this page, the admin monitor gets deactivated + assertFalse(ExtensionList.lookupSingleton(CspRecommendation.class).isActivated()); + + // We can see the checkbox now + assertThat(setupPage.getWebResponse().getContentAsString(), containsString("Enforce Content Security Policy")); + assertThat(setupPage.getWebResponse().getResponseHeaderValue("Content-Security-Policy"), nullValue()); + assertThat(setupPage.getWebResponse().getResponseHeaderValue("Content-Security-Policy-Report-Only"), not(nullValue())); + + // check the box + final DomElement enforceCheckbox = setupPage.getElementByName("_.enforce"); + assertThat(enforceCheckbox, instanceOf(HtmlCheckBoxInput.class)); + enforceCheckbox.click(); + + // no config file yet + assertFalse(ExtensionList.lookupSingleton(CspConfiguration.class).getConfigFile().exists()); + + final Page afterSavingPage = HtmlFormUtil.submit(setupPage.getFormByName("config"), setupPage.getFormByName("config").getButtonByName("Submit")); + assertThat(afterSavingPage, instanceOf(HtmlPage.class)); + assertThat(afterSavingPage.getUrl().getPath(), is(j.contextPath + "/manage/")); + assertThat(afterSavingPage.getWebResponse().getResponseHeaderValue("Content-Security-Policy"), not(nullValue())); + assertThat(afterSavingPage.getWebResponse().getResponseHeaderValue("Content-Security-Policy-Report-Only"), nullValue()); + + // confirm that submitting created a config file + assertTrue(ExtensionList.lookupSingleton(CspConfiguration.class).getConfigFile().exists()); + } finally { + DevelopmentHeaderDecider.DISABLED = false; + } + } + + private static Matcher hasBlurb(Properties props) { + return containsString(props.getProperty("blurb")); + } + + private static Properties jellyResource(Class clazz, String filename) throws IOException { + Properties props = new Properties(); + props.load(clazz.getResourceAsStream(clazz.getSimpleName() + "/" + filename)); + return props; + } +} diff --git a/test/src/test/java/jenkins/security/csp/impl/ReportingActionTest.java b/test/src/test/java/jenkins/security/csp/impl/ReportingActionTest.java new file mode 100644 index 000000000000..0bdb51fc69ed --- /dev/null +++ b/test/src/test/java/jenkins/security/csp/impl/ReportingActionTest.java @@ -0,0 +1,404 @@ +package jenkins.security.csp.impl; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; + +import hudson.ExtensionList; +import hudson.model.User; +import hudson.security.ACL; +import hudson.security.ACLContext; +import java.net.URL; +import java.util.logging.Level; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import jenkins.model.Jenkins; +import jenkins.security.csp.CspReceiver; +import jenkins.security.csp.ReportingContext; +import net.sf.json.JSONObject; +import org.htmlunit.HttpMethod; +import org.htmlunit.WebRequest; +import org.htmlunit.WebResponse; +import org.htmlunit.html.HtmlPage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.jvnet.hudson.test.For; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.LoggerRule; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; +import org.springframework.security.core.Authentication; + +@For(ReportingAction.class) +@WithJenkins +class ReportingActionTest { + + private LoggerRule logger; + + @BeforeEach + void setup() { + logger = new LoggerRule(); + } + + @Test + void validReports(JenkinsRule j) throws Exception { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + + // Test with authenticated user + User alice = User.getById("alice", true); + String encodedAuth; + try (ACLContext ctx = ACL.as2(alice.impersonate2())) { + Authentication auth = Jenkins.getAuthentication2(); + encodedAuth = ReportingContext.encodeContext(Jenkins.class, auth, "/"); + } + + TestReceiver receiverAuth = new TestReceiver(); + ExtensionList.lookup(CspReceiver.class).add(receiverAuth); + + JSONObject reportAuth = new JSONObject(); + reportAuth.put("csp-report", new JSONObject().put("violated-directive", "script-src")); + + try (JenkinsRule.WebClient wc = j.createWebClient()) { + WebRequest request = new WebRequest(new URL(j.getURL(), ReportingAction.URL + "/" + encodedAuth), HttpMethod.POST); + request.setRequestBody(reportAuth.toString()); + request.setAdditionalHeader("Content-Type", "application/csp-report"); + + WebResponse response = wc.getPage(request).getWebResponse(); + assertThat(response.getStatusCode(), is(200)); + assertThat(receiverAuth.invoked, is(true)); + assertThat(receiverAuth.userId, is("alice")); + } + + { + // Test with anonymous user + String encodedAnon; + try (ACLContext ctx = ACL.as2(Jenkins.ANONYMOUS2)) { + Authentication auth = Jenkins.getAuthentication2(); + encodedAnon = ReportingContext.encodeContext(Jenkins.class, auth, "/"); + } + + TestReceiver receiverAnon = new TestReceiver(); + ExtensionList.lookup(CspReceiver.class).add(receiverAnon); + + JSONObject report = new JSONObject(); + report.put("csp-report", new JSONObject()); + + try (JenkinsRule.WebClient wc = j.createWebClient()) { + WebRequest request = new WebRequest(new URL(j.getURL(), ReportingAction.URL + "/" + encodedAnon), HttpMethod.POST); + request.setRequestBody(report.toString()); + wc.getPage(request); + + assertThat(receiverAnon.invoked, is(true)); + } + } + + { + // Test with system user + String encodedSystem; + try (ACLContext ctx = ACL.as2(ACL.SYSTEM2)) { + Authentication auth = Jenkins.getAuthentication2(); + encodedSystem = ReportingContext.encodeContext(Jenkins.class, auth, "/"); + } + + JSONObject report = new JSONObject(); + report.put("csp-report", new JSONObject()); + + try (JenkinsRule.WebClient wc = j.createWebClient()) { + WebRequest request = new WebRequest(new URL(j.getURL(), ReportingAction.URL + "/" + encodedSystem), HttpMethod.POST); + request.setRequestBody(report.toString()); + WebResponse response = wc.getPage(request).getWebResponse(); + + assertThat(response.getStatusCode(), is(200)); + } + } + } + + @Test + void multipleReceivers(JenkinsRule j) throws Exception { + TestReceiver receiver1 = new TestReceiver(); + TestReceiver receiver2 = new TestReceiver(); + ExtensionList receivers = ExtensionList.lookup(CspReceiver.class); + receivers.add(receiver1); + receivers.add(receiver2); + + String encoded; + try (ACLContext ctx = ACL.as2(ACL.SYSTEM2)) { + Authentication auth = Jenkins.getAuthentication2(); + encoded = ReportingContext.encodeContext(Jenkins.class, auth, "/"); + } + + JSONObject report = new JSONObject(); + report.put("csp-report", new JSONObject()); + + try (JenkinsRule.WebClient wc = j.createWebClient()) { + WebRequest request = new WebRequest(new URL(j.getURL(), ReportingAction.URL + "/" + encoded), HttpMethod.POST); + request.setRequestBody(report.toString()); + wc.getPage(request); + } + assertThat(receiver1.invoked, is(true)); + assertThat(receiver2.invoked, is(true)); + } + + @Test + void invalidReportBody(JenkinsRule j) throws Exception { + logger.record(ReportingAction.class, Level.FINE).capture(10); + + String encoded; + try (ACLContext unused = ACL.as2(ACL.SYSTEM2)) { + Authentication auth = Jenkins.getAuthentication2(); + encoded = ReportingContext.encodeContext(Jenkins.class, auth, "/"); + } + + try (JenkinsRule.WebClient wc = j.createWebClient()) { + wc.setThrowExceptionOnFailingStatusCode(false); + + { + WebRequest req = new WebRequest( + new URL(j.getURL(), ReportingAction.URL + "/" + encoded), HttpMethod.POST); + req.setRequestBody("{ invalid json ]"); + WebResponse rsp = wc.getPage(req).getWebResponse(); + assertThat(rsp.getStatusCode(), is(200)); // Graceful handling + // IOException from reading/parsing should be logged + assertThat(logger.getMessages(), hasItem(equalTo("Failed to parse JSON report for ViewContext[className=jenkins.model.Jenkins, viewName=/]: { invalid json ]"))); + } + + { + WebRequest req = new WebRequest( + new URL(j.getURL(), ReportingAction.URL + "/" + encoded), HttpMethod.POST); + // Don't set request body + WebResponse rsp = wc.getPage(req).getWebResponse(); + assertThat(rsp.getStatusCode(), is(200)); // Graceful handling + // Empty body causes JSON parsing error + assertThat(logger.getMessages(), hasItem(equalTo("Failed to parse JSON report for ViewContext[className=jenkins.model.Jenkins, viewName=/]: "))); + } + } + } + + @Test + void testNoCharsetDoNotCare(JenkinsRule j) throws Exception { + logger.record(ReportingAction.class, Level.FINE).capture(10); + + String encoded; + try (ACLContext unused = ACL.as2(ACL.SYSTEM2)) { + Authentication auth = Jenkins.getAuthentication2(); + encoded = ReportingContext.encodeContext(Jenkins.class, auth, "/"); + } + + try (JenkinsRule.WebClient wc = j.createWebClient()) { + // Null character encoding (no Content-Type header) - should work without error + { + JSONObject report = new JSONObject(); + report.put("csp-report", new JSONObject()); + WebRequest request3 = new WebRequest( + new URL(j.getURL(), ReportingAction.URL + "/" + encoded), HttpMethod.POST); + request3.setRequestBody(report.toString()); + request3.removeAdditionalHeader("Content-Type"); + WebResponse response3 = wc.getPage(request3).getWebResponse(); + assertThat(response3.getStatusCode(), is(200)); + // Valid JSON should parse successfully, no error logs expected + assertThat(logger.getMessages(), empty()); + } + } + } + + @Test + void reportBodyTooLong(JenkinsRule j) throws Exception { + logger.record(ReportingAction.class, Level.FINER).capture(10); + + String encoded; + try (ACLContext unused = ACL.as2(ACL.SYSTEM2)) { + Authentication auth = Jenkins.getAuthentication2(); + encoded = ReportingContext.encodeContext(Jenkins.class, auth, "/"); + } + + // Create report larger than MAX_REPORT_LENGTH (20KB) + JSONObject largeReport = new JSONObject(); + largeReport.put("data", "x".repeat(25 * 1024)); // 25KB + + try (JenkinsRule.WebClient wc = j.createWebClient()) { + WebRequest request = new WebRequest(new URL(j.getURL(), ReportingAction.URL + "/" + encoded), HttpMethod.POST); + request.setRequestBody(largeReport.toString()); + + WebResponse response = wc.getPage(request).getWebResponse(); + assertThat(response.getStatusCode(), is(200)); + + // Should log that max length was exceeded + assertThat(logger.getMessages(), hasItem(containsString("exceeded max length"))); + } + } + + @Test + void invalidContext(JenkinsRule j) throws Exception { + logger.record(ReportingAction.class, Level.FINE).capture(10); + + JSONObject report = new JSONObject(); + report.put("csp-report", new JSONObject()); + + try (JenkinsRule.WebClient wc = j.createWebClient()) { + wc.setThrowExceptionOnFailingStatusCode(false); + + // Malformed context (completely invalid) + String malformedContext = "not-a-valid-context"; + WebRequest request1 = new WebRequest( + new URL(j.getURL(), ReportingAction.URL + "/" + malformedContext), HttpMethod.POST); + request1.setRequestBody(report.toString()); + WebResponse response1 = wc.getPage(request1).getWebResponse(); + assertThat(response1.getStatusCode(), is(200)); // Graceful handling + assertThat(logger.getMessages(), hasItem(containsString("Unexpected rest of path failed to decode: not-a-valid-context"))); + + // Tampered context (valid structure but tampered MAC) + String encoded; + try (ACLContext ctx = ACL.as2(ACL.SYSTEM2)) { + Authentication auth = Jenkins.getAuthentication2(); + encoded = ReportingContext.encodeContext(Jenkins.class, auth, "/"); + } + String[] parts = encoded.split(":"); + String tampered = "AAAA:" + parts[1] + ":" + parts[2] + ":" + parts[3]; + + WebRequest request2 = new WebRequest( + new URL(j.getURL(), ReportingAction.URL + "/" + tampered), HttpMethod.POST); + request2.setRequestBody(report.toString()); + WebResponse response2 = wc.getPage(request2).getWebResponse(); + assertThat(response2.getStatusCode(), is(200)); + assertThat(logger.getMessages(), hasItem(containsString("Unexpected rest of path failed to decode: AAAA:U1lTVEVN:amVua2lucy5tb2RlbC5KZW5raW5z:Lw=="))); + } + } + + @Test + void brokenReceiverImplementation(JenkinsRule j) throws Exception { + logger.record(ReportingAction.class, Level.WARNING).capture(10); + + ThrowingReceiver throwingReceiver = new ThrowingReceiver(); + TestReceiver normalReceiver = new TestReceiver(); + + ExtensionList receivers = ExtensionList.lookup(CspReceiver.class); + receivers.add(0, throwingReceiver); // Add first + receivers.add(1, normalReceiver); + + String encoded; + try (ACLContext ctx = ACL.as2(ACL.SYSTEM2)) { + Authentication auth = Jenkins.getAuthentication2(); + encoded = ReportingContext.encodeContext(Jenkins.class, auth, "/"); + } + + JSONObject report = new JSONObject(); + report.put("csp-report", new JSONObject()); + + try (JenkinsRule.WebClient wc = j.createWebClient()) { + WebRequest request = new WebRequest( + new URL(j.getURL(), ReportingAction.URL + "/" + encoded), HttpMethod.POST); + request.setRequestBody(report.toString()); + + wc.getPage(request); + + // Normal receiver should still be invoked + assertThat(normalReceiver.invoked, is(true)); + // Exception should be logged + assertThat( + logger.getMessages(), + hasItem(containsString("Error reporting CSP for ViewContext[className=jenkins.model.Jenkins, viewName=/] to jenkins.security.csp.impl.ReportingActionTest$ThrowingReceiver@"))); + } + } + + @Test + void wrongHttpMethods(JenkinsRule j) throws Exception { + String encoded; + try (ACLContext ctx = ACL.as2(ACL.SYSTEM2)) { + Authentication auth = Jenkins.getAuthentication2(); + encoded = ReportingContext.encodeContext(Jenkins.class, auth, "/"); + } + + try (JenkinsRule.WebClient wc = j.createWebClient()) { + wc.setThrowExceptionOnFailingStatusCode(false); + + // GET should fail + WebRequest requestGet = new WebRequest( + new URL(j.getURL(), ReportingAction.URL + "/" + encoded), HttpMethod.GET); + WebResponse responseGet = wc.getPage(requestGet).getWebResponse(); + assertThat(responseGet.getStatusCode(), not(is(200))); + + // PUT should fail + WebRequest requestPut = new WebRequest( + new URL(j.getURL(), ReportingAction.URL + "/" + encoded), HttpMethod.PUT); + WebResponse responsePut = wc.getPage(requestPut).getWebResponse(); + assertThat(responsePut.getStatusCode(), not(is(200))); + } + } + + @Test + void loggingReceiverWorks(JenkinsRule j) throws Exception { + logger.record(LoggingReceiver.class, Level.FINEST).capture(100); + + // Navigate to a page and extract CSP reporting endpoint + try (JenkinsRule.WebClient wc = j.createWebClient()) { + HtmlPage page = wc.goTo(""); + String cspHeader = page.getWebResponse().getResponseHeaderValue("Content-Security-Policy"); + + assertThat(cspHeader, notNullValue()); + + String reportUri = extractReportUri(cspHeader); + assertThat(reportUri, notNullValue()); + + // Submit a CSP violation report + JSONObject violation = new JSONObject(); + JSONObject cspReport = new JSONObject(); + cspReport.put("document-uri", j.getURL().toString()); + cspReport.put("violated-directive", "script-src 'self'"); + cspReport.put("effective-directive", "script-src"); + cspReport.put("original-policy", cspHeader); + cspReport.put("blocked-uri", "https://evil.com/script.js"); + violation.put("csp-report", cspReport); + + WebRequest request = new WebRequest(new URL(reportUri), HttpMethod.POST); + request.setRequestBody(violation.toString()); + request.setAdditionalHeader("Content-Type", "application/csp-report"); + + WebResponse response = wc.getPage(request).getWebResponse(); + assertThat(response.getStatusCode(), is(200)); + + // Verify it was logged + assertThat(logger.getMessages(), hasItem( + allOf( + containsString("Received anonymous report for context ViewContext[className=hudson.model.AllView, viewName=]: " + + "{\"csp-report\":{\"document-uri\":\"" + j.getURL().toExternalForm() + + "\",\"violated-directive\":\"script-src 'self'\",\"effective-directive\":\"script-src\",\"original-policy\":\"base-uri 'none'; default-src 'self'; form-action 'self'; frame-ancestors 'self'; " + + "img-src 'self' data:; script-src 'report-sample' 'self'; style-src 'report-sample' 'self' 'unsafe-inline'; report-to content-security-policy; report-uri " + j.getURL().toExternalForm() + + "content-security-policy-reporting-endpoint/"), + containsString(":YW5vbnltb3Vz:aHVkc29uLm1vZGVsLkFsbFZpZXc=:\",\"blocked-uri\":\"https://evil.com/script.js\"}}")))); + } + } + + private String extractReportUri(String cspHeader) { + // Extract report-uri from CSP header + Pattern pattern = Pattern.compile("report-uri\\s+([^;]+)"); + Matcher matcher = pattern.matcher(cspHeader); + return matcher.find() ? matcher.group(1).trim() : null; + } + + private static class TestReceiver implements CspReceiver { + boolean invoked; + String userId; + ViewContext viewContext; + JSONObject report; + + @Override + public void report(ViewContext viewContext, String userId, JSONObject report) { + this.invoked = true; + this.userId = userId; + this.viewContext = viewContext; + this.report = report; + } + } + + private static class ThrowingReceiver implements CspReceiver { + @Override + public void report(ViewContext viewContext, String userId, JSONObject report) { + throw new RuntimeException("Test exception from receiver"); + } + } +}