Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion core/src/main/java/jenkins/security/csp/CspHeader.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

package jenkins.security.csp;

import edu.umd.cs.findbugs.annotations.CheckForNull;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.Beta;

Expand All @@ -35,14 +36,16 @@
@Restricted(Beta.class)
public enum CspHeader {
ContentSecurityPolicy("Content-Security-Policy"),
ContentSecurityPolicyReportOnly("Content-Security-Policy-Report-Only");
ContentSecurityPolicyReportOnly("Content-Security-Policy-Report-Only"),
None(null);

private final String headerName;

CspHeader(String headerName) {
this.headerName = headerName;
}

@CheckForNull
public String getHeaderName() {
return headerName;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,11 @@ public String getReportingEndpointsHeaderValue(HttpServletRequest req) {
}

/**
* Determines the name of the HTTP header to set.
* Determines the name of the HTTP header to set, or {@code null} if none.
*
* @return the name of the HTTP header to set.
*/
@CheckForNull
public String getContentSecurityPolicyHeaderName() {
final Optional<CspHeaderDecider> decider = CspHeaderDecider.getCurrentDecider();
if (decider.isPresent()) {
Expand Down
20 changes: 12 additions & 8 deletions core/src/main/java/jenkins/security/csp/impl/CspFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,14 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha

CspDecorator cspDecorator = ExtensionList.lookupSingleton(CspDecorator.class);
final String headerName = cspDecorator.getContentSecurityPolicyHeaderName();
final boolean headerShouldBeSet = headerName != null;

// 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) {

if (headerShouldBeSet && !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) {
Expand All @@ -78,14 +80,16 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha
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 + "'");
if (headerShouldBeSet) {
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);
}
} catch (RuntimeException e) {
// Be defensive just in case
LOGGER.log(Level.FINER, "Error checking CSP header after request processing", e);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
package jenkins.security.csp.impl;

import hudson.Extension;
import hudson.Util;
import java.util.Arrays;
import java.util.Objects;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
Expand All @@ -50,7 +52,8 @@ public Optional<CspHeader> 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();
final String expected = Util.fixEmptyAndTrim(systemProperty);
return Arrays.stream(CspHeader.values()).filter(h -> Objects.equals(expected, h.getHeaderName())).findFirst();
}
return Optional.empty();
}
Expand Down
105 changes: 105 additions & 0 deletions core/src/main/java/jenkins/telemetry/impl/ContentSecurityPolicy.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* 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.telemetry.impl;

import edu.umd.cs.findbugs.annotations.NonNull;
import hudson.Extension;
import hudson.ExtensionList;
import java.time.LocalDate;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import jenkins.security.csp.AdvancedConfigurationDescriptor;
import jenkins.security.csp.Contributor;
import jenkins.security.csp.CspBuilder;
import jenkins.security.csp.CspHeader;
import jenkins.security.csp.CspHeaderDecider;
import jenkins.security.csp.impl.CspConfiguration;
import jenkins.telemetry.Telemetry;
import net.sf.json.JSONObject;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;

/**
* Collect information about Content Security Policy configuration.
*
*/
@Restricted(NoExternalUse.class)
@Extension
public class ContentSecurityPolicy extends Telemetry {

private static final Logger LOGGER = Logger.getLogger(ContentSecurityPolicy.class.getName());

@NonNull
@Override
public String getDisplayName() {
return "Content Security Policy";

Check warning on line 62 in core/src/main/java/jenkins/telemetry/impl/ContentSecurityPolicy.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 62 is not covered by tests
}

@NonNull
@Override
public LocalDate getStart() {
return LocalDate.of(2025, 12, 1);
}

@NonNull
@Override
public LocalDate getEnd() {
return LocalDate.of(2026, 6, 1);
}

@Override
public JSONObject createContent() {
final JSONObject data = new JSONObject();
data.put("enforce", ExtensionList.lookupSingleton(CspConfiguration.class).isEnforce());
final Optional<CspHeaderDecider> decider = CspHeaderDecider.getCurrentDecider();
data.put("decider", decider.map(Object::getClass).map(Class::getName).orElse(null));
data.put("header", decider.map(CspHeaderDecider::decide).filter(Optional::isPresent).map(Optional::get).map(CspHeader::getHeaderName).orElse(null));

Set<String> contributors = new TreeSet<>();
ExtensionList.lookup(Contributor.class).stream().map(Contributor::getClass).map(Class::getName).forEach(contributors::add);
data.put("contributors", contributors);

Set<String> configurations = new TreeSet<>();
ExtensionList.lookup(AdvancedConfigurationDescriptor.class).stream().map(AdvancedConfigurationDescriptor::getClass).map(Class::getName).forEach(configurations::add);
data.put("configurations", configurations);

try {
Map<String, Map<String, Integer>> directivesSize = new CspBuilder().withDefaultContributions().getMergedDirectives().stream()
.map(d -> Map.entry(d.name(), Map.of("entries", d.values().size(), "chars", String.join(" ", d.values()).length())))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
data.put("directivesSize", directivesSize);
} catch (RuntimeException ex) {
LOGGER.log(Level.FINE, "Error during directive processing", ex);

Check warning on line 99 in core/src/main/java/jenkins/telemetry/impl/ContentSecurityPolicy.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 98-99 are not covered by tests
}

data.put("components", buildComponentInformation());
return data;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,13 @@ public String getDisplayName() {
@NonNull
@Override
public LocalDate getStart() {
return LocalDate.of(2023, 12, 17);
return LocalDate.of(2026, 1, 4);
}

@NonNull
@Override
public LocalDate getEnd() {
return LocalDate.of(2024, 4, 1);
return LocalDate.of(2026, 4, 1);
}

@Override
Expand Down
2 changes: 1 addition & 1 deletion core/src/main/resources/hudson/model/Run/console-log.jelly
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
</j:when>
<!-- output is completed now. -->
<j:otherwise>
<pre class="console-output">
<pre id="out" class="console-output">
<st:getOutput var="output" />
<j:whitespace>${it.writeLogTo(offset,output)}</j:whitespace>
</pre>
Expand Down
7 changes: 4 additions & 3 deletions core/src/main/resources/hudson/slaves/Cloud/sidepanel.jelly
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@ THE SOFTWARE.
<l:header />
<l:side-panel>
<l:tasks>
<l:task contextMenu="false" href="." icon="symbol-computer" title="${%Status}"/>
<l:task href="configure" icon="symbol-settings"
<j:set var="url" value="${h.getNearestAncestorUrl(request2,it)}"/>
<l:task contextMenu="false" href="${url}/" icon="symbol-computer" title="${%Status}"/>
<l:task href="${url}/configure" icon="symbol-settings"
title="${app.hasPermission(app.ADMINISTER) ? '%Configure' : '%View Configuration'}"/>
<l:delete permission="${app.ADMINISTER}" title="${%Delete Cloud}" message="${%delete.cloud(it.displayName)}"/>
<l:delete permission="${app.ADMINISTER}" title="${%Delete Cloud}" message="${%delete.cloud(it.displayName)}" urlPrefix="${url}"/>
<t:actions />
</l:tasks>
<j:forEach var="action" items="${it.allActions}">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ THE SOFTWARE.
-->
<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler">
<st:setHeader name="${it.getContentSecurityPolicyHeaderName()}" value="${it.getContentSecurityPolicyHeaderValue(request2)}" />
<st:setHeader name="Reporting-Endpoints" value="${it.getReportingEndpointsHeaderValue(request2)}" />
<j:set var="cspHeaderName" value="${it.getContentSecurityPolicyHeaderName()}"/>
<j:if test="${cspHeaderName != null}">
<st:setHeader name="${cspHeaderName}" value="${it.getContentSecurityPolicyHeaderValue(request2)}" />
<st:setHeader name="Reporting-Endpoints" value="${it.getReportingEndpointsHeaderValue(request2)}" />
</j:if>
</j:jelly>
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,14 @@ THE SOFTWARE.
-->
<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core" xmlns:f="/lib/form">
<f:description>${%blurb(it.headerName)}</f:description>
<f:description>
<j:choose>
<j:when test="${it.headerName != null}">
${%blurb(it.headerName)}
</j:when>
<j:otherwise>
${%blurbUnset}
</j:otherwise>
</j:choose>
</f:description>
</j:jelly>
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
blurb = Content Security Policy is configured to use the HTTP header <code>{0}</code> based on the system property <code>jenkins.security.csp.CspHeader.headerName</code>. \
It cannot be configured through the UI while this system property specifies a header name.
It cannot be configured through the UI while this system property is set.
blurbUnset = Content Security Policy is disabled based on the system property <code>jenkins.security.csp.CspHeader.headerName</code>. \
It cannot be configured through the UI while this system property is set.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core">
This trial collects basic information about the current configuration of Content Security Policy (CSP):
<ul>
<li>Whether the user-facing configuration option is set to enforce CSP</li>
<li>The active <code>CspHeaderDecider</code>, which roughly corresponds to how Jenkins is run and whether system properties related to CSP are set</li>
<li>The current Content Security Policy header (<code>Content-Security-Policy</code> or <code>Content-Security-Policy-Report-Only</code>)</li>
<li>Which <code>Contributor</code>s are known, i.e., extensions that contribute to the CSP rules</li>
<li>Which <code>AdvancedConfiguration</code>s are known, i.e., extensions that add CSP configuration options</li>
<li>Which directives (e.g., <code>img-src</code>) are set, and the length in bytes of their values</li>
</ul>

<p>
<strong>This trial does not collect the actual CSP rules.</strong>
</p>
<p>
Additionally this trial collects the list of installed plugins, their version, and the version of Jenkins.
This data will be used to understand how widely CSP protection is used.
</p>
</j:jelly>
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ THE SOFTWARE.
<spotless.check.skip>false</spotless.check.skip>
<ban-junit4-imports.skip>false</ban-junit4-imports.skip>
<!-- Make sure to keep the jetty-ee9-maven-plugin version in war/pom.xml in sync with the Jetty release in Winstone: -->
<winstone.version>8.1023.v8b_42b_1b_79b_f7</winstone.version>
<winstone.version>8.1029.vd3071f6b_5988</winstone.version>
<node.version>24.11.1</node.version>
</properties>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package jenkins.security.csp;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.greaterThan;
import static org.hamcrest.Matchers.hasLength;
import static org.hamcrest.Matchers.is;

import org.htmlunit.FailingHttpStatusCodeException;
import org.htmlunit.WebClient;
import org.htmlunit.html.HtmlPage;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.jvnet.hudson.test.junit.jupiter.RealJenkinsExtension;

public class WinstoneResponseHeaderLengthTest {

@RegisterExtension
public RealJenkinsExtension extension = new RealJenkinsExtension().addSyntheticPlugin(new RealJenkinsExtension.SyntheticPlugin(jenkins.security.csp.winstoneResponseHeaderLengthTest.ContributorImpl.class));

@Test
void testLength() throws Exception {
extension.startJenkins();
String lastHeader = "";
try (WebClient wc = new WebClient()) {
// Hopefully speed this up a bit:
wc.getOptions().setJavaScriptEnabled(false);
wc.getOptions().setCssEnabled(false);
wc.getOptions().setDownloadImages(false);
wc.getPage(extension.getUrl()); // request once outside try/catch to ensure it works in principle
try {
while (true) {
final HtmlPage htmlPage = wc.getPage(extension.getUrl());
lastHeader = htmlPage.getWebResponse().getResponseHeaderValue("Content-Security-Policy");
}
} catch (FailingHttpStatusCodeException e) {
assertThat(e.getStatusCode(), is(500));
assertThat(e.getResponse().getContentAsString(), containsString("Error 500 Response Header Fields Too Large"));

assertThat(lastHeader, hasLength(greaterThan(30_000)));
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,31 @@ public void testDefaultWithSystemPropertyUnenforce(JenkinsRule j) throws IOExcep
}
}

@Test
public void testDefaultWithSystemPropertyNone(JenkinsRule j) throws IOException, SAXException {
System.setProperty(SystemPropertyHeaderDecider.SYSTEM_PROPERTY_NAME, "");
try (JenkinsRule.WebClient webClient = j.createWebClient()) {
final Optional<CspHeaderDecider> 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(),
hasMessage(jellyResource(SystemPropertyHeaderDecider.class, "message.properties"), "blurbUnset"));
assertThat(htmlPage.getWebResponse().getResponseHeaderValue("Content-Security-Policy"), 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 testDefaultWithSystemPropertyWrong(JenkinsRule j) throws IOException, SAXException {
System.setProperty(SystemPropertyHeaderDecider.SYSTEM_PROPERTY_NAME, "Some-Other-Value");
Expand Down Expand Up @@ -219,7 +244,11 @@ public void testFallbackAdminMonitorAndSetup(JenkinsRule j) throws IOException,
}

private static Matcher<String> hasBlurb(Properties props) {
return containsString(props.getProperty("blurb"));
return hasMessage(props, "blurb");
}

private static Matcher<String> hasMessage(Properties props, String key) {
return containsString(props.getProperty(key));
}

private static Properties jellyResource(Class<?> clazz, String filename) throws IOException {
Expand Down
Loading
Loading