Skip to content

Commit

Permalink
[SECURITY-284][SECURITY-907]
Browse files Browse the repository at this point in the history
  • Loading branch information
yaroslavafenkin committed Jul 19, 2022
1 parent 18577d1 commit b46165c
Show file tree
Hide file tree
Showing 13 changed files with 659 additions and 35 deletions.
19 changes: 15 additions & 4 deletions README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.

Expand Down
183 changes: 183 additions & 0 deletions src/main/java/hudson/plugins/git/ApiTokenPropertyConfiguration.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
29 changes: 24 additions & 5 deletions src/main/java/hudson/plugins/git/GitStatus.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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";
Expand Down Expand Up @@ -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()){
Expand Down Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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;
}
Loading

0 comments on commit b46165c

Please sign in to comment.