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

CVE-2022-36884

CVE-2022-36884

Description

The webhook endpoint in Jenkins Git Plugin 4.11.3 and earlier provide unauthenticated attackers information about the existence of jobs configured to use an attacker-specified Git repository.

AI Insight

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

Jenkins Git Plugin webhook discloses existence of jobs using a given Git repository to unauthenticated attackers.

The Git Plugin for Jenkins provides a webhook endpoint at /git/notifyCommit that can be used to notify Jenkins of changes to a repository. In versions 4.11.3 and earlier, this endpoint does not require authentication and discloses whether any job is configured to use a given Git repository URL [1][2]. This allows an unauthenticated attacker to probe for jobs that use a specific repository.

An attacker can send a GET request to the webhook endpoint with a repository URL parameter. The endpoint responds with a message indicating whether jobs are found for that URL, thus leaking existence of jobs [1][2]. No authentication or prior knowledge is needed beyond the Jenkins instance URL and a repository URL of interest.

The information disclosure could be used to map Jenkins jobs to their Git repositories, potentially exposing project structure or enabling targeted attacks. While the attacker does not gain code execution or credential access, the leaked information aids reconnaissance [4].

The issue is fixed in Git Plugin version 4.11.4, which added an authentication check and requires POST requests for the webhook endpoint [1][3]. Users should upgrade to the latest version. No workaround is available other than updating.

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

6

News mentions

0

No linked articles in our index yet.