VYPR
Moderate severityNVD Advisory· Published May 11, 2021· Updated Aug 3, 2024

CVE-2021-21649

CVE-2021-21649

Description

Jenkins Dashboard View Plugin 2.15 and earlier has a stored XSS via unescaped URLs in Image Dashboard Portlets.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Jenkins Dashboard View Plugin 2.15 and earlier has a stored XSS via unescaped URLs in Image Dashboard Portlets.

Vulnerability

Jenkins Dashboard View Plugin version 2.15 and earlier does not escape URLs referenced in Image Dashboard Portlets, leading to a stored cross-site scripting (XSS) vulnerability [1][2]. The vulnerability resides in the Image Dashboard Portlet configuration, where user-provided URLs are stored and later rendered without proper escaping.

Exploitation

An attacker with View/Configure permission on a Jenkins view can create or modify an Image Dashboard Portlet and inject a malicious URL containing JavaScript code [1]. When other users view the dashboard, the injected script executes in their browser, as the URL is not sanitized.

Impact

Successful exploitation results in stored XSS, allowing the attacker to execute arbitrary JavaScript in the context of the victim's Jenkins session. This can lead to credential theft, session hijacking, or unauthorized actions.

Mitigation

Dashboard View Plugin version 2.16, released alongside the advisory on 2021-05-11, fixes the issue by not rendering unsafe URLs and changing the property name from url to imageUrl [1]. Users should upgrade to version 2.16 or later. No workaround is available.

AI Insight generated on May 21, 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.

PackageAffected versionsPatched versions
org.jenkins-ci.plugins:dashboard-viewMaven
>= 2.13, < 2.162.16
org.jenkins-ci.plugins:dashboard-viewMaven
< 2.12.12.12.1

Affected products

2

Patches

1
586817b081d9

[SECURITY-2233]

