Skip to content

Commit b46165c

Browse files
[SECURITY-284][SECURITY-907]
1 parent 18577d1 commit b46165c

File tree

13 files changed

+659
-35
lines changed

13 files changed

+659
-35
lines changed

README.adoc

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -175,10 +175,10 @@ Refer to webhook documentation for your repository:
175175
* link:https://github.com/jenkinsci/gitea-plugin/blob/master/docs/README.md[Gitea]
176176

177177
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.
178-
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.
178+
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.
179179

180180
....
181-
curl http://yourserver/git/notifyCommit?url=<URL of the Git repository>
181+
curl http://yourserver/git/notifyCommit?url=<URL of the Git repository>&token=<Access token>
182182
....
183183

184184
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.
191191

192192
This allows a notify script to remain the same for all Jenkins jobs.
193193
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.
194-
Finally, this URL doesn't require authentication even for secured Jenkins, because the server doesn't directly use anything that the client is sending.
195-
It polls to verify that there is a change before it actually starts a build.
194+
195+
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]:
196+
197+
....
198+
hudson.plugins.git.GitStatus.NOTIFY_COMMIT_ACCESS_CONTROL
199+
....
200+
201+
It has two modes:
202+
203+
* `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
204+
`sha1` query parameter.
205+
* `disabled` - Fully disables the access token mechanism and allows all requests to `notifyCommit`
206+
to be unauthenticated. *This option is insecure and is not recommended.*
196207

197208
When notifyCommit is successful, the list of triggered projects is returned.
198209

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package hudson.plugins.git;
2+
3+
import edu.umd.cs.findbugs.annotations.NonNull;
4+
import hudson.Extension;
5+
import hudson.Util;
6+
import hudson.model.PersistentDescriptor;
7+
import hudson.util.HttpResponses;
8+
import jenkins.model.GlobalConfiguration;
9+
import jenkins.model.GlobalConfigurationCategory;
10+
import jenkins.model.Jenkins;
11+
import net.jcip.annotations.GuardedBy;
12+
import net.sf.json.JSONObject;
13+
import org.apache.commons.lang.StringUtils;
14+
import org.jenkinsci.Symbol;
15+
import org.kohsuke.accmod.Restricted;
16+
import org.kohsuke.accmod.restrictions.NoExternalUse;
17+
import org.kohsuke.stapler.HttpResponse;
18+
import org.kohsuke.stapler.StaplerRequest;
19+
import org.kohsuke.stapler.interceptor.RequirePOST;
20+
21+
import java.io.Serializable;
22+
import java.nio.charset.StandardCharsets;
23+
import java.security.MessageDigest;
24+
import java.security.NoSuchAlgorithmException;
25+
import java.security.SecureRandom;
26+
import java.util.ArrayList;
27+
import java.util.Collection;
28+
import java.util.Collections;
29+
import java.util.List;
30+
import java.util.UUID;
31+
import java.util.logging.Level;
32+
import java.util.logging.Logger;
33+
34+
35+
@Extension
36+
@Restricted(NoExternalUse.class)
37+
@Symbol("apiTokenProperty")
38+
public class ApiTokenPropertyConfiguration extends GlobalConfiguration implements PersistentDescriptor {
39+
40+
private static final Logger LOGGER = Logger.getLogger(ApiTokenPropertyConfiguration.class.getName());
41+
private static final SecureRandom RANDOM = new SecureRandom();
42+
private static final String HASH_ALGORITHM = "SHA-256";
43+
44+
@GuardedBy("this")
45+
private final List<HashedApiToken> apiTokens;
46+
47+
public ApiTokenPropertyConfiguration() {
48+
this.apiTokens = new ArrayList<>();
49+
}
50+
51+
public static ApiTokenPropertyConfiguration get() {
52+
return GlobalConfiguration.all().get(ApiTokenPropertyConfiguration.class);
53+
}
54+
55+
@NonNull
56+
@Override
57+
public GlobalConfigurationCategory getCategory() {
58+
return GlobalConfigurationCategory.get(GlobalConfigurationCategory.Security.class);
59+
}
60+
61+
@RequirePOST
62+
public HttpResponse doGenerate(StaplerRequest req) {
63+
Jenkins.get().checkPermission(Jenkins.ADMINISTER);
64+
65+
String apiTokenName = req.getParameter("apiTokenName");
66+
JSONObject json = this.generateApiToken(apiTokenName);
67+
save();
68+
69+
return HttpResponses.okJSON(json);
70+
}
71+
72+
public JSONObject generateApiToken(@NonNull String name) {
73+
byte[] random = new byte[16];
74+
RANDOM.nextBytes(random);
75+
76+
String plainTextApiToken = Util.toHexString(random);
77+
assert plainTextApiToken.length() == 32;
78+
79+
String apiTokenValueHashed = Util.toHexString(hashedBytes(plainTextApiToken.getBytes(StandardCharsets.US_ASCII)));
80+
HashedApiToken apiToken = new HashedApiToken(name, apiTokenValueHashed);
81+
82+
synchronized (this) {
83+
this.apiTokens.add(apiToken);
84+
}
85+
86+
JSONObject json = new JSONObject();
87+
json.put("uuid", apiToken.getUuid());
88+
json.put("name", apiToken.getName());
89+
json.put("value", plainTextApiToken);
90+
91+
return json;
92+
}
93+
94+
@NonNull
95+
private static byte[] hashedBytes(byte[] tokenBytes) {
96+
MessageDigest digest;
97+
try {
98+
digest = MessageDigest.getInstance(HASH_ALGORITHM);
99+
} catch (NoSuchAlgorithmException e) {
100+
throw new AssertionError("There is no " + HASH_ALGORITHM + " available in this system", e);
101+
}
102+
return digest.digest(tokenBytes);
103+
}
104+
105+
@RequirePOST
106+
public HttpResponse doRevoke(StaplerRequest req) {
107+
Jenkins.get().checkPermission(Jenkins.ADMINISTER);
108+
109+
String apiTokenUuid = req.getParameter("apiTokenUuid");
110+
if (StringUtils.isBlank(apiTokenUuid)) {
111+
return HttpResponses.errorWithoutStack(400, "API token UUID cannot be empty");
112+
}
113+
114+
synchronized (this) {
115+
this.apiTokens.removeIf(apiToken -> apiToken.getUuid().equals(apiTokenUuid));
116+
}
117+
save();
118+
119+
return HttpResponses.ok();
120+
}
121+
122+
public synchronized Collection<HashedApiToken> getApiTokens() {
123+
return Collections.unmodifiableList(new ArrayList<>(this.apiTokens));
124+
}
125+
126+
public boolean isValidApiToken(String plainApiToken) {
127+
if (StringUtils.isBlank(plainApiToken)) {
128+
return false;
129+
}
130+
131+
return this.hasMatchingApiToken(plainApiToken);
132+
}
133+
134+
public synchronized boolean hasMatchingApiToken(@NonNull String plainApiToken) {
135+
byte[] hash = hashedBytes(plainApiToken.getBytes(StandardCharsets.US_ASCII));
136+
return this.apiTokens.stream().anyMatch(apiToken -> apiToken.match(hash));
137+
}
138+
139+
public static class HashedApiToken implements Serializable {
140+
141+
private static final long serialVersionUID = 1L;
142+
143+
private final String uuid;
144+
private final String name;
145+
private final String hash;
146+
147+
private HashedApiToken(String name, String hash) {
148+
this.uuid = UUID.randomUUID().toString();
149+
this.name = name;
150+
this.hash = hash;
151+
}
152+
153+
private HashedApiToken(String uuid, String name, String hash) {
154+
this.uuid = uuid;
155+
this.name = name;
156+
this.hash = hash;
157+
}
158+
159+
public String getUuid() {
160+
return uuid;
161+
}
162+
163+
public String getName() {
164+
return name;
165+
}
166+
167+
public String getHash() {
168+
return hash;
169+
}
170+
171+
private boolean match(byte[] hashedBytes) {
172+
byte[] hashFromHex;
173+
try {
174+
hashFromHex = Util.fromHexString(hash);
175+
} catch (NumberFormatException e) {
176+
LOGGER.log(Level.INFO, "The API token with name=[{0}] is not in hex-format and so cannot be used", name);
177+
return false;
178+
}
179+
180+
return MessageDigest.isEqual(hashFromHex, hashedBytes);
181+
}
182+
}
183+
}

src/main/java/hudson/plugins/git/GitStatus.java

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,20 @@
1212
import hudson.security.ACL;
1313
import hudson.security.ACLContext;
1414
import hudson.triggers.SCMTrigger;
15-
import java.io.IOException;
1615
import java.io.PrintWriter;
1716
import java.net.URISyntaxException;
1817
import java.util.*;
1918
import java.util.logging.Level;
2019
import java.util.logging.Logger;
2120
import java.util.regex.Pattern;
22-
import javax.servlet.ServletException;
2321
import javax.servlet.http.HttpServletRequest;
2422

2523
import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
2624
import static javax.servlet.http.HttpServletResponse.SC_OK;
2725
import jenkins.model.Jenkins;
2826
import jenkins.scm.api.SCMEvent;
2927
import jenkins.triggers.SCMTriggerItem;
28+
import jenkins.util.SystemProperties;
3029
import org.apache.commons.lang.StringUtils;
3130
import static org.apache.commons.lang.StringUtils.isNotEmpty;
3231

@@ -39,6 +38,9 @@
3938
*/
4039
@Extension
4140
public class GitStatus implements UnprotectedRootAction {
41+
static /* not final */ String NOTIFY_COMMIT_ACCESS_CONTROL =
42+
SystemProperties.getString(GitStatus.class.getName() + ".NOTIFY_COMMIT_ACCESS_CONTROL");
43+
4244
@Override
4345
public String getDisplayName() {
4446
return "Git";
@@ -113,8 +115,25 @@ public String toString() {
113115
}
114116

115117
public HttpResponse doNotifyCommit(HttpServletRequest request, @QueryParameter(required=true) String url,
116-
@QueryParameter(required=false) String branches,
117-
@QueryParameter(required=false) String sha1) throws ServletException, IOException {
118+
@QueryParameter() String branches, @QueryParameter() String sha1,
119+
@QueryParameter() String token) {
120+
if (!"disabled".equalsIgnoreCase(NOTIFY_COMMIT_ACCESS_CONTROL)
121+
&& !"disabled-for-polling".equalsIgnoreCase(NOTIFY_COMMIT_ACCESS_CONTROL)) {
122+
if (StringUtils.isEmpty(token)) {
123+
return HttpResponses.errorWithoutStack(401, "An access token is required. Please refer to Git plugin documentation for details.");
124+
}
125+
if (!ApiTokenPropertyConfiguration.get().isValidApiToken(token)) {
126+
return HttpResponses.errorWithoutStack(403, "Invalid access token");
127+
}
128+
}
129+
if ("disabled-for-polling".equalsIgnoreCase(NOTIFY_COMMIT_ACCESS_CONTROL) && StringUtils.isNotEmpty(sha1)) {
130+
if (StringUtils.isEmpty(token)) {
131+
return HttpResponses.errorWithoutStack(401, "An access token is required when using the sha1 parameter. Please refer to Git plugin documentation for details.");
132+
}
133+
if (!ApiTokenPropertyConfiguration.get().isValidApiToken(token)) {
134+
return HttpResponses.errorWithoutStack(403, "Invalid access token");
135+
}
136+
}
118137
lastURL = url;
119138
lastBranches = branches;
120139
if(StringUtils.isNotBlank(sha1)&&!SHA1_PATTERN.matcher(sha1.trim()).matches()){
@@ -197,7 +216,7 @@ private static String normalizePath(String path) {
197216
}
198217

199218
/**
200-
* Contributes to a {@link #doNotifyCommit(HttpServletRequest, String, String, String)} response.
219+
* Contributes to a {@link #doNotifyCommit(HttpServletRequest, String, String, String, String)} response.
201220
*
202221
* @since 1.4.1
203222
*/
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?jelly escape-by-default='true'?>
2+
3+
<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:f="/lib/form" xmlns:l="/lib/layout">
4+
<f:section title="${%Git plugin notifyCommit access tokens}">
5+
<st:adjunct includes="hudson.plugins.git.ApiTokenPropertyConfiguration.resources" />
6+
<f:entry title="${%Current access tokens}" help="${descriptor.getHelpFile('tokens')}">
7+
<div class="api-token-list">
8+
<j:set var="apiTokens" value="${instance.apiTokens}" />
9+
<div class="api-token-list-empty-item ${apiTokens == null || apiTokens.isEmpty() ? '' : 'hidden'}">
10+
<div class="list-empty-message">${%There are no access tokens yet.}</div>
11+
</div>
12+
<f:repeatable var="apiToken" items="${apiTokens}" minimum="0" add="${%Add new access token}">
13+
<j:choose>
14+
<j:when test="${apiToken != null}">
15+
<input type="hidden" class="api-token-uuid-input" name="apiTokenUuid" value="${apiToken.uuid}" />
16+
<div class="api-token-list-item-row api-token-list-existing-token">
17+
<f:textbox readonly="true" value="${apiToken.name}" />
18+
<a href="#" onclick="return revokeApiToken(this)" class="yui-button api-token-revoke-button"
19+
data-confirm="${%Are you sure you want to revoke this access token?}"
20+
data-target-url="${descriptor.descriptorFullUrl}/revoke">
21+
${%Revoke}
22+
</a>
23+
</div>
24+
</j:when>
25+
<j:otherwise>
26+
<div class="api-token-list-item">
27+
<div class="api-token-list-item-row">
28+
<input type="hidden" class="api-token-uuid-input" name="apiTokenUuid" value="${apiToken.uuid}" />
29+
<f:textbox clazz="api-token-name-input" name="apiTokenName" placeholder="${%Access token name}"/>
30+
<span class="new-api-token-value hidden"><!-- to be filled by JS --></span>
31+
<span class="yui-button api-token-save-button">
32+
<button type="button" tabindex="0" data-target-url="${descriptor.descriptorFullUrl}/generate" onclick="saveApiToken(this)">
33+
${%Generate}
34+
</button>
35+
</span>
36+
<span class="api-token-cancel-button">
37+
<f:repeatableDeleteButton value="${%Cancel}" />
38+
</span>
39+
<l:copyButton message="${%Copied}" text="" clazz="hidden" tooltip="${%Copy to clipboard}" />
40+
<a href="#" onclick="return revokeApiToken(this)" class="yui-button api-token-revoke-button hidden"
41+
data-confirm="${%Are you sure you want to revoke this access token?}"
42+
data-target-url="${descriptor.descriptorFullUrl}/revoke">
43+
${%Revoke}
44+
</a>
45+
</div>
46+
<span class="warning api-token-warning-message hidden">${%Access token will only be displayed once.}</span>
47+
</div>
48+
</j:otherwise>
49+
</j:choose>
50+
</f:repeatable>
51+
</div>
52+
</f:entry>
53+
</f:section>
54+
</j:jelly>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<div>
2+
<p>These access tokens serve as a way of authenticating requests to the <code>notifyCommit</code> endpoint.
3+
<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
4+
that requirement with the <a href="https://www.jenkins.io/doc/book/managing/system-properties/">system property</a>:
5+
<pre><code>hudson.plugins.git.GitStatus.NOTIFY_COMMIT_ACCESS_CONTROL</code></pre>
6+
<br/>
7+
It has two modes:
8+
<ul>
9+
<li><code>disabled-for-polling</code> - Allows unauthenticated requests as long as they only request polling of the repository supplied in the
10+
<code>url</code> query parameter. Prohibits unauthenticated requests that attempt to schedule a build immediately by providing a
11+
<code>sha1</code> query parameter.</li>
12+
<li><code>disabled</code> - Fully disables the access token mechanism and allows all requests to <code>notifyCommit</code>
13+
to be unauthenticated. <b>This option is insecure and is not recommended.</b></li>
14+
</ul>
15+
</div>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
.api-token-list .api-token-list-item-row {
2+
display: flex;
3+
align-items: center;
4+
max-width: 700px;
5+
}
6+
.api-token-list .api-token-list-item-row.api-token-list-existing-api-token {
7+
justify-content: space-between;
8+
}
9+
.api-token-list .api-token-list-item .hidden, .api-token-list .api-token-list-empty-item.hidden {
10+
display: none;
11+
}
12+
13+
.api-token-list .api-token-revoke-button, .api-token-list .new-api-token-value {
14+
padding: 0 0.5rem;
15+
}
16+
.api-token-list .api-token-warning-message, .api-token-list .api-token-save-button {
17+
margin: 0.5rem 0;
18+
}

0 commit comments

Comments
 (0)