Security context
Medium· 5.4GHSA-7g95-jmg9-h524 CVE-2025-27624CWE-352Published Mar 6, 2025

Jenkins cross-site request forgery (CSRF) vulnerability

Research this vulnerability

Research is free — Hunters explains how the bug works, the root-cause code pattern, how the fix addresses it, and how to test whether a target is affected, in chat. Investigate & write exploit is a paid run — the engine reads the advisory and fix commits, then builds and validates a working proof-of-concept exploit with reproduction steps.

Affected versions

2.493 → fixed in 2.5000 → fixed in 2.492.2

Details

Jenkins 2.499 and earlier, LTS 2.492.1 and earlier does not require POST requests for the HTTP endpoint toggling collapsed/expanded status of sidepanel widgets (e.g., Build Queue and Build Executor Status widgets), resulting in a cross-site request forgery (CSRF) vulnerability. This vulnerability allows attackers to have users toggle their collapsed/expanded status of sidepanel widgets. Additionally, as the API accepts any string as the identifier of the panel ID to be toggled, attacker-controlled content can be stored in the victim’s user profile in Jenkins. Jenkins 2.500, LTS 2.492.2 requires POST requests for the affected HTTP endpoint.

The fix

[SECURITY-3498]

Daniel Beck· Feb 25, 2025, 04:17 PM+22684ef1a4d4d
core/src/main/java/jenkins/model/Jenkins.java+1 0
@@ -4165,6 +4165,7 @@ public synchronized HttpRedirect doCancelQuietDown() {
return new HttpRedirect(".");
}
+ @POST
public HttpResponse doToggleCollapse() throws ServletException, IOException {
final StaplerRequest2 request = Stapler.getCurrentRequest2();
final String paneId = request.getParameter("paneId");
core/src/main/resources/lib/hudson/executors.jelly+3 1
@@ -174,7 +174,9 @@ THE SOFTWARE.
${executorDetails}
</span>
</j:if>
- <a class="collapse" href="${rootURL}/toggleCollapse?paneId=executors"
+ <st:adjunct includes="lib.form.link.link"/>
+ <!-- TODO improve l:link so the `a` can be changed to `l:link`. -->
+ <a class="collapse post" href="${rootURL}/toggleCollapse?paneId=executors"
tooltip="${paneIsCollapsed ? '%Expand' : '%Collapse'}" data-tooltip-append-to-parent="true">
<j:set var="svgIconId" value="${paneIsCollapsed ? 'chevron-up' : 'chevron-down'}" />
<l:icon src="symbol-${svgIconId}" />
core/src/main/resources/lib/layout/pane.jelly+3 1
@@ -59,7 +59,9 @@ THE SOFTWARE.
</span>
<j:if test="${attrs.id != null}">
- <a class="collapse" href="${rootURL}/toggleCollapse?paneId=${attrs.id}"
+ <st:adjunct includes="lib.form.link.link"/>
+ <!-- TODO improve l:link so the `a` can be changed to `l:link`. -->
+ <a class="collapse post" href="${rootURL}/toggleCollapse?paneId=${attrs.id}"
title="${paneIsCollapsed ? '%expand' : '%collapse'}">
<j:set var="svgIconId" value="${paneIsCollapsed ? 'chevron-up' : 'chevron-down'}" />
test/src/test/java/hudson/model/ComputerSetTest.java+1 1
@@ -173,7 +173,7 @@ public void testTerminatedNodeAjaxExecutorsDoesNotShowTrace() throws Exception {
new OfflineCause.ChannelTermination(new RuntimeException(message))
);
- WebClient wc = j.createWebClient();
+ WebClient wc = j.createWebClient().withJavaScriptEnabled(false);
Page page = wc.getPage(wc.createCrumbedUrl(HasWidgetHelper.getWidget(j.jenkins.getComputer(), ExecutorsWidget.class).orElseThrow().getUrl() + "ajax"));
String content = page.getWebResponse().getContentAsString();
assertThat(content, not(containsString(message)));
test/src/test/java/hudson/model/ComputerTest.java+1 1
@@ -285,7 +285,7 @@ public void testTerminatedNodeAjaxExecutorsDoesNotShowTrace() throws Exception {
new OfflineCause.ChannelTermination(new RuntimeException(message))
);
- WebClient wc = j.createWebClient();
+ WebClient wc = j.createWebClient().withJavaScriptEnabled(false);
Page page = wc.getPage(wc.createCrumbedUrl(HasWidgetHelper.getWidget(agent.toComputer(), ExecutorsWidget.class).orElseThrow().getUrl() + "ajax"));
String content = page.getWebResponse().getContentAsString();
assertThat(content, not(containsString(message)));
test/src/test/java/jenkins/model/JenkinsTest.java+11 0
@@ -72,6 +72,7 @@
import hudson.util.FormValidation;
import hudson.util.HttpResponses;
import hudson.util.VersionNumber;
+import jakarta.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
import java.net.HttpURLConnection;
@@ -130,6 +131,16 @@ public class JenkinsTest {
@Rule
public TemporaryFolder tmp = new TemporaryFolder();
+ @Test
+ @Issue("SECURITY-3498")
+ public void testPaneToggleCollapse() throws Exception {
+ try (WebClient wc = j.createWebClient()) {
+ final FailingHttpStatusCodeException ex = assertThrows(FailingHttpStatusCodeException.class, () -> wc.goTo("toggleCollapse?paneId=foo"));
+ // @POST responds 404 when the verb is wrong; @RequirePOST would respond 405.
+ assertThat(ex.getStatusCode(), is(HttpServletResponse.SC_NOT_FOUND));
+ }
+ }
+
@Test
@Issue("SECURITY-3073")
public void verifyUploadedFingerprintFilePermission() throws Exception {
test/src/test/java/lib/layout/AjaxTest.java+2 2
@@ -61,7 +61,7 @@ public class AjaxTest {
@Test
@Issue("JENKINS-65288")
public void ajaxPageRenderingPossibleWithoutJellyTrace() throws Exception {
- JenkinsRule.WebClient wc = r.createWebClient();
+ JenkinsRule.WebClient wc = r.createWebClient().withJavaScriptEnabled(false);
HtmlPage htmlPage = wc.goTo(getExecutorsWidgetAjaxViewUrl());
r.assertGoodStatus(htmlPage);
}
@@ -76,7 +76,7 @@ public void ajaxPageRenderingPossibleWithJellyTrace() throws Exception {
try {
JellyFacet.TRACE = true;
- JenkinsRule.WebClient wc = r.createWebClient();
+ JenkinsRule.WebClient wc = r.createWebClient().withJavaScriptEnabled(false);
HtmlPage htmlPage = wc.goTo(getExecutorsWidgetAjaxViewUrl());
r.assertGoodStatus(htmlPage);
} finally {

References