Jenkins has a stored XSS vulnerability in node offline cause description
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.542 → fixed in 2.5512.483 → fixed in 2.541.2
Details
Jenkins 2.483 through 2.550 (both inclusive), LTS 2.492.1 through 2.541.1 (both inclusive) does not escape the user-provided description of the "Mark temporarily offline" offline cause, resulting in a stored cross-site scripting (XSS) vulnerability exploitable by attackers with Agent/Configure or Agent/Disconnect permission.
The fix
[SECURITY-3669]
core/src/main/java/hudson/slaves/OfflineCause.java+7 −1
@@ -26,6 +26,7 @@import edu.umd.cs.findbugs.annotations.CheckForNull;import edu.umd.cs.findbugs.annotations.NonNull;+import hudson.Util;import hudson.model.Computer;import hudson.model.User;import java.io.ObjectStreamException;@@ -166,7 +167,7 @@ public User getUser() {* @return the message that was provided when the computer was taken offline*/public String getMessage() {-return message;+return Util.escape(message);}// Storing the User in a filed was a mistake, switch to userId@@ -202,6 +203,11 @@ public String getComputerIconAltText() {public String getIcon() {return "symbol-person";}++@Override+public String toString() {+return Util.escape(super.toString());+}}public static class ByCLI extends UserCause {
test/src/test/java/jenkins/security/Security3669Test.java+122 −0
@@ -0,0 +1,122 @@+package jenkins.security;++import static org.hamcrest.MatcherAssert.assertThat;+import static org.hamcrest.Matchers.allOf;+import static org.hamcrest.Matchers.containsString;+import static org.hamcrest.Matchers.not;+import static org.hamcrest.Matchers.nullValue;++import hudson.model.Computer;+import hudson.model.User;+import hudson.slaves.DumbSlave;+import hudson.slaves.OfflineCause;+import java.io.ByteArrayInputStream;+import jenkins.model.Jenkins;+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.jvnet.hudson.test.junit.jupiter.WithLocalData;++@WithJenkins+public class Security3669Test {+@Test+public void newOfflineCause(JenkinsRule jenkinsRule) throws Exception {+try (JenkinsRule.WebClient webClient = jenkinsRule.createWebClient()) {+HtmlPage formPage = webClient.getPage(Jenkins.get(), "markOffline");+formPage.getElementByName("offlineMessage").setTextContent("<img src=x onerror=alert(1)>");+HtmlFormUtil.submit(formPage.getForms().stream().filter(f -> f.getActionAttribute().equals("toggleOffline")).findFirst().orElseThrow());++final HtmlPage nodePage = webClient.getPage(Jenkins.get());+assertThat(+nodePage.getWebResponse().getContentAsString(),+allOf(+not(containsString("<img src=x onerror=alert(1)>")),+containsString("Disconnected by anonymous : <img src=x onerror=alert(1)>")));+}+}++@Test+public void editOfflineCause(JenkinsRule jenkinsRule) throws Exception {+Jenkins.get().getComputer("").setTemporaryOfflineCause(new OfflineCause.UserCause(User.current(), "initial reason"));+try (JenkinsRule.WebClient webClient = jenkinsRule.createWebClient()) {+HtmlPage formPage = webClient.getPage(Jenkins.get(), "setOfflineCause");+formPage.getElementByName("offlineMessage").setTextContent("<img src=x onerror=alert(1)>");+HtmlFormUtil.submit(formPage.getForms().stream().filter(f -> f.getActionAttribute().equals("changeOfflineCause")).findFirst().orElseThrow());++final HtmlPage nodePage = webClient.getPage(Jenkins.get());+assertThat(+nodePage.getWebResponse().getContentAsString(),+allOf(+not(containsString("<img src=x onerror=alert(1)>")),+containsString("Disconnected by anonymous : <img src=x onerror=alert(1)>")));+}+}++@Test+void postConfigXmlWithLocalizable(JenkinsRule jenkinsRule) throws Exception {+final DumbSlave agent = jenkinsRule.createOnlineSlave();++String xml = "<?xml version=\"1.1\" encoding=\"UTF-8\"?>\n" ++"<slave>\n" ++" <temporaryOfflineCause class=\"hudson.slaves.OfflineCause$UserCause\">\n" ++" <timestamp>1770000000000</timestamp>\n" ++" <description>\n" ++" <holder>\n" ++" <owner>hudson.slaves.Messages</owner>\n" ++" </holder>\n" ++" <key>SlaveComputer.DisconnectedBy</key>\n" ++" <args>\n" ++" <string>admin</string>\n" ++" <string> : <img src=x onerror=alert(1)></string>\n" ++" </args>\n" ++" </description>\n" ++" <userId>admin</userId>\n" ++" <message><img src=x onerror=alert(1)></message>\n" ++" </temporaryOfflineCause>\n" ++" <name>" + agent.getNodeName() + "</name>\n" ++" <description></description>\n" ++" <remoteFS>/tmp/foo</remoteFS>\n" ++" <numExecutors>1</numExecutors>\n" ++" <mode>NORMAL</mode>\n" ++" <retentionStrategy class=\"hudson.slaves.RetentionStrategy$Always\"/>\n" ++" <launcher class=\"hudson.slaves.JNLPLauncher\">\n" ++" <workDirSettings>\n" ++" <disabled>false</disabled>\n" ++" <internalDir>remoting</internalDir>\n" ++" <failIfWorkDirIsMissing>false</failIfWorkDirIsMissing>\n" ++" </workDirSettings>\n" ++" <webSocket>false</webSocket>\n" ++" </launcher>\n" ++" <label></label>\n" ++" <nodeProperties/>\n" ++"</slave>";+agent.toComputer().updateByXml(new ByteArrayInputStream(xml.getBytes()));++try (JenkinsRule.WebClient webClient = jenkinsRule.createWebClient()) {+final HtmlPage nodePage = webClient.getPage(agent);+assertThat(+nodePage.getWebResponse().getContentAsString(),+allOf(+not(containsString("<img src=x onerror=alert(1)>")),+containsString("Disconnected by admin : <img src=x onerror=alert(1)>")));+}+}++@Test+@WithLocalData+void dataFromDisk(JenkinsRule jenkinsRule) throws Exception {+final Computer agent = jenkinsRule.jenkins.getComputer("a1");+assertThat(agent, not(nullValue()));++try (JenkinsRule.WebClient webClient = jenkinsRule.createWebClient()) {+final HtmlPage nodePage = webClient.getPage(agent.getNode());+assertThat(+nodePage.getWebResponse().getContentAsString(),+allOf(+not(containsString("<img src=x onerror=alert(1)>")),+containsString("Disconnected by anonymous : <img src=x onerror=alert(1)>")));+}+}+}
test/src/test/resources/jenkins/security/Security3669Test/dataFromDisk/nodes/a1/config.xml+33 −0
@@ -0,0 +1,33 @@+<?xml version='1.1' encoding='UTF-8'?>+<slave>+<temporaryOfflineCause class="hudson.slaves.OfflineCause$UserCause">+<timestamp>1770000000000</timestamp>+<description>+<holder>+<owner>hudson.slaves.Messages</owner>+</holder>+<key>SlaveComputer.DisconnectedBy</key>+<args>+<string>anonymous</string>+<string> : <img src=x onerror=alert(1)></string>+</args>+</description>+<message><img src=x onerror=alert(1)></message>+</temporaryOfflineCause>+<name>a1</name>+<description></description>+<remoteFS>/tmp/a1</remoteFS>+<numExecutors>1</numExecutors>+<mode>NORMAL</mode>+<retentionStrategy class="hudson.slaves.RetentionStrategy$Always"/>+<launcher class="hudson.slaves.JNLPLauncher">+<workDirSettings>+<disabled>false</disabled>+<internalDir>remoting</internalDir>+<failIfWorkDirIsMissing>false</failIfWorkDirIsMissing>+</workDirSettings>+<webSocket>false</webSocket>+</launcher>+<label></label>+<nodeProperties/>+</slave>
References
- ADVISORYhttps://nvd.nist.gov/vuln/detail/CVE-2026-27099
- WEBhttps://github.com/jenkinsci/jenkins/commit/578c028e2cdfdc9e124d0ca389a80bb2bd231ab2
- PACKAGEhttps://github.com/jenkinsci/jenkins
- WEBhttps://github.com/jenkinsci/jenkins/releases/tag/jenkins-2.541.2
- WEBhttps://github.com/jenkinsci/jenkins/releases/tag/jenkins-2.551
- WEBhttps://www.jenkins.io/security/advisory/2026-02-18/#SECURITY-3669