Security context
High· 8.0GHSA-g4j6-m3m3-crw8 CVE-2020-2221CWE-79Published May 24, 2022

Stored XSS vulnerability in Jenkins upstream cause

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

0 → fixed in 2.235.22.236 → fixed in 2.245

Details

Jenkins 2.244 and earlier, LTS 2.235.1 and earlier does not escape the upstream job's display name shown as part of a build cause, resulting in a stored cross-site scripting vulnerability. Jenkins 2.245, LTS 2.235.2 escapes the job display name.

The fix

[SECURITY-1901]

Wadeck Follonier· Jun 30, 2020, 02:56 PM+1714f6e575381b
core/src/main/resources/hudson/model/Cause/UpstreamCause/description.jelly+4 4
@@ -29,20 +29,20 @@ THE SOFTWARE.
<j:when test="${it.upstreamUrl!=null and app.getItemByFullName(it.upstreamProject)!=null}">
<j:choose>
<j:when test="${app.getItemByFullName(it.upstreamProject).getBuildByNumber(it.upstreamBuild)!=null}">
- <j:out value="${%started_by_project(app.getItemByFullName(it.upstreamProject).fullDisplayName,it.upstreamBuild.toString(),it.upstreamUrl,rootURL)}"/>
+ ${%started_by_project(app.getItemByFullName(it.upstreamProject).fullDisplayName,it.upstreamBuild.toString(),it.upstreamUrl,rootURL)}
</j:when>
<j:otherwise>
- <j:out value="${%started_by_project_with_deleted_build(app.getItemByFullName(it.upstreamProject).fullDisplayName,it.upstreamBuild.toString(),it.upstreamUrl,rootURL)}"/>
+ ${%started_by_project_with_deleted_build(app.getItemByFullName(it.upstreamProject).fullDisplayName,it.upstreamBuild.toString(),it.upstreamUrl,rootURL)}
</j:otherwise>
</j:choose>
</j:when>
<j:otherwise>
- <j:out value="${it.shortDescription}"/>
+ ${it.shortDescription}
</j:otherwise>
</j:choose>
<j:if test="${!it.upstreamCauses.isEmpty()}">
- <br /><j:out value="${%caused_by}" />
+ <br />${%caused_by}
<ul>
<j:forEach var="c" items="${it.upstreamCauses}">
<li><st:include page="description.jelly" it="${c}" /></li>
test/src/test/java/hudson/model/UpstreamCauseSEC1901Test.java+167 0
@@ -0,0 +1,167 @@
+/*
+ * The MIT License
+ *
+ * Copyright (c) 2020 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 hudson.model;
+
+import hudson.tasks.BuildTrigger;
+import jenkins.model.Jenkins;
+import org.junit.Rule;
+import org.junit.Test;
+import org.jvnet.hudson.test.Issue;
+import org.jvnet.hudson.test.JenkinsRule;
+import org.jvnet.hudson.test.TestExtension;
+
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.junit.Assert.assertFalse;
+
+public class UpstreamCauseSEC1901Test {
+
+ @Rule
+ public JenkinsRule j = new JenkinsRule();
+
+ @Test
+ @Issue("SECURITY-1901")
+ public void preventXssInUpstreamDisplayName() throws Exception {
+ j.jenkins.setQuietPeriod(0);
+ FreeStyleProject up = j.createFreeStyleProject("up");
+ up.setDisplayName("Up<script>alert(123)</script>Project");
+
+ FreeStyleProject down = j.createFreeStyleProject("down");
+
+ up.getPublishersList().add(new BuildTrigger(down.getFullName(), false));
+
+ j.jenkins.rebuildDependencyGraph();
+
+ j.buildAndAssertSuccess(up);
+
+ FreeStyleBuild downBuild = this.waitForDownBuild(down);
+
+ ensureXssIsPrevented(downBuild);
+ }
+
+ @Test
+ @Issue("SECURITY-1901")
+ public void preventXssInUpstreamDisplayName_deleted() throws Exception {
+ j.jenkins.setQuietPeriod(0);
+ FreeStyleProject up = j.createFreeStyleProject("up");
+ up.setDisplayName("Up<script>alert(123)</script>Project");
+
+ FreeStyleProject down = j.createFreeStyleProject("down");
+
+ up.getPublishersList().add(new BuildTrigger(down.getFullName(), false));
+
+ j.jenkins.rebuildDependencyGraph();
+
+ FreeStyleBuild upBuild = j.buildAndAssertSuccess(up);
+
+ FreeStyleBuild downBuild = this.waitForDownBuild(down);
+
+ // that will display a different part
+ upBuild.delete();
+
+ ensureXssIsPrevented(downBuild);
+ }
+
+ @Test
+ @Issue("SECURITY-1901")
+ public void preventXssInUpstreamShortDescription() throws Exception {
+ FullNameChangingProject up = j.createProject(FullNameChangingProject.class, "up");
+
+ FreeStyleProject down = j.createFreeStyleProject("down");
+
+ CustomBuild upBuild = j.buildAndAssertSuccess(up);
+
+ up.setVirtualName("Up<script>alert(123)</script>Project");
+ j.assertBuildStatusSuccess(down.scheduleBuild2(0, new Cause.UpstreamCause(upBuild)));
+ up.setVirtualName(null);
+
+ FreeStyleBuild downBuild = this.waitForDownBuild(down);
+
+ ensureXssIsPrevented(downBuild);
+ }
+
+ private void ensureXssIsPrevented(FreeStyleBuild downBuild) throws Exception {
+ AtomicBoolean alertCalled = new AtomicBoolean(false);
+
+ JenkinsRule.WebClient wc = j.createWebClient();
+ wc.setAlertHandler((page, s) -> alertCalled.set(true));
+ wc.goTo(downBuild.getUrl());
+
+ assertFalse("XSS not prevented", alertCalled.get());
+ }
+
+ private <B extends Build<?, B>> B waitForDownBuild(Project<?, B> down) throws Exception {
+ j.waitUntilNoActivity();
+ B result = down.getBuilds().getLastBuild();
+
+ return result;
+ }
+
+ public static class CustomBuild extends Build<FullNameChangingProject, CustomBuild> {
+ public CustomBuild(FullNameChangingProject job) throws IOException {
+ super(job);
+ }
+ }
+
+ static class FullNameChangingProject extends Project<FullNameChangingProject, CustomBuild> implements TopLevelItem {
+ private volatile String virtualName;
+
+ public FullNameChangingProject(ItemGroup parent, String name) {
+ super(parent, name);
+ }
+
+ public void setVirtualName(String virtualName) {
+ this.virtualName = virtualName;
+ }
+
+ @Override
+ public String getName() {
+ if (virtualName != null) {
+ return virtualName;
+ } else {
+ return super.getName();
+ }
+ }
+
+ @Override
+ protected Class<CustomBuild> getBuildClass() {
+ return CustomBuild.class;
+ }
+
+ @Override
+ public TopLevelItemDescriptor getDescriptor() {
+ return (FreeStyleProject.DescriptorImpl) Jenkins.get().getDescriptorOrDie(getClass());
+ }
+
+ @TestExtension
+ public static class DescriptorImpl extends AbstractProjectDescriptor {
+
+ @Override
+ public FullNameChangingProject newInstance(ItemGroup parent, String name) {
+ return new FullNameChangingProject(parent, name);
+ }
+ }
+ }
+}

References