CVE-2024-28149
Description
Jenkins HTML Publisher Plugin 1.16 through 1.32 (both inclusive) does not properly sanitize input, allowing attackers with Item/Configure permission to implement cross-site scripting (XSS) attacks and to determine whether a path on the Jenkins controller file system exists.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Jenkins HTML Publisher Plugin 1.16–1.32 fails to sanitize input from older reports, enabling stored XSS and file-system path enumeration for users with Item/Configure permission.
Vulnerability
Overview
CVE-2024-28149 is a high-severity vulnerability in the Jenkins HTML Publisher Plugin affecting versions 1.16 through 1.32 (inclusive). The root cause stems from improper input sanitization in the plugin's handling of reports created in version 1.15 and earlier. A previous fix for CVE-2018-1000175 (a path traversal vulnerability) retained a compatibility fallback for these older reports, but that fallback did not properly sanitize user-controlled input [1].
Attack
Vector and Exploitation
An attacker must have the Item/Configure permission on a Jenkins job or pipeline to exploit this flaw. By crafting a malicious report that is stored by the HTML Publisher plugin, the attacker can inject arbitrary HTML or JavaScript. When the report is subsequently viewed (e.g., through the Jenkins UI), the injected script executes in the context of the victim's browser session. Additionally, the plugin's failure to sanitize input allows the attacker to probe whether specific paths exist on the Jenkins controller's file system, although the attacker cannot read the contents of those paths [1][3].
Impact
Successful exploitation leads to stored cross-site scripting (XSS), which can be used to steal session cookies, perform actions on behalf of the victim, or deface the Jenkins interface. The file-system path enumeration assists in reconnaissance for further attacks. This vulnerability does not directly compromise the Jenkins controller, but it significantly elevates the risk for privilege escalation or data theft when combined with other weaknesses [1][2].
Mitigation
The Jenkins project has released HTML Publisher Plugin version 1.32.1, which removes support for reports created before version 1.15. Those legacy reports remain on disk but are no longer accessible through the Jenkins UI, effectively closing the attack vector. Users are strongly advised to update to 1.32.1 or later [1][2]. The fix is available in the Jenkins update center and is also tracked in the linked commit [4].
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
org.jenkins-ci.plugins:htmlpublisherMaven | >= 1.16, < 1.32.1 | 1.32.1 |
Affected products
3- Range: >=1.16 <=1.32
- Range: 1.16
Patches
18bf2e2297a86SECURITY-3301
8 files changed · +387 −69
src/main/java/htmlpublisher/HtmlPublisherTarget.java+27 −56 modified@@ -1,15 +1,22 @@ package htmlpublisher; -import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.Objects; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - +import com.infradna.tool.bridge_method_injector.WithBridgeMethods; import edu.umd.cs.findbugs.annotations.NonNull; -import javax.servlet.ServletException; - +import hudson.Extension; +import hudson.FilePath; +import hudson.Util; +import hudson.model.AbstractDescribableImpl; +import hudson.model.Action; +import hudson.model.AbstractItem; +import hudson.model.Run; +import hudson.model.DirectoryBrowserSupport; +import hudson.model.Job; +import hudson.model.ProminentProjectAction; +import hudson.model.AbstractBuild; +import hudson.model.InvisibleAction; +import hudson.model.Descriptor; +import hudson.util.HttpResponses; +import jenkins.model.RunAction2; import org.apache.commons.codec.binary.Hex; import org.apache.commons.lang.StringUtils; import org.kohsuke.accmod.Restricted; @@ -20,23 +27,15 @@ import org.kohsuke.stapler.StaplerResponse; import org.owasp.encoder.Encode; -import com.infradna.tool.bridge_method_injector.WithBridgeMethods; +import javax.servlet.ServletException; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; -import hudson.Extension; -import hudson.FilePath; -import hudson.Util; -import hudson.model.AbstractBuild; -import hudson.model.AbstractDescribableImpl; -import hudson.model.AbstractItem; -import hudson.model.Action; -import hudson.model.Descriptor; -import hudson.model.DirectoryBrowserSupport; -import hudson.model.InvisibleAction; -import hudson.model.Job; -import hudson.model.ProminentProjectAction; -import hudson.model.Run; -import hudson.util.HttpResponses; -import jenkins.model.RunAction2; +import static hudson.Functions.htmlAttributeEscape; /** * A representation of an HTML directory to archive and publish. @@ -48,7 +47,7 @@ public class HtmlPublisherTarget extends AbstractDescribableImpl<HtmlPublisherTa /** * The name of the report to display for the build/project, such as "Code Coverage" */ - private final String reportName; + private String reportName; /** * The path to the HTML report directory relative to the workspace. @@ -183,15 +182,8 @@ public void setReportTitles(String reportTitles) { this.reportTitles = StringUtils.trim(reportTitles); } - /** - * Actually not safe, this allowed directory traversal (SECURITY-784). - * @return Returns a string with replaced whitespaces by underscores. - */ - private String getLegacySanitizedName() { - String safeName = this.reportName; - safeName = safeName.replace(" ", "_"); - return safeName; - } + //Add this for testing purposes + public void setReportName(String reportName) {this.reportName = StringUtils.trim(reportName);} public String getSanitizedName() { return sanitizeReportName(this.reportName, getEscapeUnderscores()); @@ -313,11 +305,6 @@ protected File dir() { if (run != null) { File javadocDir = getBuildArchiveDir(run); - if (!javadocDir.exists()) { - javadocDir = getBuildArchiveDir(run, getLegacySanitizedName()); - } - // TODO not sure about this change - if (javadocDir.exists()) { for (HTMLBuildAction a : run.getActions(HTMLBuildAction.class)) { if (a.getHTMLTarget().getReportName().equals(getHTMLTarget().getReportName())) { @@ -329,15 +316,7 @@ protected File dir() { } } - // SECURITY-784: prefer safe over legacy, but if neither exists, return safe dir File projectArchiveDir = getProjectArchiveDir(this.project); - if (projectArchiveDir.exists()) { - return projectArchiveDir; - } - File legacyProjectArchiveDir = getProjectArchiveDir(this.project, getLegacySanitizedName()); - if (legacyProjectArchiveDir.exists()) { - return legacyProjectArchiveDir; - } return projectArchiveDir; } @@ -440,15 +419,7 @@ public String getBackToUrl() { @Override protected File dir() { - // SECURITY-784: prefer safe over legacy, but if neither exists, return safe dir File buildArchiveDir = getBuildArchiveDir(this.build); - if (buildArchiveDir.exists()) { - return buildArchiveDir; - } - File legacyBuildArchiveDir = getBuildArchiveDir(this.build, getLegacySanitizedName()); - if (legacyBuildArchiveDir.exists()) { - return legacyBuildArchiveDir; - } return buildArchiveDir; }
src/test/java/htmlpublisher/Security3301Test.java+74 −0 added@@ -0,0 +1,74 @@ +package htmlpublisher; + +import hudson.model.FreeStyleProject; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.recipes.LocalData; + +import java.io.File; + +import static hudson.Functions.isWindows; +import static org.junit.Assume.assumeFalse; + +public class Security3301Test { + + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Test + @LocalData + public void security3301sanitizeTest() throws Exception { + + + // Skip on windows + assumeFalse(isWindows()); + + FreeStyleProject job = j.jenkins.getItemByFullName("testJob", FreeStyleProject.class); + + Assert.assertTrue(new File(job.getRootDir(), "htmlreports/HTML_20Report").exists()); + + j.buildAndAssertSuccess(job); + + changeJobReportName(job,"HTML_20Report/javascript:alert(1)"); + + job.save(); + + j.buildAndAssertSuccess(job); + + HtmlPublisherTarget.HTMLAction action = job.getAction(HtmlPublisherTarget.HTMLAction.class); + Assert.assertNotNull(action); + + //Check that the report name is escaped for the Url + Assert.assertEquals("HTML_20Report/javascript:alert(1)", action.getHTMLTarget().getReportName()); + Assert.assertEquals("HTML_5f20Report_2fjavascript_3aalert_281_29", action.getUrlName()); + + Assert.assertTrue(new File(job.getRootDir(), "htmlreports/HTML_5f20Report_2fjavascript_3aalert_281_29").exists()); + + FreeStyleProject anotherJob = j.jenkins.getItemByFullName("anotherJob", FreeStyleProject.class); + + Assert.assertTrue(new File(anotherJob.getRootDir(), "htmlreports/HTML_20Report").exists()); + + j.buildAndAssertSuccess(anotherJob); + + changeJobReportName(job,"../../anotherJob/htmlreports/HTML_20Report"); + + job.save(); + + //Check that the build reports is not from the new job (anotherJob) + Assert.assertEquals("../../anotherJob/htmlreports/HTML_20Report", action.getHTMLTarget().getReportName()); + Assert.assertEquals("_2e_2e_2f_2e_2e_2fanotherJob_2fhtmlreports_2fHTML_5f20Report", action.getUrlName()); + Assert.assertFalse(new File(job.getRootDir(), "htmlreports/_2e_2e_2f_2e_2e_2fanotherJob_2fhtmlreports_2fHTML_5f20Report/test.txt").exists()); + + } + + public void changeJobReportName(FreeStyleProject job, String newName) { + for (Object publisher : job.getPublishersList()) { + if (publisher instanceof HtmlPublisher) { + HtmlPublisher existingPublishHTML = (HtmlPublisher) publisher; + existingPublishHTML.getReportTargets().get(0).setReportName(newName); + } + } + } +}
src/test/java/htmlpublisher/Security784Test.java+2 −13 modified@@ -24,15 +24,7 @@ public void security784upgradeTest() throws Exception { Assert.assertTrue(new File(job.getRootDir(), "htmlreports/foo!!!!bar/index.html").exists()); - HtmlPublisherTarget.HTMLAction action = job.getAction(HtmlPublisherTarget.HTMLAction.class); - Assert.assertNotNull(action); - Assert.assertEquals("foo!!!!bar", action.getHTMLTarget().getReportName()); - Assert.assertEquals("foo!!!!bar", action.getUrlName()); // legacy - JenkinsRule.WebClient client = j.createWebClient(); - HtmlPage page = client.getPage(job, "foo!!!!bar/index.html"); - String text = page.getWebResponse().getContentAsString(); - Assert.assertEquals("Sun Mar 25 15:42:10 CEST 2018", text.trim()); job.getBuildersList().clear(); String newDate = new Date().toString(); @@ -44,16 +36,13 @@ public void security784upgradeTest() throws Exception { Assert.assertTrue(new File(job.getRootDir(), "htmlreports/foo_21_21_21_21bar/index.html").exists()); - action = job.getAction(HtmlPublisherTarget.HTMLAction.class); + HtmlPublisherTarget.HTMLAction action = job.getAction(HtmlPublisherTarget.HTMLAction.class); Assert.assertNotNull(action); Assert.assertEquals("foo!!!!bar", action.getHTMLTarget().getReportName()); Assert.assertEquals("foo_21_21_21_21bar", action.getUrlName()); // new - text = client.goTo("job/thejob/foo_21_21_21_21bar/index.html").getWebResponse().getContentAsString(); + String text = client.goTo("job/thejob/foo_21_21_21_21bar/index.html").getWebResponse().getContentAsString(); Assert.assertEquals(newDate, text.trim()); - - // leftovers from legacy naming - Assert.assertTrue(new File(job.getRootDir(), "htmlreports/foo!!!!bar/index.html").exists()); } @Test
src/test/resources/htmlpublisher/Security3301Test/security3301sanitizeTest/jobs/anotherJob/config.xml+39 −0 added@@ -0,0 +1,39 @@ +<?xml version='1.1' encoding='UTF-8'?> +<project> + <actions/> + <description></description> + <keepDependencies>false</keepDependencies> + <properties/> + <scm class="hudson.scm.NullSCM"/> + <canRoam>true</canRoam> + <disabled>false</disabled> + <blockBuildWhenDownstreamBuilding>false</blockBuildWhenDownstreamBuilding> + <blockBuildWhenUpstreamBuilding>false</blockBuildWhenUpstreamBuilding> + <triggers/> + <concurrentBuild>false</concurrentBuild> + <builders> + <hudson.tasks.Shell> + <command>echo "Test999" > test.txt</command> + <configuredLocalRules/> + </hudson.tasks.Shell> + </builders> + <publishers> + <htmlpublisher.HtmlPublisher plugin="htmlpublisher@1.33-SNAPSHOT"> + <reportTargets> + <htmlpublisher.HtmlPublisherTarget> + <reportName>HTML Report</reportName> + <reportDir></reportDir> + <reportFiles>test.txt</reportFiles> + <alwaysLinkToLastBuild>false</alwaysLinkToLastBuild> + <reportTitles></reportTitles> + <keepAll>false</keepAll> + <allowMissing>false</allowMissing> + <includes>**/*</includes> + <escapeUnderscores>true</escapeUnderscores> + <useWrapperFileDirectly>true</useWrapperFileDirectly> + </htmlpublisher.HtmlPublisherTarget> + </reportTargets> + </htmlpublisher.HtmlPublisher> + </publishers> + <buildWrappers/> +</project> \ No newline at end of file
src/test/resources/htmlpublisher/Security3301Test/security3301sanitizeTest/jobs/anotherJob/htmlreports/HTML_20Report/htmlpublisher-wrapper.html+141 −0 added@@ -0,0 +1,141 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" +"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> + +<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> +<head> +<meta http-equiv="Content-Type" content="text/html" /> +<!-- CSS Tabs is licensed under Creative Commons Attribution 3.0 - http://creativecommons.org/licenses/by/3.0/ --> +<style type="text/css"> + +body { +font: 100% verdana, arial, sans-serif; +background-color: #fff; +} + +/* begin css tabs */ + +ul#tabnav { /* general settings */ +text-align: left; /* set to left, right or center */ +margin: 8px 0 0 0; /* set margins as desired */ +font: bold 11px verdana, arial, sans-serif; /* set font as desired */ +border-bottom: 1px solid #6c6; /* set border COLOR as desired */ +list-style-type: none; +padding: 3px 10px 0px 10px; +} + +ul#tabnav li { /* do not change */ +display: inline-block; +} + +ul#tabnav li.selected { /* settings for selected tab */ +border-bottom: 1px solid #fff; /* set border color to page background color */ +background-color: #fff; /* set background color to match above border color */ +} + + +ul#tabnav li { /* settings for all tab links */ +padding: 3px 4px; +border: 1px solid #6c6; /* set border COLOR as desired; usually matches border color specified in #tabnav */ +border-bottom: 1px solid #cfc; +background-color: #cfc; /* set unselected tab background color as desired */ +color: #666; /* set unselected tab link color as desired */ +margin-right: 0px; /* set additional spacing between tabs as desired */ +text-decoration: none; +cursor: pointer; +} + +ul#tabnav li:hover { /* settings for hover effect */ +background: #afa; /* set desired hover color */ +} + +/* end css tabs */ + +/* FF 100% height iframe */ +html, body, div, iframe { margin:0; padding:0; } +iframe { display:block; width:100%; border:none; } + +h1 +{ + display: inline; + float: left; + font-size: small; + margin: 0; + padding: 0 10px; +} + +h2 +{ + display: inline; + float: right; + font-size: small; + margin: 0; + padding: 0 10px; +} + +</style> + +<script type="text/javascript"> +function updateBody(tabId, page) { + document.getElementById(selectedTab).setAttribute("class", "unselected"); + tab = document.getElementById(tabId) + tab.setAttribute("class", "selected"); + selectedTab = tabId; + iframe = document.getElementById("myframe"); + iframe.src = encodeURIComponent(tab.getAttribute("value")).replace(/%2F/g, '/'); +} +function init(tabId){ + updateBody(tabId); + updateViewport(); + + window.onresize = updateViewport; +} + +function updateViewport(){ + var viewportheight; + + // the more standards compliant browsers (mozilla/netscape/opera/IE7) use window.innerWidth and window.innerHeight + + if (typeof window.innerWidth != 'undefined') + { + viewportheight = window.innerHeight + } + + // IE6 in standards compliant mode (i.e. with a valid doctype as the first line in the document) + + else if (typeof document.documentElement != 'undefined' + && typeof document.documentElement.clientWidth != + 'undefined' && document.documentElement.clientWidth != 0) + { + viewportheight = document.documentElement.clientHeight + } + // older versions of IE + else + { + viewportheight = document.getElementsByTagName('body')[0].clientHeight + } + + iframe = document.getElementById("myframe"); + iframe.style.height = (viewportheight-30)+'px'; +} +var selectedTab = "tab1" +</script> + +</head> + +<body onload="init('tab1');"> + +<h1><a id="hudson_link" href="#"></a></h1> +<h2><a id="zip_link" href="#">Zip</a></h2> + +<ul id="tabnav"> +<li id="tab1" class="unselected" onclick="updateBody('tab1');" value="test.txt">test</li> +<script type="text/javascript">document.getElementById("hudson_link").innerHTML="Back to anotherJob";</script> +<script type="text/javascript">document.getElementById("hudson_link").onclick = function() { history.go(-1); return false; };</script> +<script type="text/javascript">document.getElementById("zip_link").href="*zip*/HTML_20Report.zip";</script> +</ul> +<div> +<iframe id="myframe" height="100%" width="100%" frameborder="0"></iframe> +</div> + +</body> +</html>
src/test/resources/htmlpublisher/Security3301Test/security3301sanitizeTest/jobs/anotherJob/htmlreports/HTML_20Report/test.txt+1 −0 added@@ -0,0 +1 @@ +Test999
src/test/resources/htmlpublisher/Security3301Test/security3301sanitizeTest/jobs/testJob/config.xml+39 −0 added@@ -0,0 +1,39 @@ +<?xml version='1.1' encoding='UTF-8'?> +<project> + <description></description> + <keepDependencies>false</keepDependencies> + <properties/> + <scm class="hudson.scm.NullSCM"/> + <canRoam>true</canRoam> + <disabled>false</disabled> + <blockBuildWhenDownstreamBuilding>false</blockBuildWhenDownstreamBuilding> + <blockBuildWhenUpstreamBuilding>false</blockBuildWhenUpstreamBuilding> + <triggers/> + <concurrentBuild>false</concurrentBuild> + <builders> + <hudson.tasks.Shell> + <command>touch "javascript:alert(1)" + </command> + <configuredLocalRules/> + </hudson.tasks.Shell> + </builders> + <publishers> + <htmlpublisher.HtmlPublisher plugin="htmlpublisher@1.33-SNAPSHOT"> + <reportTargets> + <htmlpublisher.HtmlPublisherTarget> + <reportName>HTML Report</reportName> + <reportDir></reportDir> + <reportFiles>index.html</reportFiles> + <alwaysLinkToLastBuild>false</alwaysLinkToLastBuild> + <reportTitles></reportTitles> + <keepAll>false</keepAll> + <allowMissing>false</allowMissing> + <includes>**/*</includes> + <escapeUnderscores>true</escapeUnderscores> + <useWrapperFileDirectly>true</useWrapperFileDirectly> + </htmlpublisher.HtmlPublisherTarget> + </reportTargets> + </htmlpublisher.HtmlPublisher> + </publishers> + <buildWrappers/> +</project> \ No newline at end of file
src/test/resources/htmlpublisher/Security3301Test/security3301sanitizeTest/jobs/testJob/htmlreports/HTML_20Report/htmlpublisher-wrapper.html+64 −0 added@@ -0,0 +1,64 @@ +<script type="text/javascript"> +function updateBody(tabId, page) { + document.getElementById(selectedTab).setAttribute("class", "unselected"); + tab = document.getElementById(tabId) + tab.setAttribute("class", "selected"); + selectedTab = tabId; + iframe = document.getElementById("myframe"); + iframe.src = encodeURIComponent(tab.getAttribute("value")).replace(/%2F/g, '/'); +} +function init(tabId){ + updateBody(tabId); + updateViewport(); + + window.onresize = updateViewport; +} + +function updateViewport(){ + var viewportheight; + + // the more standards compliant browsers (mozilla/netscape/opera/IE7) use window.innerWidth and window.innerHeight + + if (typeof window.innerWidth != 'undefined') + { + viewportheight = window.innerHeight + } + + // IE6 in standards compliant mode (i.e. with a valid doctype as the first line in the document) + + else if (typeof document.documentElement != 'undefined' + && typeof document.documentElement.clientWidth != + 'undefined' && document.documentElement.clientWidth != 0) + { + viewportheight = document.documentElement.clientHeight + } + // older versions of IE + else + { + viewportheight = document.getElementsByTagName('body')[0].clientHeight + } + + iframe = document.getElementById("myframe"); + iframe.style.height = (viewportheight-30)+'px'; +} +var selectedTab = "tab1" +</script> + +</head> + +<body onload="init('tab1');"> + +<h1><a id="hudson_link" href="#"></a></h1> +<h2><a id="zip_link" href="#">Zip</a></h2> + +<ul id="tabnav"> + <script type="text/javascript">document.getElementById("hudson_link").innerHTML="Back to testJob";</script> + <script type="text/javascript">document.getElementById("hudson_link").onclick = function() { history.go(-1); return false; };</script> + <script type="text/javascript">document.getElementById("zip_link").href="*zip*/HTML_5f20Report_2fjavascript_3aalert_281_29.zip";</script> +</ul> +<div> + <iframe id="myframe" height="100%" width="100%" frameborder="0"></iframe> +</div> + +</body> +</html> \ No newline at end of file
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-8vcg-v7g4-3vr7ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-28149ghsaADVISORY
- www.jenkins.io/security/advisory/2024-03-06/ghsavendor-advisoryWEB
- www.openwall.com/lists/oss-security/2024/03/06/3ghsaWEB
- github.com/jenkinsci/htmlpublisher-plugin/commit/8bf2e2297a86ad50f7567fb953b2f8ec18b2891bghsaWEB
News mentions
1- Jenkins Security Advisory 2024-03-06Jenkins Security Advisories · Mar 6, 2024