VYPR
Moderate severityNVD Advisory· Published Jul 27, 2022· Updated Aug 3, 2024

CVE-2022-36882

CVE-2022-36882

Description

A cross-site request forgery (CSRF) vulnerability in Jenkins Git Plugin 4.11.3 and earlier allows attackers to trigger builds of jobs configured to use an attacker-specified Git repository and to cause them to check out an attacker-specified commit.

AI Insight

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

CSRF in Jenkins Git Plugin before 4.11.4 allows attackers to trigger builds with an attacker-specified Git repository and commit, without authentication.

Vulnerability

Description

CVE-2022-36882 is a cross-site request forgery (CSRF) vulnerability in the Jenkins Git Plugin up to version 4.11.3. The plugin provides a webhook endpoint at /git/notifyCommit that can be used to notify Jenkins of changes to a Git repository. This endpoint, designed for basic functionality, accepts a repository URL and initiates a build of jobs configured to use that repository. The vulnerability allows an unauthenticated attacker to craft a malicious request that, when triggered by a victim with sufficient permissions (e.g., a Jenkins administrator or user with build trigger permissions), causes Jenkins to trigger a build of any job that is configured to use the attacker-specified Git repository and to check out an attacker-specified commit. The root cause is the lack of a CSRF token or other state-changing request validation on the /git/notifyCommit endpoint [1][2].

Exploitation