https://github.com/jenkinsci/dashboard-view-pluginTobias GruetzmacherMay 4, 2021via ghsa
11 files changed · +282 7
  • src/main/java/hudson/plugins/view/dashboard/core/ImagePortlet.java+59 2 modified
    @@ -1,10 +1,17 @@
     package hudson.plugins.view.dashboard.core;
     
    +import edu.umd.cs.findbugs.annotations.CheckForNull;
     import hudson.Extension;
     import hudson.model.Descriptor;
     import hudson.plugins.view.dashboard.DashboardPortlet;
     import hudson.plugins.view.dashboard.Messages;
    +import hudson.util.FormValidation;
    +import java.net.URI;
    +import java.net.URISyntaxException;
    +import org.apache.commons.lang.StringUtils;
     import org.kohsuke.stapler.DataBoundConstructor;
    +import org.kohsuke.stapler.DataBoundSetter;
    +import org.kohsuke.stapler.QueryParameter;
     
     /**
      * Portlet displays image fetched from specified URL
    @@ -13,24 +20,74 @@
      */
     public class ImagePortlet extends DashboardPortlet {
     
    -  private final String url;
    +  private String url;
     
       @DataBoundConstructor
    +  public ImagePortlet(String name) {
    +    super(name);
    +  }
    +
    +  @Deprecated
       public ImagePortlet(String name, String url) {
         super(name);
         this.url = url;
       }
     
    -  public String getUrl() {
    +  public String getImageUrl() {
         return this.url;
       }
     
    +  @DataBoundSetter
    +  public void setImageUrl(final String url) {
    +    this.url = url;
    +  }
    +
    +  @Deprecated @DataBoundSetter
    +  public void setUrl(final String url) {
    +    this.url = url;
    +  }
    +
    +  public boolean isUrlValid() {
    +    return getUrlError(url) == null;
    +  }
    +
       @Extension
       public static class DescriptorImpl extends Descriptor<DashboardPortlet> {
     
         @Override
         public String getDisplayName() {
           return Messages.Dashboard_Image();
         }
    +
    +    public FormValidation doCheckImageUrl(@QueryParameter String value) {
    +      String error = getUrlError(value);
    +      if (error != null) {
    +        return FormValidation.error(error);
    +      }
    +      return FormValidation.ok();
    +    }
    +  }
    +
    +  /**
    +   * Checks if the passed string is a valid HTTP or relative URL.
    +   *
    +   * @return Localized error message or null if URL is valid.
    +   */
    +  @CheckForNull
    +  private static final String getUrlError(String url) {
    +    if (StringUtils.isBlank(url)) {
    +      return Messages.Dashboard_ImageUrlEmpty();
    +    }
    +    try {
    +      final URI allowedUrl = new URI(url);
    +      final String protocol = allowedUrl.getScheme();
    +      if (!allowedUrl.isAbsolute() || protocol.equals("http") || protocol.equals("https")) {
    +        return null;
    +      } else {
    +        return Messages.Dashboard_ImageUrlHttp();
    +      }
    +    } catch (URISyntaxException e) {
    +      return Messages.Dashboard_ImageUrlInvalid(e.getMessage());
    +    }
       }
     }
    
  • src/main/resources/hudson/plugins/view/dashboard/core/ImagePortlet/config.jelly+2 2 modified
    @@ -28,6 +28,6 @@ THE SOFTWARE.
         <f:textbox name="portlet.name" field="name" default="${descriptor.getDisplayName()}" />
       </f:entry>
       <f:entry title="URL">
    -    <f:textbox name="portlet.url" field="url" default="" />
    +    <f:textbox name="portlet.imageUrl" field="imageUrl" default="" />
       </f:entry>
    -</j:jelly>
    \ No newline at end of file
    +</j:jelly>
    
  • src/main/resources/hudson/plugins/view/dashboard/core/ImagePortlet/portlet_de.properties+1 0 added
    @@ -0,0 +1 @@
    +Display\ error\:\ Image\ URL\ is\ invalid=Anzeigefehler: Die Bild-URL ist ung\u00FCltig
    
  • src/main/resources/hudson/plugins/view/dashboard/core/ImagePortlet/portlet.jelly+8 1 modified
    @@ -27,7 +27,14 @@ THE SOFTWARE.
       <dp:decorate portlet="${it}" width="3">
         <tr>
           <td>
    -        <img src="${it.getUrl()}"/>
    +        <j:choose>
    +          <j:when test="${it.urlValid}">
    +            <img src="${it.getImageUrl()}"/>
    +          </j:when>
    +          <j:otherwise>
    +            <div class="error">${%Display error: Image URL is invalid}</div>
    +          </j:otherwise>
    +        </j:choose>
           </td>
         </tr>
       </dp:decorate>
    
  • src/main/resources/hudson/plugins/view/dashboard/Messages.properties+3 0 modified
    @@ -21,4 +21,7 @@ Dashboard.Date=date
     Dashboard.Count=count
     Dashboard.AgentStatistics=Agent statistics
     Dashboard.Image=Image
    +Dashboard.ImageUrlEmpty=Image URL should not be empty.
    +Dashboard.ImageUrlInvalid=Image URL is invalid: {0}
    +Dashboard.ImageUrlHttp=Image URL must be http or https.
     Dashboard.IframePortlet=Iframe Portlet
    
  • src/test/java/hudson/plugins/view/dashboard/ConfigurationAsCodeBackCompatTest.java+86 0 added
    @@ -0,0 +1,86 @@
    +package hudson.plugins.view.dashboard;
    +
    +import hudson.plugins.view.dashboard.core.ImagePortlet;
    +import io.jenkins.plugins.casc.ConfigurationAsCode;
    +import io.jenkins.plugins.casc.ConfigurationContext;
    +import io.jenkins.plugins.casc.ConfiguratorRegistry;
    +import io.jenkins.plugins.casc.ObsoleteConfigurationMonitor;
    +import io.jenkins.plugins.casc.misc.ConfiguredWithCode;
    +import io.jenkins.plugins.casc.misc.JenkinsConfiguredWithCodeRule;
    +import io.jenkins.plugins.casc.misc.Util;
    +import io.jenkins.plugins.casc.model.CNode;
    +import org.hamcrest.Description;
    +import org.hamcrest.Matcher;
    +import org.hamcrest.TypeSafeDiagnosingMatcher;
    +import org.junit.Rule;
    +import org.junit.Test;
    +import org.jvnet.hudson.test.Issue;
    +import org.jvnet.hudson.test.JenkinsRule;
    +
    +import java.util.List;
    +
    +import static io.jenkins.plugins.casc.misc.Util.getJenkinsRoot;
    +import static org.hamcrest.MatcherAssert.assertThat;
    +import static org.hamcrest.Matchers.containsInAnyOrder;
    +import static org.hamcrest.Matchers.containsString;
    +import static org.hamcrest.Matchers.equalTo;
    +import static org.hamcrest.Matchers.hasProperty;
    +import static org.hamcrest.Matchers.instanceOf;
    +import static org.hamcrest.Matchers.is;
    +import static org.hamcrest.Matchers.notNullValue;
    +import static org.hamcrest.core.AllOf.allOf;
    +
    +public class ConfigurationAsCodeBackCompatTest {
    +
    +  @Rule
    +  public JenkinsConfiguredWithCodeRule j = new JenkinsConfiguredWithCodeRule();
    +
    +
    +  @Test
    +  @Issue("SECURITY-2233")
    +  @ConfiguredWithCode("casc_image_backcompat_import.yml")
    +  public void testOldImagePortletUrl() throws Exception {
    +    Dashboard.DescriptorImpl descriptor =
    +      j.jenkins.getDescriptorByType(Dashboard.DescriptorImpl.class);
    +    assertThat(descriptor, notNullValue());
    +
    +    Dashboard dashboard = (Dashboard) j.jenkins.getView("test");
    +    assertThat(dashboard.getViewName(), is("test"));
    +    List<DashboardPortlet> leftPortlets = dashboard.getLeftPortlets();
    +    assertThat(leftPortlets.get(0), allOf(
    +      instanceOf(ImagePortlet.class),
    +      hasProperty("name", equalTo("Image")),
    +      hasProperty("imageUrl", equalTo("test-backcompat"))
    +    ));
    +
    +    final List<ObsoleteConfigurationMonitor.Error> errors = ObsoleteConfigurationMonitor.get().getErrors();
    +    assertThat(errors, containsInAnyOrder(new ErrorMatcher(containsString("'url' is deprecated"))));
    +
    +    final ConfiguratorRegistry registry = ConfiguratorRegistry.get();
    +    final ConfigurationContext context = new ConfigurationContext(registry);
    +    CNode node = getJenkinsRoot(context).get("views").asSequence().get(0).asMapping();
    +
    +    final String exported = Util.toYamlString(node);
    +    final String expected = Util.toStringFromYamlFile(this, "casc_image_backcompat_export.yml");
    +
    +    assertThat(exported, is(expected));
    +  }
    +
    +  static class ErrorMatcher extends TypeSafeDiagnosingMatcher<ObsoleteConfigurationMonitor.Error> {
    +    final Matcher<String> messageMatcher;
    +
    +    public ErrorMatcher(final Matcher<String> messageMatcher) {
    +      this.messageMatcher = messageMatcher;
    +    }
    +
    +    @Override
    +    protected boolean matchesSafely(final ObsoleteConfigurationMonitor.Error item, final Description mismatchDescription) {
    +      return messageMatcher.matches(item.message);
    +    }
    +
    +    @Override
    +    public void describeTo(final Description description) {
    +      description.appendDescriptionOf(messageMatcher);
    +    }
    +  }
    +}
    
  • src/test/java/hudson/plugins/view/dashboard/core/ImagePortletTest.java+60 0 added
    @@ -0,0 +1,60 @@
    +package hudson.plugins.view.dashboard.core;
    +
    +import static org.hamcrest.MatcherAssert.assertThat;
    +import static org.hamcrest.Matchers.emptyIterable;
    +import static org.hamcrest.Matchers.hasSize;
    +import static org.hamcrest.Matchers.is;
    +
    +import com.gargoylesoftware.htmlunit.html.DomNode;
    +import com.gargoylesoftware.htmlunit.html.HtmlPage;
    +import hudson.plugins.view.dashboard.Dashboard;
    +import java.util.Arrays;
    +import java.util.List;
    +import org.junit.Rule;
    +import org.junit.Test;
    +import org.jvnet.hudson.test.Issue;
    +import org.jvnet.hudson.test.JenkinsRule;
    +
    +public class ImagePortletTest {
    +  @Rule public JenkinsRule j = new JenkinsRule();
    +
    +  @Test
    +  @Issue("SECURITY-2233")
    +  public void imagePortletValidation() throws Exception {
    +    j.createFreeStyleProject("p1");
    +
    +    Dashboard dashboard = new Dashboard("dash1");
    +    dashboard.setIncludeRegex(".*");
    +    j.jenkins.addView(dashboard);
    +
    +    for (String invalid :
    +        Arrays.asList("", "<img/src/onerror=alert(\"XSS\")>", "ftp://example.com")) {
    +      ImagePortlet imagePortlet = new ImagePortlet("bar", invalid);
    +      dashboard.getBottomPortlets().clear();
    +      dashboard.getBottomPortlets().add(imagePortlet);
    +      assertThat(imagePortlet.isUrlValid(), is(false));
    +      HtmlPage page = j.createWebClient().goTo("view/dash1/");
    +      assertThat(findError(page), hasSize(1));
    +      assertThat(findImage(page), is(emptyIterable()));
    +    }
    +
    +    for (String valid :
    +        Arrays.asList("http://example.com/img.png", "//example.com/img.png", "/some/img.png")) {
    +      ImagePortlet imagePortlet = new ImagePortlet("bar", valid);
    +      dashboard.getBottomPortlets().clear();
    +      dashboard.getBottomPortlets().add(imagePortlet);
    +      assertThat(imagePortlet.isUrlValid(), is(true));
    +      HtmlPage page = j.createWebClient().goTo("view/dash1/");
    +      assertThat(findError(page), is(emptyIterable()));
    +      assertThat(findImage(page), hasSize(1));
    +    }
    +  }
    +
    +  private List<DomNode> findImage(HtmlPage page) {
    +    return page.getByXPath("//table[@id='portlet-bottomPortlets-0']//img");
    +  }
    +
    +  private List<DomNode> findError(HtmlPage page) {
    +    return page.getByXPath("//div[@class='error']");
    +  }
    +}
    
  • src/test/resources/hudson/plugins/view/dashboard/casc-export.yml+1 1 modified
    @@ -33,8 +33,8 @@ dashboard:
           iframeSource: "test"
           name: "Iframe Portlet"
       - imagePortlet:
    +      imageUrl: "test"
           name: "Image"
    -      url: "test"
       name: "test"
       recurse: true
       rightPortlets:
    
  • src/test/resources/hudson/plugins/view/dashboard/casc_image_backcompat_export.yml+27 0 added
    @@ -0,0 +1,27 @@
    +dashboard:
    +  columns:
    +  - "status"
    +  - "weather"
    +  - "jobName"
    +  - "lastSuccess"
    +  - "lastFailure"
    +  - "lastDuration"
    +  - "buildButton"
    +  hideJenkinsPanels: true
    +  includeRegex: ".*"
    +  includeStdJobList: true
    +  leftPortlets:
    +  - imagePortlet:
    +      imageUrl: "test-backcompat"
    +      name: "Image"
    +  name: "test"
    +  recurse: true
    +  topPortlets:
    +  - statSlaves:
    +      name: "Agent statistics"
    +  - statBuilds:
    +      name: "Build statistics"
    +  - latestBuilds:
    +      name: "Latest builds"
    +      numBuilds: 10
    +  useCssStyle: true
    
  • src/test/resources/hudson/plugins/view/dashboard/casc_image_backcompat_import.yml+34 0 added
    @@ -0,0 +1,34 @@
    +---
    +configuration-as-code:
    +  deprecated: warn
    +jenkins:
    +  views:
    +    - dashboard:
    +        name: "test"
    +        columns:
    +          - "status"
    +          - "weather"
    +          - "jobName"
    +          - "lastSuccess"
    +          - "lastFailure"
    +          - "lastDuration"
    +          - "buildButton"
    +        recurse: true
    +        includeRegex: ".*"
    +        includeStdJobList: true
    +        hideJenkinsPanels: true
    +        useCssStyle: true
    +        leftPortletWidth: "50%"
    +        rightPortletWidth: "50%"
    +        topPortlets:
    +          - statSlaves:
    +              name: "Agent statistics"
    +          - statBuilds:
    +              name: "Build statistics"
    +          - latestBuilds:
    +              name: "Latest builds"
    +              numBuilds: 10
    +        leftPortlets:
    +          - imagePortlet:
    +              name: "Image"
    +              url: "test-backcompat"
    
  • src/test/resources/hudson/plugins/view/dashboard/casc.yml+1 1 modified
    @@ -33,7 +33,7 @@ jenkins:
                   name: "Iframe Portlet"
               - imagePortlet:
                   name: "Image"
    -              url: "test"
    +              imageUrl: "test"
             rightPortlets:
               - hudsonStdJobsPortlet:
                   name: "Jenkins jobs list"
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

1