Skip to content

Commit c44b39e

Browse files
committed
[JENKINS-74983] Add support for authenticated Webhooks registered in Bitbucket
Verify in the webhooks processor when the signature is present is matches the configured Add configuration in the global settings to setup HMAC credentials
1 parent a13b7b3 commit c44b39e

7 files changed

+73
-23
lines changed

src/main/java/com/cloudbees/jenkins/plugins/bitbucket/BitbucketSCMNavigator.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ public String getServerUrl() {
221221

222222
@DataBoundSetter
223223
public void setServerUrl(@CheckForNull String serverUrl) {
224-
serverUrl = BitbucketEndpointConfiguration.normalizeServerUrl(serverUrl);
224+
serverUrl = BitbucketEndpointConfiguration.normalizeServerURL(serverUrl);
225225
if (serverUrl != null && !StringUtils.equals(this.serverUrl, serverUrl)) {
226226
this.serverUrl = serverUrl;
227227
resetId();

src/main/java/com/cloudbees/jenkins/plugins/bitbucket/BitbucketSCMSource.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ public String getServerUrl() {
287287

288288
@DataBoundSetter
289289
public void setServerUrl(@CheckForNull String serverUrl) {
290-
String url = BitbucketEndpointConfiguration.normalizeServerUrl(serverUrl);
290+
String url = BitbucketEndpointConfiguration.normalizeServerURL(serverUrl);
291291
if (url == null) {
292292
url = BitbucketEndpointConfiguration.get().getDefaultEndpoint().getServerUrl();
293293
}

src/main/java/com/cloudbees/jenkins/plugins/bitbucket/endpoints/AbstractBitbucketEndpoint.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ static String normalizeJenkinsRootUrl(String rootUrl) {
106106
// This routine is not really BitbucketEndpointConfiguration
107107
// specific, it just works on strings with some defaults:
108108
return Util.ensureEndsWith(
109-
BitbucketEndpointConfiguration.normalizeServerUrl(rootUrl),"/");
109+
BitbucketEndpointConfiguration.normalizeServerURL(rootUrl),"/");
110110
}
111111

112112
/**

src/main/java/com/cloudbees/jenkins/plugins/bitbucket/endpoints/BitbucketEndpointConfiguration.java

+14-14
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ public Permission getRequiredGlobalConfigPagePermission() {
9797
@Restricted(NoExternalUse.class) // only for plugin internal use.
9898
@NonNull
9999
public String readResolveServerUrl(@CheckForNull String bitbucketServerUrl) {
100-
String serverUrl = normalizeServerUrl(bitbucketServerUrl);
100+
String serverUrl = normalizeServerURL(bitbucketServerUrl);
101101
serverUrl = StringUtils.defaultIfBlank(serverUrl, BitbucketCloudEndpoint.SERVER_URL);
102102
AbstractBitbucketEndpoint endpoint = findEndpoint(serverUrl).orElse(null);
103103
if (endpoint == null && ACL.SYSTEM2.equals(Jenkins.getAuthentication2())) {
@@ -194,14 +194,14 @@ public synchronized void setEndpoints(@CheckForNull List<? extends AbstractBitbu
194194
* @return {@code true} if the list of endpoints was modified
195195
*/
196196
public synchronized boolean addEndpoint(@NonNull AbstractBitbucketEndpoint endpoint) {
197-
List<AbstractBitbucketEndpoint> endpoints = new ArrayList<>(getEndpoints());
198-
for (AbstractBitbucketEndpoint ep : endpoints) {
197+
List<AbstractBitbucketEndpoint> newEndpoints = new ArrayList<>(getEndpoints());
198+
for (AbstractBitbucketEndpoint ep : newEndpoints) {
199199
if (ep.getServerUrl().equals(endpoint.getServerUrl())) {
200200
return false;
201201
}
202202
}
203-
endpoints.add(endpoint);
204-
setEndpoints(endpoints);
203+
newEndpoints.add(endpoint);
204+
setEndpoints(newEndpoints);
205205
return true;
206206
}
207207

@@ -211,20 +211,20 @@ public synchronized boolean addEndpoint(@NonNull AbstractBitbucketEndpoint endpo
211211
* @param endpoint the endpoint to update.
212212
*/
213213
public synchronized void updateEndpoint(@NonNull AbstractBitbucketEndpoint endpoint) {
214-
List<AbstractBitbucketEndpoint> endpoints = new ArrayList<>(getEndpoints());
214+
List<AbstractBitbucketEndpoint> newEndpoints = new ArrayList<>(getEndpoints());
215215
boolean found = false;
216-
for (int i = 0; i < endpoints.size(); i++) {
217-
AbstractBitbucketEndpoint ep = endpoints.get(i);
216+
for (int i = 0; i < newEndpoints.size(); i++) {
217+
AbstractBitbucketEndpoint ep = newEndpoints.get(i);
218218
if (ep.getServerUrl().equals(endpoint.getServerUrl())) {
219-
endpoints.set(i, endpoint);
219+
newEndpoints.set(i, endpoint);
220220
found = true;
221221
break;
222222
}
223223
}
224224
if (!found) {
225-
endpoints.add(endpoint);
225+
newEndpoints.add(endpoint);
226226
}
227-
setEndpoints(endpoints);
227+
setEndpoints(newEndpoints);
228228
}
229229

230230
/**
@@ -244,7 +244,7 @@ public boolean removeEndpoint(@NonNull AbstractBitbucketEndpoint endpoint) {
244244
* @return {@code true} if the list of endpoints was modified
245245
*/
246246
public synchronized boolean removeEndpoint(@CheckForNull String serverURL) {
247-
String fixedServerURL = normalizeServerUrl(serverURL);
247+
String fixedServerURL = normalizeServerURL(serverURL);
248248
List<AbstractBitbucketEndpoint> newEndpoints = new ArrayList<>(getEndpoints());
249249
boolean modified = newEndpoints.removeIf(endpoint -> Objects.equals(fixedServerURL, endpoint.getServerUrl()));
250250
setEndpoints(newEndpoints);
@@ -258,7 +258,7 @@ public synchronized boolean removeEndpoint(@CheckForNull String serverURL) {
258258
* @return the global configuration for the specified server url or {@code null} if not defined.
259259
*/
260260
public synchronized Optional<AbstractBitbucketEndpoint> findEndpoint(@CheckForNull String serverURL) {
261-
serverURL = normalizeServerUrl(serverURL);
261+
serverURL = normalizeServerURL(serverURL);
262262
for (AbstractBitbucketEndpoint endpoint : getEndpoints()) {
263263
if (Objects.equals(serverURL, endpoint.getServerUrl())) {
264264
return Optional.of(endpoint);
@@ -293,7 +293,7 @@ public synchronized AbstractBitbucketEndpoint getDefaultEndpoint() {
293293
* @return the normalized server URL.
294294
*/
295295
@CheckForNull
296-
public static String normalizeServerUrl(@CheckForNull String serverURL) {
296+
public static String normalizeServerURL(@CheckForNull String serverURL) {
297297
if (StringUtils.isBlank(serverURL)) {
298298
return null;
299299
}

src/main/java/com/cloudbees/jenkins/plugins/bitbucket/endpoints/BitbucketServerEndpoint.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ public BitbucketServerEndpoint(@CheckForNull String displayName,
111111
@CheckForNull String credentialsId) {
112112
super(manageHooks, credentialsId);
113113
// use fixNull to silent nullability check
114-
this.serverUrl = Util.fixNull(BitbucketEndpointConfiguration.normalizeServerUrl(serverUrl));
114+
this.serverUrl = Util.fixNull(BitbucketEndpointConfiguration.normalizeServerURL(serverUrl));
115115
this.displayName = StringUtils.isBlank(displayName)
116116
? SCMName.fromUrl(this.serverUrl, COMMON_PREFIX_HOSTNAMES)
117117
: displayName.trim();

src/main/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/BitbucketSCMSourcePushHookReceiver.java

+53-3
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,16 @@
2323
*/
2424
package com.cloudbees.jenkins.plugins.bitbucket.hooks;
2525

26+
import com.cloudbees.jenkins.plugins.bitbucket.endpoints.AbstractBitbucketEndpoint;
27+
import com.cloudbees.jenkins.plugins.bitbucket.endpoints.BitbucketCloudEndpoint;
28+
import com.cloudbees.jenkins.plugins.bitbucket.endpoints.BitbucketEndpointConfiguration;
29+
import com.cloudbees.jenkins.plugins.bitbucket.impl.util.BitbucketCredentials;
30+
import com.cloudbees.plugins.credentials.common.StandardCredentials;
2631
import hudson.Extension;
2732
import hudson.model.UnprotectedRootAction;
2833
import hudson.security.csrf.CrumbExclusion;
2934
import hudson.util.HttpResponses;
35+
import hudson.util.Secret;
3036
import jakarta.servlet.FilterChain;
3137
import jakarta.servlet.ServletException;
3238
import jakarta.servlet.http.HttpServletRequest;
@@ -35,8 +41,13 @@
3541
import java.nio.charset.StandardCharsets;
3642
import java.util.logging.Level;
3743
import java.util.logging.Logger;
44+
import javax.crypto.Mac;
45+
import jenkins.model.Jenkins;
3846
import jenkins.scm.api.SCMEvent;
47+
import org.apache.commons.codec.digest.HmacUtils;
3948
import org.apache.commons.io.IOUtils;
49+
import org.apache.commons.lang.StringUtils;
50+
import org.jenkinsci.plugins.plaincredentials.StringCredentials;
4051
import org.kohsuke.stapler.HttpResponse;
4152
import org.kohsuke.stapler.StaplerRequest2;
4253

@@ -78,6 +89,7 @@ public String getUrlName() {
7889
public HttpResponse doNotify(StaplerRequest2 req) throws IOException {
7990
String origin = SCMEvent.originOf(req);
8091
String body = IOUtils.toString(req.getInputStream(), StandardCharsets.UTF_8);
92+
8193
String eventKey = req.getHeader("X-Event-Key");
8294
if (eventKey == null) {
8395
return HttpResponses.error(HttpServletResponse.SC_BAD_REQUEST, "X-Event-Key HTTP header not found");
@@ -89,20 +101,58 @@ public HttpResponse doNotify(StaplerRequest2 req) throws IOException {
89101
}
90102

91103
String bitbucketKey = req.getHeader("X-Bitbucket-Type");
92-
String serverUrl = req.getParameter("server_url");
104+
String serverURL = req.getParameter("server_url");
105+
93106
BitbucketType instanceType = null;
94107
if (bitbucketKey != null) {
95108
instanceType = BitbucketType.fromString(bitbucketKey);
96109
}
97-
if (instanceType == null && serverUrl != null) {
110+
if (instanceType == null && serverURL != null) {
98111
LOGGER.log(Level.FINE, "server_url request parameter found. Bitbucket Native Server webhook incoming.");
99112
instanceType = BitbucketType.SERVER;
100113
} else {
101114
LOGGER.log(Level.FINE, "X-Bitbucket-Type header / server_url request parameter not found. Bitbucket Cloud webhook incoming.");
115+
instanceType = BitbucketType.CLOUD;
116+
serverURL = BitbucketCloudEndpoint.SERVER_URL;
117+
}
118+
119+
String signatureHeader = req.getParameter("X-Hub-Signature");
120+
if (signatureHeader != null) {
121+
// TODO lookup the serverURL hostname from the http request instead trust in the payload, consider also proxy headers
122+
AbstractBitbucketEndpoint endpoint = BitbucketEndpointConfiguration.get()
123+
.findEndpoint(serverURL)
124+
.orElse(null);
125+
if (endpoint == null) {
126+
LOGGER.log(Level.INFO, "No bitbucket endpoint found for {} to verify the signature of incoming webhook.", serverURL);
127+
} else if (endpoint.isManageHooks()) {
128+
String hmacCredentialsId = endpoint.getHMACCredentialsId();
129+
130+
String algorithm = StringUtils.substringBefore(signatureHeader, "=");
131+
String signature = StringUtils.substringAfter(signatureHeader, "=");
132+
String key;
133+
StringCredentials hmacCredentials = BitbucketCredentials.lookupCredentials(serverURL, Jenkins.get(), hmacCredentialsId, StringCredentials.class);
134+
if (hmacCredentials != null) {
135+
key = Secret.toString(hmacCredentials.getSecret());
136+
HmacUtils util;
137+
try {
138+
util = new HmacUtils(algorithm, key.getBytes());
139+
} catch (IllegalArgumentException e) {
140+
return HttpResponses.error(HttpServletResponse.SC_BAD_REQUEST, "Signature method not supported: " + signature);
141+
}
142+
String mac = util.hmacHex(body);
143+
if (!mac.equals(signature)) {
144+
return HttpResponses.error(HttpServletResponse.SC_FORBIDDEN, "Signature does not match");
145+
}
146+
} else {
147+
LOGGER.log(Level.WARNING, "No credentials {} found to verify the signature of incoming webhook.", hmacCredentialsId);
148+
}
149+
} else {
150+
LOGGER.log(Level.FINE, "Signature of incoming webhook not configurted for endpoint {}.", serverURL);
151+
}
102152
}
103153

104154
HookProcessor hookProcessor = getHookProcessor(type);
105-
hookProcessor.process(type, body, instanceType, origin, serverUrl);
155+
hookProcessor.process(type, body, instanceType, origin, serverURL);
106156
return HttpResponses.ok();
107157
}
108158

src/main/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/HookProcessor.java

+2-2
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,9 @@ public abstract class HookProcessor {
6767
* @param payload the hook payload
6868
* @param instanceType the Bitbucket type that called the hook
6969
* @param origin the origin of the event.
70-
* @param serverUrl special value for native Bitbucket Server hooks which don't expose the server URL in the payload.
70+
* @param serverURL special value for native Bitbucket Server hooks which don't expose the server URL in the payload.
7171
*/
72-
public void process(HookEventType type, String payload, BitbucketType instanceType, String origin, String serverUrl) {
72+
public void process(HookEventType type, String payload, BitbucketType instanceType, String origin, String serverURL) {
7373
process(type, payload, instanceType, origin);
7474
}
7575

0 commit comments

Comments
 (0)