To exploit this vulnerability, an attacker needs to trick a Jenkins user who has access to the /git/notifyCommit endpoint into submitting a crafted request. This can be achieved through social engineering, such as embedding the malicious request in a link or image tag on a website visited by the victim. The attack does not require authentication to the Jenkins instance itself, but the victim must have the necessary permissions (e.g., Job/Build permission) for the affected jobs. The attacker must know the URL of the Jenkins server and the name of a job that uses a Git repository which the attacker can specify (or the attacker can supply a repository URL that matches one of the job's configured SCMs) [1][2].

Impact

A successful CSRF attack against the /git/notifyCommit endpoint enables the attacker to trigger builds of arbitrary jobs that are configured to use a Git repository. Moreover, the attacker can force the build to check out a specific commit, which could be malicious (e.g., containing backdoors or altered code). This could lead to supply chain compromise, where the attacker-controlled code is integrated into the software produced by the build. The CVSS score for this vulnerability is Medium [1][2][3].

Mitigation

The vulnerability is fixed in Git Plugin version 4.11.4. Users should upgrade to this version immediately. The fix adds CSRF protection (e.g., requiring a POST request with a valid CSRF token) to the /git/notifyCommit endpoint. Additionally, administrators can consider disabling the webhook endpoint or restricting access to it via Jenkins' global security settings if the endpoint is not needed [1][2][4].

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:gitMaven
< 4.11.44.11.4

Affected products

2

Patches

1
b46165c74a0b

[SECURITY-284][SECURITY-907]

https://github.com/jenkinsci/git-pluginYaroslav AfenkinJul 18, 2022via ghsa
13 files changed · +659 35
  • README.adoc+15 4 modified
    @@ -175,10 +175,10 @@ Refer to webhook documentation for your repository:
     * link:https://github.com/jenkinsci/gitea-plugin/blob/master/docs/README.md[Gitea]
     
     Other git repositories can use a link:https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks[post-receive hook] in the remote repository to notify Jenkins of changes.
    -Add the following line in your `hooks/post-receive` file on the git server, replacing <URL of the Git repository> with the fully qualified URL you use when cloning the repository.
    +Add the following line in your `hooks/post-receive` file on the git server, replacing <URL of the Git repository> with the fully qualified URL you use when cloning the repository, and replacing <Access token> with a token generated by a Jenkins administrator using the "Git plugin notifyCommit access tokens" section of the "Configure Global Security" page.
     
     ....
    -curl http://yourserver/git/notifyCommit?url=<URL of the Git repository>
    +curl http://yourserver/git/notifyCommit?url=<URL of the Git repository>&token=<Access token>
     ....
     
     This will scan all the jobs that:
    @@ -191,8 +191,19 @@ If polling finds a change worthy of a build, a build will be triggered.
     
     This allows a notify script to remain the same for all Jenkins jobs.
     Or if you have multiple repositories under a single repository host application (such as Gitosis), you can share a single post-receive hook script with all the repositories.
    -Finally, this URL doesn't require authentication even for secured Jenkins, because the server doesn't directly use anything that the client is sending.
    -It polls to verify that there is a change before it actually starts a build.
    +
    +The `token` parameter is required by default as a security measure, but can be disabled by the following link:https://www.jenkins.io/doc/book/managing/system-properties/[system property]:
    +
    +....
    +hudson.plugins.git.GitStatus.NOTIFY_COMMIT_ACCESS_CONTROL
    +....
    +
    +It has two modes:
    +
    +* `disabled-for-polling` - Allows unauthenticated requests as long as they only request polling of the repository supplied in the `url` query parameter. Prohibits unauthenticated requests that attempt to schedule a build immediately by providing a
    +`sha1` query parameter.
    +* `disabled` - Fully disables the access token mechanism and allows all requests to `notifyCommit`
    +to be unauthenticated. *This option is insecure and is not recommended.*
     
     When notifyCommit is successful, the list of triggered projects is returned.
     
    
  • src/main/java/hudson/plugins/git/ApiTokenPropertyConfiguration.java+183 0 added
    @@ -0,0 +1,183 @@
    +package hudson.plugins.git;
    +
    +import edu.umd.cs.findbugs.annotations.NonNull;
    +import hudson.Extension;
    +import hudson.Util;
    +import hudson.model.PersistentDescriptor;
    +import hudson.util.HttpResponses;
    +import jenkins.model.GlobalConfiguration;
    +import jenkins.model.GlobalConfigurationCategory;
    +import jenkins.model.Jenkins;
    +import net.jcip.annotations.GuardedBy;
    +import net.sf.json.JSONObject;
    +import org.apache.commons.lang.StringUtils;
    +import org.jenkinsci.Symbol;
    +import org.kohsuke.accmod.Restricted;
    +import org.kohsuke.accmod.restrictions.NoExternalUse;
    +import org.kohsuke.stapler.HttpResponse;
    +import org.kohsuke.stapler.StaplerRequest;
    +import org.kohsuke.stapler.interceptor.RequirePOST;
    +
    +import java.io.Serializable;
    +import java.nio.charset.StandardCharsets;
    +import java.security.MessageDigest;
    +import java.security.NoSuchAlgorithmException;
    +import java.security.SecureRandom;
    +import java.util.ArrayList;
    +import java.util.Collection;
    +import java.util.Collections;
    +import java.util.List;
    +import java.util.UUID;
    +import java.util.logging.Level;
    +import java.util.logging.Logger;
    +
    +
    +@Extension
    +@Restricted(NoExternalUse.class)
    +@Symbol("apiTokenProperty")
    +public class ApiTokenPropertyConfiguration extends GlobalConfiguration implements PersistentDescriptor {
    +
    +    private static final Logger LOGGER = Logger.getLogger(ApiTokenPropertyConfiguration.class.getName());
    +    private static final SecureRandom RANDOM = new SecureRandom();
    +    private static final String HASH_ALGORITHM = "SHA-256";
    +
    +    @GuardedBy("this")
    +    private final List<HashedApiToken> apiTokens;
    +
    +    public ApiTokenPropertyConfiguration() {
    +        this.apiTokens = new ArrayList<>();
    +    }
    +
    +    public static ApiTokenPropertyConfiguration get() {
    +        return GlobalConfiguration.all().get(ApiTokenPropertyConfiguration.class);
    +    }
    +
    +    @NonNull
    +    @Override
    +    public GlobalConfigurationCategory getCategory() {
    +        return GlobalConfigurationCategory.get(GlobalConfigurationCategory.Security.class);
    +    }
    +
    +    @RequirePOST
    +    public HttpResponse doGenerate(StaplerRequest req) {
    +        Jenkins.get().checkPermission(Jenkins.ADMINISTER);
    +
    +        String apiTokenName = req.getParameter("apiTokenName");
    +        JSONObject json = this.generateApiToken(apiTokenName);
    +        save();
    +
    +        return HttpResponses.okJSON(json);
    +    }
    +
    +    public JSONObject generateApiToken(@NonNull String name) {
    +        byte[] random = new byte[16];
    +        RANDOM.nextBytes(random);
    +
    +        String plainTextApiToken = Util.toHexString(random);
    +        assert plainTextApiToken.length() == 32;
    +
    +        String apiTokenValueHashed = Util.toHexString(hashedBytes(plainTextApiToken.getBytes(StandardCharsets.US_ASCII)));
    +        HashedApiToken apiToken = new HashedApiToken(name, apiTokenValueHashed);
    +
    +        synchronized (this) {
    +            this.apiTokens.add(apiToken);
    +        }
    +
    +        JSONObject json = new JSONObject();
    +        json.put("uuid", apiToken.getUuid());
    +        json.put("name", apiToken.getName());
    +        json.put("value", plainTextApiToken);
    +
    +        return json;
    +    }
    +
    +    @NonNull
    +    private static byte[] hashedBytes(byte[] tokenBytes) {
    +        MessageDigest digest;
    +        try {
    +            digest = MessageDigest.getInstance(HASH_ALGORITHM);
    +        } catch (NoSuchAlgorithmException e) {
    +            throw new AssertionError("There is no " + HASH_ALGORITHM + " available in this system", e);
    +        }
    +        return digest.digest(tokenBytes);
    +    }
    +
    +    @RequirePOST
    +    public HttpResponse doRevoke(StaplerRequest req) {
    +        Jenkins.get().checkPermission(Jenkins.ADMINISTER);
    +
    +        String apiTokenUuid = req.getParameter("apiTokenUuid");
    +        if (StringUtils.isBlank(apiTokenUuid)) {
    +            return HttpResponses.errorWithoutStack(400, "API token UUID cannot be empty");
    +        }
    +
    +        synchronized (this) {
    +            this.apiTokens.removeIf(apiToken -> apiToken.getUuid().equals(apiTokenUuid));
    +        }
    +        save();
    +
    +        return HttpResponses.ok();
    +    }
    +
    +    public synchronized Collection<HashedApiToken> getApiTokens() {
    +        return Collections.unmodifiableList(new ArrayList<>(this.apiTokens));
    +    }
    +
    +    public boolean isValidApiToken(String plainApiToken) {
    +        if (StringUtils.isBlank(plainApiToken)) {
    +            return false;
    +        }
    +
    +        return this.hasMatchingApiToken(plainApiToken);
    +    }
    +
    +    public synchronized boolean hasMatchingApiToken(@NonNull String plainApiToken) {
    +        byte[] hash = hashedBytes(plainApiToken.getBytes(StandardCharsets.US_ASCII));
    +        return this.apiTokens.stream().anyMatch(apiToken -> apiToken.match(hash));
    +    }
    +
    +    public static class HashedApiToken implements Serializable {
    +
    +        private static final long serialVersionUID = 1L;
    +
    +        private final String uuid;
    +        private final String name;
    +        private final String hash;
    +
    +        private HashedApiToken(String name, String hash) {
    +            this.uuid = UUID.randomUUID().toString();
    +            this.name = name;
    +            this.hash = hash;
    +        }
    +
    +        private HashedApiToken(String uuid, String name, String hash) {
    +            this.uuid = uuid;
    +            this.name = name;
    +            this.hash = hash;
    +        }
    +
    +        public String getUuid() {
    +            return uuid;
    +        }
    +
    +        public String getName() {
    +            return name;
    +        }
    +
    +        public String getHash() {
    +            return hash;
    +        }
    +
    +        private boolean match(byte[] hashedBytes) {
    +            byte[] hashFromHex;
    +            try {
    +                hashFromHex = Util.fromHexString(hash);
    +            } catch (NumberFormatException e) {
    +                LOGGER.log(Level.INFO, "The API token with name=[{0}] is not in hex-format and so cannot be used", name);
    +                return false;
    +            }
    +
    +            return MessageDigest.isEqual(hashFromHex, hashedBytes);
    +        }
    +    }
    +}
    
  • src/main/java/hudson/plugins/git/GitStatus.java+24 5 modified
    @@ -12,21 +12,20 @@
     import hudson.security.ACL;
     import hudson.security.ACLContext;
     import hudson.triggers.SCMTrigger;
    -import java.io.IOException;
     import java.io.PrintWriter;
     import java.net.URISyntaxException;
     import java.util.*;
     import java.util.logging.Level;
     import java.util.logging.Logger;
     import java.util.regex.Pattern;
    -import javax.servlet.ServletException;
     import javax.servlet.http.HttpServletRequest;
     
     import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
     import static javax.servlet.http.HttpServletResponse.SC_OK;
     import jenkins.model.Jenkins;
     import jenkins.scm.api.SCMEvent;
     import jenkins.triggers.SCMTriggerItem;
    +import jenkins.util.SystemProperties;
     import org.apache.commons.lang.StringUtils;
     import static org.apache.commons.lang.StringUtils.isNotEmpty;
     
    @@ -39,6 +38,9 @@
      */
     @Extension
     public class GitStatus implements UnprotectedRootAction {
    +    static /* not final */ String NOTIFY_COMMIT_ACCESS_CONTROL =
    +            SystemProperties.getString(GitStatus.class.getName() + ".NOTIFY_COMMIT_ACCESS_CONTROL");
    +
         @Override
         public String getDisplayName() {
             return "Git";
    @@ -113,8 +115,25 @@ public String toString() {
         }
     
         public HttpResponse doNotifyCommit(HttpServletRequest request, @QueryParameter(required=true) String url,
    -                                       @QueryParameter(required=false) String branches,
    -                                       @QueryParameter(required=false) String sha1) throws ServletException, IOException {
    +                                       @QueryParameter() String branches, @QueryParameter() String sha1,
    +                                       @QueryParameter() String token) {
    +        if (!"disabled".equalsIgnoreCase(NOTIFY_COMMIT_ACCESS_CONTROL)
    +                && !"disabled-for-polling".equalsIgnoreCase(NOTIFY_COMMIT_ACCESS_CONTROL)) {
    +            if (StringUtils.isEmpty(token)) {
    +                return HttpResponses.errorWithoutStack(401, "An access token is required. Please refer to Git plugin documentation for details.");
    +            }
    +            if (!ApiTokenPropertyConfiguration.get().isValidApiToken(token)) {
    +                return HttpResponses.errorWithoutStack(403, "Invalid access token");
    +            }
    +        }
    +        if ("disabled-for-polling".equalsIgnoreCase(NOTIFY_COMMIT_ACCESS_CONTROL) && StringUtils.isNotEmpty(sha1)) {
    +            if (StringUtils.isEmpty(token)) {
    +                return HttpResponses.errorWithoutStack(401, "An access token is required when using the sha1 parameter. Please refer to Git plugin documentation for details.");
    +            } 
    +            if (!ApiTokenPropertyConfiguration.get().isValidApiToken(token)) {
    +                return HttpResponses.errorWithoutStack(403, "Invalid access token");
    +            }
    +        }
             lastURL = url;
             lastBranches = branches;
             if(StringUtils.isNotBlank(sha1)&&!SHA1_PATTERN.matcher(sha1.trim()).matches()){
    @@ -197,7 +216,7 @@ private static String normalizePath(String path) {
         }
     
         /**
    -     * Contributes to a {@link #doNotifyCommit(HttpServletRequest, String, String, String)} response.
    +     * Contributes to a {@link #doNotifyCommit(HttpServletRequest, String, String, String, String)} response.
          *
          * @since 1.4.1
          */
    
  • src/main/resources/hudson/plugins/git/ApiTokenPropertyConfiguration/config.jelly+54 0 added
    @@ -0,0 +1,54 @@
    +<?jelly escape-by-default='true'?>
    +
    +<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:f="/lib/form" xmlns:l="/lib/layout">
    +    <f:section title="${%Git plugin notifyCommit access tokens}">
    +        <st:adjunct includes="hudson.plugins.git.ApiTokenPropertyConfiguration.resources" />
    +        <f:entry title="${%Current access tokens}" help="${descriptor.getHelpFile('tokens')}">
    +            <div class="api-token-list">
    +                <j:set var="apiTokens" value="${instance.apiTokens}" />
    +                <div class="api-token-list-empty-item ${apiTokens == null || apiTokens.isEmpty() ? '' : 'hidden'}">
    +                    <div class="list-empty-message">${%There are no access tokens yet.}</div>
    +                </div>
    +                <f:repeatable var="apiToken" items="${apiTokens}" minimum="0" add="${%Add new access token}">
    +                    <j:choose>
    +                        <j:when test="${apiToken != null}">
    +                            <input type="hidden" class="api-token-uuid-input" name="apiTokenUuid" value="${apiToken.uuid}" />
    +                            <div class="api-token-list-item-row api-token-list-existing-token">
    +                                <f:textbox readonly="true" value="${apiToken.name}" />
    +                                <a href="#" onclick="return revokeApiToken(this)" class="yui-button api-token-revoke-button"
    +                                   data-confirm="${%Are you sure you want to revoke this access token?}"
    +                                   data-target-url="${descriptor.descriptorFullUrl}/revoke">
    +                                    ${%Revoke}
    +                                </a>
    +                            </div>
    +                        </j:when>
    +                        <j:otherwise>
    +                            <div class="api-token-list-item">
    +                                <div class="api-token-list-item-row">
    +                                    <input type="hidden" class="api-token-uuid-input" name="apiTokenUuid" value="${apiToken.uuid}" />
    +                                    <f:textbox clazz="api-token-name-input" name="apiTokenName" placeholder="${%Access token name}"/>
    +                                    <span class="new-api-token-value hidden"><!-- to be filled by JS --></span>
    +                                    <span class="yui-button api-token-save-button">
    +                                        <button type="button" tabindex="0" data-target-url="${descriptor.descriptorFullUrl}/generate" onclick="saveApiToken(this)">
    +                                            ${%Generate}
    +                                        </button>
    +                                    </span>
    +                                    <span class="api-token-cancel-button">
    +                                        <f:repeatableDeleteButton value="${%Cancel}" />
    +                                    </span>
    +                                    <l:copyButton message="${%Copied}" text="" clazz="hidden" tooltip="${%Copy to clipboard}" />
    +                                    <a href="#" onclick="return revokeApiToken(this)" class="yui-button api-token-revoke-button hidden"
    +                                       data-confirm="${%Are you sure you want to revoke this access token?}"
    +                                       data-target-url="${descriptor.descriptorFullUrl}/revoke">
    +                                        ${%Revoke}
    +                                    </a>
    +                                </div>
    +                                <span class="warning api-token-warning-message hidden">${%Access token will only be displayed once.}</span>
    +                            </div>
    +                        </j:otherwise>
    +                    </j:choose>
    +                </f:repeatable>
    +            </div>
    +        </f:entry>
    +    </f:section>
    +</j:jelly>
    
  • src/main/resources/hudson/plugins/git/ApiTokenPropertyConfiguration/help-tokens.html+15 0 added
    @@ -0,0 +1,15 @@
    +<div>
    +    <p>These access tokens serve as a way of authenticating requests to the <code>notifyCommit</code> endpoint.
    +    <p>By default, all requests to <code>notifyCommit</code> must include a valid token in the <code>token</code> query parameter. However, it is possible to disable
    +        that requirement with the <a href="https://www.jenkins.io/doc/book/managing/system-properties/">system property</a>:
    +    <pre><code>hudson.plugins.git.GitStatus.NOTIFY_COMMIT_ACCESS_CONTROL</code></pre>
    +    <br/>
    +    It has two modes:
    +    <ul>
    +        <li><code>disabled-for-polling</code> - Allows unauthenticated requests as long as they only request polling of the repository supplied in the
    +            <code>url</code> query parameter. Prohibits unauthenticated requests that attempt to schedule a build immediately by providing a
    +            <code>sha1</code> query parameter.</li>
    +        <li><code>disabled</code> - Fully disables the access token mechanism and allows all requests to <code>notifyCommit</code>
    +            to be unauthenticated. <b>This option is insecure and is not recommended.</b></li>
    +    </ul>
    +</div>
    
  • src/main/resources/hudson/plugins/git/ApiTokenPropertyConfiguration/resources.css+18 0 added
    @@ -0,0 +1,18 @@
    +.api-token-list .api-token-list-item-row {
    +    display: flex;
    +    align-items: center;
    +    max-width: 700px;
    +}
    +.api-token-list .api-token-list-item-row.api-token-list-existing-api-token {
    +    justify-content: space-between;
    +}
    +.api-token-list .api-token-list-item .hidden, .api-token-list .api-token-list-empty-item.hidden {
    +    display: none;
    +}
    +
    +.api-token-list .api-token-revoke-button, .api-token-list .new-api-token-value {
    +    padding: 0 0.5rem;
    +}
    +.api-token-list .api-token-warning-message, .api-token-list .api-token-save-button {
    +    margin: 0.5rem 0;
    +}
    
  • src/main/resources/hudson/plugins/git/ApiTokenPropertyConfiguration/resources.js+81 0 added
    @@ -0,0 +1,81 @@
    +function revokeApiToken(anchorRevoke) {
    +    const repeatedChunk = anchorRevoke.up('.repeated-chunk');
    +    const apiTokenList = repeatedChunk.up('.api-token-list');
    +    const confirmMessage = anchorRevoke.getAttribute('data-confirm');
    +    const targetUrl = anchorRevoke.getAttribute('data-target-url');
    +    const inputUuid = repeatedChunk.querySelector('.api-token-uuid-input');
    +    const apiTokenUuid = inputUuid.value;
    +
    +    if (confirm(confirmMessage)) {
    +        new Ajax.Request(targetUrl, {
    +            method: "post",
    +            parameters: {apiTokenUuid: apiTokenUuid},
    +            onSuccess: function(res, _) {
    +                repeatedChunk.remove();
    +                adjustEmptyListMessage(apiTokenList);
    +            }
    +        });
    +    }
    +
    +    return false;
    +}
    +
    +function saveApiToken(button){
    +    if (button.hasClassName('request-pending')) {
    +        // avoid multiple requests to be sent if user is clicking multiple times
    +        return;
    +    }
    +    button.addClassName('request-pending');
    +    const targetUrl = button.getAttribute('data-target-url');
    +    const repeatedChunk = button.up('.repeated-chunk');
    +    const apiTokenList = repeatedChunk.up('.api-token-list');
    +    const nameInput = repeatedChunk.querySelector('.api-token-name-input');
    +    const apiTokenName = nameInput.value;
    +
    +    new Ajax.Request(targetUrl, {
    +        method: "post",
    +        parameters: {apiTokenName: apiTokenName},
    +        onSuccess: function(res, _) {
    +            const { name, value, uuid } = res.responseJSON.data;
    +            nameInput.value = name;
    +
    +            const apiTokenValueSpan = repeatedChunk.querySelector('.new-api-token-value');
    +            apiTokenValueSpan.innerText = value;
    +            apiTokenValueSpan.removeClassName('hidden');
    +
    +            const apiTokenCopyButton = repeatedChunk.querySelector('.copy-button');
    +            apiTokenCopyButton.setAttribute('text', value);
    +            apiTokenCopyButton.removeClassName('hidden');
    +
    +            const uuidInput = repeatedChunk.querySelector('.api-token-uuid-input');
    +            uuidInput.value = uuid;
    +
    +            const warningMessage = repeatedChunk.querySelector('.api-token-warning-message');
    +            warningMessage.removeClassName('hidden');
    +
    +            // we do not want to allow user to create twice api token using same name by mistake
    +            button.remove();
    +
    +            const revokeButton = repeatedChunk.querySelector('.api-token-revoke-button');
    +            revokeButton.removeClassName('hidden');
    +
    +            const cancelButton = repeatedChunk.querySelector('.api-token-cancel-button');
    +            cancelButton.addClassName('hidden');
    +
    +            repeatedChunk.addClassName('api-token-list-fresh-item');
    +
    +            adjustEmptyListMessage(apiTokenList);
    +        }
    +    });
    +}
    +
    +function adjustEmptyListMessage(apiTokenList) {
    +    const emptyListMessageClassList = apiTokenList.querySelector('.api-token-list-empty-item').classList;
    +
    +    const apiTokenListLength = apiTokenList.querySelectorAll('.api-token-list-existing-item, .api-token-list-fresh-item').length;
    +    if (apiTokenListLength >= 1) {
    +        emptyListMessageClassList.add("hidden");
    +    } else {
    +        emptyListMessageClassList.remove("hidden");
    +    }
    +}
    
  • src/test/java/hudson/plugins/git/AbstractGitProject.java+5 0 modified
    @@ -59,6 +59,7 @@
     import org.junit.Rule;
     
     import org.jvnet.hudson.test.CaptureEnvironmentBuilder;
    +import org.jvnet.hudson.test.FlagRule;
     import org.jvnet.hudson.test.JenkinsRule;
     
     /**
    @@ -70,6 +71,10 @@ public class AbstractGitProject extends AbstractGitRepository {
         @Rule
         public JenkinsRule jenkins = new JenkinsRule();
     
    +    @Rule
    +    public FlagRule<String> notifyCommitAccessControl =
    +            new FlagRule<>(() -> GitStatus.NOTIFY_COMMIT_ACCESS_CONTROL, x -> GitStatus.NOTIFY_COMMIT_ACCESS_CONTROL = x);
    +
         protected FreeStyleProject setupProject(List<BranchSpec> branches, boolean authorOrCommitter) throws Exception {
             FreeStyleProject project = jenkins.createFreeStyleProject();
             GitSCM scm = new GitSCM(remoteConfigs(), branches,
    
  • src/test/java/hudson/plugins/git/GitStatusCrumbExclusionTest.java+17 3 modified
    @@ -39,7 +39,10 @@ public class GitStatusCrumbExclusionTest {
         private final String branchArgument;
         private final byte[] branchArgumentBytes;
     
    -    public GitStatusCrumbExclusionTest() throws IOException {
    +    private final String notifyCommitApiToken;
    +    private final byte[] notifyCommitApiTokenBytes;
    +
    +    public GitStatusCrumbExclusionTest() throws Exception {
             String jenkinsUrl = r.getURL().toExternalForm();
             if (!jenkinsUrl.endsWith("/")) {
                 jenkinsUrl = jenkinsUrl + "/";
    @@ -56,6 +59,9 @@ public GitStatusCrumbExclusionTest() throws IOException {
     
             branchArgument = "branches=origin/some-branch-name";
             branchArgumentBytes = branchArgument.getBytes(StandardCharsets.UTF_8);
    +
    +        notifyCommitApiToken = "token=" + ApiTokenPropertyConfiguration.get().generateApiToken("test").getString("value");
    +        notifyCommitApiTokenBytes = notifyCommitApiToken.getBytes(StandardCharsets.UTF_8);
         }
     
         private HttpURLConnection connectionPOST;
    @@ -86,6 +92,8 @@ public void testPOSTValidPathNoArgument() throws Exception {
         public void testPOSTValidPathMandatoryArgument() throws Exception {
             try (OutputStream os = connectionPOST.getOutputStream()) {
                 os.write(urlArgumentBytes);
    +            os.write(separatorBytes);
    +            os.write(notifyCommitApiTokenBytes);
             }
             assertThat(connectionPOST.getResponseCode(), is(HttpURLConnection.HTTP_OK));
             assertThat(connectionPOST.getResponseMessage(), is("OK"));
    @@ -97,6 +105,8 @@ public void testPOSTValidPathEmptyMandatoryArgument() throws Exception {
                 String urlEmptyArgument = "url="; // Empty argument is not a valid URL
                 byte[] urlEmptyArgumentBytes = urlEmptyArgument.getBytes(StandardCharsets.UTF_8);
                 os.write(urlEmptyArgumentBytes);
    +            os.write(separatorBytes);
    +            os.write(notifyCommitApiTokenBytes);
             }
             assertThat(connectionPOST.getResponseCode(), is(HttpURLConnection.HTTP_BAD_REQUEST));
             assertThat(connectionPOST.getResponseMessage(), is("Bad Request"));
    @@ -108,6 +118,8 @@ public void testPOSTValidPathBadURLInMandatoryArgument() throws Exception {
                 String urlBadArgument = "url=" + "http://256.256.256.256/"; // Not a valid URI per Java 8 javadoc
                 byte[] urlBadArgumentBytes = urlBadArgument.getBytes(StandardCharsets.UTF_8);
                 os.write(urlBadArgumentBytes);
    +            os.write(separatorBytes);
    +            os.write(notifyCommitApiTokenBytes);
             }
             assertThat(connectionPOST.getResponseCode(), is(HttpURLConnection.HTTP_OK));
             assertThat(connectionPOST.getResponseMessage(), is("OK"));
    @@ -119,6 +131,8 @@ public void testPOSTValidPathMandatoryAndOptionalArgument() throws Exception {
                 os.write(urlArgumentBytes);
                 os.write(separatorBytes);
                 os.write(branchArgumentBytes);
    +            os.write(separatorBytes);
    +            os.write(notifyCommitApiTokenBytes);
             }
             assertThat(connectionPOST.getResponseCode(), is(HttpURLConnection.HTTP_OK));
             assertThat(connectionPOST.getResponseMessage(), is("OK"));
    @@ -231,7 +245,7 @@ public void testGETValidPathNoArgument() throws Exception {
     
         @Test
         public void testGETValidPathMandatoryArgument() throws Exception {
    -        URL getURL = new URL(notifyCommitURL + "?" + urlArgument);
    +        URL getURL = new URL(notifyCommitURL + "?" + urlArgument + separator + notifyCommitApiToken);
             HttpURLConnection connectionGET = (HttpURLConnection) getURL.openConnection();
             connectionGET.setRequestMethod("GET");
             connectionGET.connect();
    @@ -242,7 +256,7 @@ public void testGETValidPathMandatoryArgument() throws Exception {
     
         @Test
         public void testGETValidPathMandatoryAndOptionalArgument() throws Exception {
    -        URL getURL = new URL(notifyCommitURL + "?" + urlArgument + separator + branchArgument);
    +        URL getURL = new URL(notifyCommitURL + "?" + urlArgument + separator + branchArgument + separator + notifyCommitApiToken);
             HttpURLConnection connectionGET = (HttpURLConnection) getURL.openConnection();
             connectionGET.setRequestMethod("GET");
             connectionGET.connect();
    
  • src/test/java/hudson/plugins/git/GitStatusTest.java+118 21 modified
    @@ -54,6 +54,7 @@ public class GitStatusTest extends AbstractGitProject {
         private String repoURL;
         private String branch;
         private String sha1;
    +    private String notifyCommitApiToken;
     
         @Before
         public void setUp() throws Exception {
    @@ -65,6 +66,9 @@ public void setUp() throws Exception {
             this.repoURL = new File(".").getAbsolutePath();
             this.branch = "**";
             this.sha1 = "7bb68ef21dc90bd4f7b08eca876203b2e049198d";
    +        if (jenkins.jenkins != null) {
    +            this.notifyCommitApiToken = ApiTokenPropertyConfiguration.get().generateApiToken("test").getString("value");
    +        }
         }
     
         @After
    @@ -154,7 +158,7 @@ public void testDoNotifyCommitWithNoBranches() throws Exception {
             SCMTrigger bMasterTrigger = setupProjectWithTrigger("b", "master", false);
             SCMTrigger bTopicTrigger = setupProjectWithTrigger("b", "topic", false);
     
    -        this.gitStatus.doNotifyCommit(requestWithNoParameter, "a", "", null);
    +        this.gitStatus.doNotifyCommit(requestWithNoParameter, "a", "", null, notifyCommitApiToken);
             Mockito.verify(aMasterTrigger).run();
             Mockito.verify(aTopicTrigger).run();
             Mockito.verify(bMasterTrigger, Mockito.never()).run();
    @@ -170,7 +174,7 @@ public void testDoNotifyCommitWithNoMatchingUrl() throws Exception {
             SCMTrigger bMasterTrigger = setupProjectWithTrigger("b", "master", false);
             SCMTrigger bTopicTrigger = setupProjectWithTrigger("b", "topic", false);
     
    -        this.gitStatus.doNotifyCommit(requestWithNoParameter, "nonexistent", "", null);
    +        this.gitStatus.doNotifyCommit(requestWithNoParameter, "nonexistent", "", null, notifyCommitApiToken);
             Mockito.verify(aMasterTrigger, Mockito.never()).run();
             Mockito.verify(aTopicTrigger, Mockito.never()).run();
             Mockito.verify(bMasterTrigger, Mockito.never()).run();
    @@ -186,7 +190,7 @@ public void testDoNotifyCommitWithOneBranch() throws Exception {
             SCMTrigger bMasterTrigger = setupProjectWithTrigger("b", "master", false);
             SCMTrigger bTopicTrigger = setupProjectWithTrigger("b", "topic", false);
     
    -        this.gitStatus.doNotifyCommit(requestWithNoParameter, "a", "master", null);
    +        this.gitStatus.doNotifyCommit(requestWithNoParameter, "a", "master", null, notifyCommitApiToken);
             Mockito.verify(aMasterTrigger).run();
             Mockito.verify(aTopicTrigger, Mockito.never()).run();
             Mockito.verify(bMasterTrigger, Mockito.never()).run();
    @@ -204,7 +208,7 @@ public void testDoNotifyCommitWithTwoBranches() throws Exception {
             SCMTrigger bTopicTrigger = setupProjectWithTrigger("b", "topic", false);
             SCMTrigger bFeatureTrigger = setupProjectWithTrigger("b", "feature/def", false);
     
    -        this.gitStatus.doNotifyCommit(requestWithNoParameter, "a", "master,topic,feature/def", null);
    +        this.gitStatus.doNotifyCommit(requestWithNoParameter, "a", "master,topic,feature/def", null, notifyCommitApiToken);
             Mockito.verify(aMasterTrigger).run();
             Mockito.verify(aTopicTrigger).run();
             Mockito.verify(aFeatureTrigger).run();
    @@ -223,7 +227,7 @@ public void testDoNotifyCommitWithNoMatchingBranches() throws Exception {
             SCMTrigger bMasterTrigger = setupProjectWithTrigger("b", "master", false);
             SCMTrigger bTopicTrigger = setupProjectWithTrigger("b", "topic", false);
     
    -        this.gitStatus.doNotifyCommit(requestWithNoParameter, "a", "nonexistent", null);
    +        this.gitStatus.doNotifyCommit(requestWithNoParameter, "a", "nonexistent", null, notifyCommitApiToken);
             Mockito.verify(aMasterTrigger, Mockito.never()).run();
             Mockito.verify(aTopicTrigger, Mockito.never()).run();
             Mockito.verify(bMasterTrigger, Mockito.never()).run();
    @@ -239,7 +243,7 @@ public void testDoNotifyCommitWithSlashesInBranchNames() throws Exception {
     
             SCMTrigger aSlashesTrigger = setupProjectWithTrigger("a", "name/with/slashes", false);
     
    -        this.gitStatus.doNotifyCommit(requestWithParameter, "a", "name/with/slashes", null);
    +        this.gitStatus.doNotifyCommit(requestWithParameter, "a", "name/with/slashes", null, notifyCommitApiToken);
             Mockito.verify(aSlashesTrigger).run();
             Mockito.verify(bMasterTrigger, Mockito.never()).run();
     
    @@ -252,7 +256,7 @@ public void testDoNotifyCommitWithParametrizedBranch() throws Exception {
             SCMTrigger bMasterTrigger = setupProjectWithTrigger("b", "master", false);
             SCMTrigger bTopicTrigger = setupProjectWithTrigger("b", "topic", false);
     
    -        this.gitStatus.doNotifyCommit(requestWithNoParameter, "a", "master", null);
    +        this.gitStatus.doNotifyCommit(requestWithNoParameter, "a", "master", null, notifyCommitApiToken);
             Mockito.verify(aMasterTrigger).run();
             Mockito.verify(bMasterTrigger, Mockito.never()).run();
             Mockito.verify(bTopicTrigger, Mockito.never()).run();
    @@ -264,7 +268,7 @@ public void testDoNotifyCommitWithParametrizedBranch() throws Exception {
         public void testDoNotifyCommitWithIgnoredRepository() throws Exception {
             SCMTrigger aMasterTrigger = setupProjectWithTrigger("a", "master", true);
     
    -        this.gitStatus.doNotifyCommit(requestWithNoParameter, "a", null, "");
    +        this.gitStatus.doNotifyCommit(requestWithNoParameter, "a", null, "", notifyCommitApiToken);
             Mockito.verify(aMasterTrigger, Mockito.never()).run();
     
             assertEquals("URL: a SHA1: ", this.gitStatus.toString());
    @@ -273,7 +277,7 @@ public void testDoNotifyCommitWithIgnoredRepository() throws Exception {
         @Test
         public void testDoNotifyCommitWithNoScmTrigger() throws Exception {
             setupProject("a", "master", null);
    -        this.gitStatus.doNotifyCommit(requestWithNoParameter, "a", null, "");
    +        this.gitStatus.doNotifyCommit(requestWithNoParameter, "a", null, "", notifyCommitApiToken);
             // no expectation here, however we shouldn't have a build triggered, and no exception
     
             assertEquals("URL: a SHA1: ", this.gitStatus.toString());
    @@ -321,7 +325,7 @@ private void doNotifyCommitWithTwoBranchesAndAdditionalParameter(final boolean a
             parameterMap.put("paramKey1", new String[] {"paramValue1"});
             when(requestWithParameter.getParameterMap()).thenReturn(parameterMap);
     
    -        this.gitStatus.doNotifyCommit(requestWithParameter, "a", "master,topic", null);
    +        this.gitStatus.doNotifyCommit(requestWithParameter, "a", "master,topic", null, notifyCommitApiToken);
             Mockito.verify(aMasterTrigger).run();
             Mockito.verify(aTopicTrigger).run();
             Mockito.verify(bMasterTrigger, Mockito.never()).run();
    @@ -345,39 +349,39 @@ private void doNotifyCommitWithTwoBranchesAndAdditionalParameter(final boolean a
         @Theory
         public void testDoNotifyCommitBranchWithSlash(@FromDataPoints("branchSpecPrefixes") String branchSpecPrefix) throws Exception {
             SCMTrigger trigger = setupProjectWithTrigger("remote", branchSpecPrefix + "feature/awesome-feature", false);
    -        this.gitStatus.doNotifyCommit(requestWithNoParameter, "remote", "feature/awesome-feature", null);
    +        this.gitStatus.doNotifyCommit(requestWithNoParameter, "remote", "feature/awesome-feature", null, notifyCommitApiToken);
     
             Mockito.verify(trigger).run();
         }
     
         @Theory
         public void testDoNotifyCommitBranchWithoutSlash(@FromDataPoints("branchSpecPrefixes") String branchSpecPrefix) throws Exception {
             SCMTrigger trigger = setupProjectWithTrigger("remote", branchSpecPrefix + "awesome-feature", false);
    -        this.gitStatus.doNotifyCommit(requestWithNoParameter, "remote", "awesome-feature", null);
    +        this.gitStatus.doNotifyCommit(requestWithNoParameter, "remote", "awesome-feature", null, notifyCommitApiToken);
     
             Mockito.verify(trigger).run();
         }
     
         @Theory
         public void testDoNotifyCommitBranchByBranchRef(@FromDataPoints("branchSpecPrefixes") String branchSpecPrefix) throws Exception {
             SCMTrigger trigger = setupProjectWithTrigger("remote", branchSpecPrefix + "awesome-feature", false);
    -        this.gitStatus.doNotifyCommit(requestWithNoParameter, "remote", "refs/heads/awesome-feature", null);
    +        this.gitStatus.doNotifyCommit(requestWithNoParameter, "remote", "refs/heads/awesome-feature", null, notifyCommitApiToken);
     
             Mockito.verify(trigger).run();
         }
     
         @Test
         public void testDoNotifyCommitBranchWithRegex() throws Exception {
             SCMTrigger trigger = setupProjectWithTrigger("remote", ":[^/]*/awesome-feature", false);
    -        this.gitStatus.doNotifyCommit(requestWithNoParameter, "remote", "feature/awesome-feature", null);
    +        this.gitStatus.doNotifyCommit(requestWithNoParameter, "remote", "feature/awesome-feature", null, notifyCommitApiToken);
     
             Mockito.verify(trigger).run();
         }
     
         @Test
         public void testDoNotifyCommitBranchWithWildcard() throws Exception {
             SCMTrigger trigger = setupProjectWithTrigger("remote", "origin/feature/*", false);
    -        this.gitStatus.doNotifyCommit(requestWithNoParameter, "remote", "feature/awesome-feature", null);
    +        this.gitStatus.doNotifyCommit(requestWithNoParameter, "remote", "feature/awesome-feature", null, notifyCommitApiToken);
     
             Mockito.verify(trigger).run();
         }
    @@ -488,7 +492,7 @@ private Map<String, String[]> setupParameterMap(String extraValue) {
         @Test
         public void testDoNotifyCommit() throws Exception { /* No parameters */
             setupNotifyProject();
    -        this.gitStatus.doNotifyCommit(requestWithNoParameter, repoURL, branch, sha1);
    +        this.gitStatus.doNotifyCommit(requestWithNoParameter, repoURL, branch, sha1, notifyCommitApiToken);
             assertEquals("URL: " + repoURL
                     + " SHA1: " + sha1
                     + " Branches: " + branch, this.gitStatus.toString());
    @@ -529,7 +533,7 @@ private void doNotifyCommitWithExtraParameterAllowed(final boolean allowed, Stri
             setupNotifyProject();
             String extraValue = "An-extra-value";
             when(requestWithParameter.getParameterMap()).thenReturn(setupParameterMap(extraValue));
    -        this.gitStatus.doNotifyCommit(requestWithParameter, repoURL, branch, sha1);
    +        this.gitStatus.doNotifyCommit(requestWithParameter, repoURL, branch, sha1, notifyCommitApiToken);
     
             String expected = "URL: " + repoURL
                     + " SHA1: " + sha1
    @@ -543,7 +547,7 @@ private void doNotifyCommitWithExtraParameterAllowed(final boolean allowed, Stri
         public void testDoNotifyCommitWithNullValueExtraParameter() throws Exception {
             setupNotifyProject();
             when(requestWithParameter.getParameterMap()).thenReturn(setupParameterMap(null));
    -        this.gitStatus.doNotifyCommit(requestWithParameter, repoURL, branch, sha1);
    +        this.gitStatus.doNotifyCommit(requestWithParameter, repoURL, branch, sha1, notifyCommitApiToken);
             assertEquals("URL: " + repoURL
                     + " SHA1: " + sha1
                     + " Branches: " + branch, this.gitStatus.toString());
    @@ -610,7 +614,7 @@ private void doNotifyCommitWithDefaultParameter(final boolean allowed, String sa
     
             String extraValue = "An-extra-value";
             when(requestWithParameter.getParameterMap()).thenReturn(setupParameterMap(extraValue));
    -        this.gitStatus.doNotifyCommit(requestWithParameter, repoURL, branch, sha1);
    +        this.gitStatus.doNotifyCommit(requestWithParameter, repoURL, branch, sha1, notifyCommitApiToken);
     
             String expected = "URL: " + repoURL
                     + " SHA1: " + sha1
    @@ -650,7 +654,7 @@ public void testDoNotifyCommitTriggeredHeadersLimited() throws Exception {
                 projectTriggers[i] = setupProjectWithTrigger("a", "master", false);
             }
     
    -        HttpResponse rsp = this.gitStatus.doNotifyCommit(requestWithNoParameter, "a", "master", null);
    +        HttpResponse rsp = this.gitStatus.doNotifyCommit(requestWithNoParameter, "a", "master", null, notifyCommitApiToken);
     
             // Up to 10 "Triggered" headers + 1 extra warning are returned.
             StaplerRequest sReq = mock(StaplerRequest.class);
    @@ -674,11 +678,104 @@ public void testDoNotifyCommitWithWrongSha1Content() throws Exception {
     
             String content = "<img src=onerror=alert(1)>";
     
    -        HttpResponse rsp = this.gitStatus.doNotifyCommit(requestWithNoParameter, "a", "master", content);
    +        HttpResponse rsp = this.gitStatus.doNotifyCommit(requestWithNoParameter, "a", "master", content, notifyCommitApiToken);
     
             HttpResponses.HttpResponseException responseException = ((HttpResponses.HttpResponseException) rsp);
             assertEquals(IllegalArgumentException.class, responseException.getCause().getClass());
             assertEquals("Illegal SHA1", responseException.getCause().getMessage());
     
         }
    +
    +    @Test
    +    @Issue("SECURITY-284")
    +    public void testDoNotifyCommitWithValidSha1AndValidApiToken() throws Exception {
    +        // when sha1 is provided build is scheduled right away instead of repo polling, so we do not check for trigger
    +        FreeStyleProject project = setupNotifyProject();
    +
    +        this.gitStatus.doNotifyCommit(requestWithParameter, repoURL, branch, sha1, notifyCommitApiToken);
    +
    +        jenkins.waitUntilNoActivity();
    +        FreeStyleBuild lastBuild = project.getLastBuild();
    +
    +        assertNotNull(lastBuild);
    +        assertEquals(lastBuild.getNumber(), 1);
    +    }
    +
    +    @Test
    +    @Issue("SECURITY-284")
    +    public void testDoNotifyCommitWithInvalidApiToken() throws Exception {
    +        setupProjectWithTrigger("a", "master", false);
    +        StaplerResponse res = mock(StaplerResponse.class);
    +
    +        HttpResponse httpResponse = this.gitStatus.doNotifyCommit(requestWithNoParameter, "a", "master", null, "invalid");
    +        httpResponse.generateResponse(null, res, null);
    +
    +        Mockito.verify(res).sendError(403, "Invalid access token");
    +    }
    +
    +    @Test
    +    @Issue("SECURITY-284")
    +    public void testDoNotifyCommitWithUnauthenticatedPollingAllowed() throws Exception {
    +        GitStatus.NOTIFY_COMMIT_ACCESS_CONTROL = "disabled-for-polling";
    +        SCMTrigger trigger = setupProjectWithTrigger("a", "master", false);
    +
    +        this.gitStatus.doNotifyCommit(requestWithNoParameter, "a", "master", null, null);
    +
    +        Mockito.verify(trigger).run();
    +    }
    +
    +    @Test
    +    @Issue("SECURITY-284")
    +    public void testDoNotifyCommitWithAllowModeRandomValue() throws Exception {
    +        GitStatus.NOTIFY_COMMIT_ACCESS_CONTROL = "random";
    +        setupProjectWithTrigger("a", "master", false);
    +        StaplerResponse res = mock(StaplerResponse.class);
    +
    +        HttpResponse httpResponse = this.gitStatus.doNotifyCommit(requestWithNoParameter, "a", "master", null, null);
    +        httpResponse.generateResponse(null, res, null);
    +
    +        Mockito.verify(res).sendError(401, "An access token is required. Please refer to Git plugin documentation for details.");
    +    }
    +
    +    @Test
    +    @Issue("SECURITY-284")
    +    public void testDoNotifyCommitWithSha1AndAllowModePoll() throws Exception {
    +        GitStatus.NOTIFY_COMMIT_ACCESS_CONTROL = "disabled-for-polling";
    +        setupProjectWithTrigger("a", "master", false);
    +        StaplerResponse res = mock(StaplerResponse.class);
    +
    +        HttpResponse httpResponse = this.gitStatus.doNotifyCommit(requestWithNoParameter, "a", "master", sha1, null);
    +        httpResponse.generateResponse(null, res, null);
    +
    +        Mockito.verify(res).sendError(401, "An access token is required when using the sha1 parameter. Please refer to Git plugin documentation for details.");
    +    }
    +
    +    @Test
    +    @Issue("SECURITY-284")
    +    public void testDoNotifyCommitWithSha1AndAllowModePollWithInvalidToken() throws Exception {
    +        GitStatus.NOTIFY_COMMIT_ACCESS_CONTROL = "disabled-for-polling";
    +        setupProjectWithTrigger("a", "master", false);
    +        StaplerResponse res = mock(StaplerResponse.class);
    +
    +        HttpResponse httpResponse = this.gitStatus.doNotifyCommit(requestWithNoParameter, "a", "master", sha1, "invalid");
    +        httpResponse.generateResponse(null, res, null);
    +
    +        Mockito.verify(res).sendError(403, "Invalid access token");
    +    }
    +
    +    @Test
    +    @Issue("SECURITY-284")
    +    public void testDoNotifyCommitWithAllowModeSha1() throws Exception {
    +        GitStatus.NOTIFY_COMMIT_ACCESS_CONTROL = "disabled";
    +        // when sha1 is provided build is scheduled right away instead of repo polling, so we do not check for trigger
    +        FreeStyleProject project = setupNotifyProject();
    +
    +        this.gitStatus.doNotifyCommit(requestWithParameter, repoURL, branch, sha1, null);
    +
    +        jenkins.waitUntilNoActivity();
    +        FreeStyleBuild lastBuild = project.getLastBuild();
    +
    +        assertNotNull(lastBuild);
    +        assertEquals(lastBuild.getNumber(), 1);
    +    }
     }
    
  • src/test/java/hudson/plugins/git/security/ApiTokenPropertyConfigurationTest.java+122 0 added
    @@ -0,0 +1,122 @@
    +package hudson.plugins.git.security;
    +
    +import com.gargoylesoftware.htmlunit.HttpMethod;
    +import com.gargoylesoftware.htmlunit.WebRequest;
    +import com.gargoylesoftware.htmlunit.WebResponse;
    +import com.gargoylesoftware.htmlunit.util.NameValuePair;
    +import hudson.plugins.git.ApiTokenPropertyConfiguration;
    +import jenkins.model.Jenkins;
    +import net.sf.json.JSONObject;
    +import org.junit.Before;
    +import org.junit.Rule;
    +import org.junit.Test;
    +import org.jvnet.hudson.test.JenkinsRule;
    +import org.jvnet.hudson.test.MockAuthorizationStrategy;
    +
    +import java.util.Collection;
    +import java.util.Collections;
    +
    +import static org.hamcrest.MatcherAssert.assertThat;
    +import static org.hamcrest.Matchers.*;
    +import static org.junit.Assert.assertEquals;
    +import static org.junit.Assert.assertTrue;
    +
    +public class ApiTokenPropertyConfigurationTest {
    +
    +    @Rule
    +    public JenkinsRule j = new JenkinsRule();
    +
    +    @Before
    +    public void init() {
    +        j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
    +        MockAuthorizationStrategy authorizationStrategy = new MockAuthorizationStrategy();
    +        authorizationStrategy.grant(Jenkins.ADMINISTER).everywhere().to("alice");
    +        authorizationStrategy.grant(Jenkins.READ).everywhere().to("bob");
    +        j.jenkins.setAuthorizationStrategy(authorizationStrategy);
    +    }
    +
    +    @Test
    +    public void testAdminPermissionRequiredToGenerateNewApiTokens() throws Exception {
    +        try (JenkinsRule.WebClient wc = j.createWebClient()) {
    +            wc.login("bob");
    +            WebRequest req = new WebRequest(
    +                    wc.createCrumbedUrl(ApiTokenPropertyConfiguration.get().getDescriptorUrl() + "/generate"), HttpMethod.POST);
    +            req.setRequestBody("{\"apiTokenName\":\"test\"}");
    +            wc.setThrowExceptionOnFailingStatusCode(false);
    +
    +            WebResponse res = wc.getPage(req).getWebResponse();
    +
    +            assertEquals(403, res.getStatusCode());
    +            assertTrue(res.getContentAsString().contains("bob is missing the Overall/Administer permission"));
    +        }
    +    }
    +
    +    @Test
    +    public void adminPermissionsRequiredToRevokeApiTokens() throws Exception {
    +        try (JenkinsRule.WebClient wc = j.createWebClient()) {
    +            wc.login("bob");
    +            WebRequest req = new WebRequest(wc.createCrumbedUrl(ApiTokenPropertyConfiguration.get().getDescriptorUrl() + "/revoke"), HttpMethod.POST);
    +            wc.setThrowExceptionOnFailingStatusCode(false);
    +
    +            WebResponse res = wc.getPage(req).getWebResponse();
    +
    +            assertEquals(403, res.getStatusCode());
    +            assertTrue(res.getContentAsString().contains("bob is missing the Overall/Administer permission"));
    +        }
    +    }
    +
    +    @Test
    +    public void testBasicGenerationAndRevocation() throws Exception {
    +        try (JenkinsRule.WebClient wc = j.createWebClient()) {
    +            wc.login("alice");
    +            WebRequest generateReq = new WebRequest(
    +                    wc.createCrumbedUrl(ApiTokenPropertyConfiguration.get().getDescriptorUrl() + "/generate"), HttpMethod.POST);
    +            generateReq.setRequestParameters(Collections.singletonList(new NameValuePair("apiTokenName", "token")));
    +            String uuid = JSONObject.fromObject(wc.getPage(generateReq).getWebResponse().getContentAsString()).getJSONObject("data").getString("uuid");
    +
    +            generateReq.setRequestParameters(Collections.singletonList(new NameValuePair("apiTokenName", "nekot")));
    +            String uuid2 = JSONObject.fromObject(wc.getPage(generateReq).getWebResponse().getContentAsString()).getJSONObject("data").getString("uuid");
    +
    +            Collection<ApiTokenPropertyConfiguration.HashedApiToken> apiTokens = ApiTokenPropertyConfiguration.get().getApiTokens();
    +            assertThat(apiTokens, allOf(
    +                    iterableWithSize(2),
    +                    hasItem(
    +                            allOf(
    +                                    hasProperty("name", is("token")),
    +                                    hasProperty("uuid", is(uuid))
    +                            )
    +                    ),
    +                    hasItem(
    +                            allOf(
    +                                    hasProperty("name", is("nekot")),
    +                                    hasProperty("uuid", is(uuid2))
    +                            )
    +                    )
    +            ));
    +
    +            WebRequest revokeReq = new WebRequest(
    +                    wc.createCrumbedUrl(ApiTokenPropertyConfiguration.get().getDescriptorUrl() + "/revoke"), HttpMethod.POST);
    +            revokeReq.setRequestParameters(Collections.singletonList(new NameValuePair("apiTokenUuid", uuid)));
    +            wc.getPage(revokeReq);
    +
    +            apiTokens = ApiTokenPropertyConfiguration.get().getApiTokens();
    +            assertThat(apiTokens, allOf(
    +                    iterableWithSize(1),
    +                    hasItem(
    +                            allOf(
    +                                    hasProperty("name", is("nekot")),
    +                                    hasProperty("uuid", is(uuid2))
    +                            )
    +                    )
    +            ));
    +        }
    +    }
    +
    +    @Test
    +    public void isValidApiTokenReturnsTrueIfGivenApiTokenExists() {
    +        JSONObject json = ApiTokenPropertyConfiguration.get().generateApiToken("test");
    +
    +        assertTrue(ApiTokenPropertyConfiguration.get().isValidApiToken(json.getString("value")));
    +    }
    +
    +}
    
  • src/test/java/jenkins/plugins/git/GitSampleRepoRule.java+4 1 modified
    @@ -29,6 +29,7 @@
     import hudson.Launcher;
     import hudson.model.TaskListener;
     import hudson.plugins.git.GitSCM;
    +import hudson.plugins.git.ApiTokenPropertyConfiguration;
     import hudson.util.StreamTaskListener;
     import java.io.ByteArrayOutputStream;
     import java.io.File;
    @@ -88,7 +89,9 @@ public final boolean mkdirs(String rel) throws IOException {
     
         public void notifyCommit(JenkinsRule r) throws Exception {
             synchronousPolling(r);
    -        WebResponse webResponse = r.createWebClient().goTo("git/notifyCommit?url=" + bareUrl(), "text/plain").getWebResponse();
    +        String notifyCommitToken = ApiTokenPropertyConfiguration.get().generateApiToken("notifyCommit").getString("value");
    +        WebResponse webResponse = r.createWebClient()
    +                .goTo("git/notifyCommit?url=" + bareUrl() + "&token=" + notifyCommitToken, "text/plain").getWebResponse();
             LOGGER.log(Level.FINE, webResponse.getContentAsString());
             for (NameValuePair pair : webResponse.getResponseHeaders()) {
                 if (pair.getName().equals("Triggered")) {
    
  • src/test/java/jenkins/plugins/git/GitSCMSourceTest.java+3 1 modified
    @@ -11,6 +11,7 @@
     import hudson.FilePath;
     import hudson.plugins.git.GitStatus;
     import hudson.plugins.git.GitTool;
    +import hudson.plugins.git.ApiTokenPropertyConfiguration;
     import hudson.scm.SCMDescriptor;
     import hudson.tools.CommandInstaller;
     import hudson.tools.InstallSourceProperty;
    @@ -92,11 +93,12 @@ public void setup() {
         @Test
         @Deprecated
         public void testSourceOwnerTriggeredByDoNotifyCommit() throws Exception {
    +        String notifyCommitApiToken = ApiTokenPropertyConfiguration.get().generateApiToken("test").getString("value");
             GitSCMSource gitSCMSource = new GitSCMSource("id", REMOTE, "", "*", "", false);
             GitSCMSourceOwner scmSourceOwner = setupGitSCMSourceOwner(gitSCMSource);
             jenkins.getInstance().add(scmSourceOwner, "gitSourceOwner");
     
    -        gitStatus.doNotifyCommit(mock(HttpServletRequest.class), REMOTE, "master", "");
    +        gitStatus.doNotifyCommit(mock(HttpServletRequest.class), REMOTE, "master", "", notifyCommitApiToken);
     
             SCMHeadEvent event =
                     jenkins.getInstance().getExtensionList(SCMEventListener.class).get(SCMEventListenerImpl.class)
    

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

0

No linked articles in our index yet.