Security context
High· 8.0GHSA-85h6-5m3v-gx37 CVE-2026-27099CWE-79Published Feb 18, 2026

Jenkins has a stored XSS vulnerability in node offline cause description

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.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]

Daniel Beck· Feb 15, 2026, 03:32 AM+1621578c028e2c
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 : &lt;img src=x onerror=alert(1)&gt;")));
+ }
+ }
+
+ @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 : &lt;img src=x onerror=alert(1)&gt;")));
+ }
+ }
+
+ @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> : &lt;img src=x onerror=alert(1)&gt;</string>\n" +
+ " </args>\n" +
+ " </description>\n" +
+ " <userId>admin</userId>\n" +
+ " <message>&lt;img src=x onerror=alert(1)&gt;</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 : &lt;img src=x onerror=alert(1)&gt;")));
+ }
+ }
+
+ @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 : &lt;img src=x onerror=alert(1)&gt;")));
+ }
+ }
+}
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> : &lt;img src=x onerror=alert(1)&gt;</string>
+ </args>
+ </description>
+ <message>&lt;img src=x onerror=alert(1)&gt;</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