Add possibility to configure secret tokens per job to allow only web hooks with the correct token to trigger builds

This commit is contained in:
Robin Müller 2016-09-20 23:24:50 +02:00
parent f4485f2017
commit 59ee882b8d
11 changed files with 135 additions and 50 deletions

View File

@ -36,6 +36,7 @@ import hudson.triggers.TriggerDescriptor;
import hudson.util.FormValidation;
import hudson.util.ListBoxModel;
import hudson.util.ListBoxModel.Option;
import hudson.util.Secret;
import hudson.util.SequentialExecutionQueue;
import jenkins.model.Jenkins;
import jenkins.model.ParameterizedJobMixIn;
@ -49,9 +50,11 @@ import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.QueryParameter;
import org.kohsuke.stapler.Stapler;
import org.kohsuke.stapler.StaplerRequest;
import org.kohsuke.stapler.StaplerResponse;
import java.io.IOException;
import java.io.ObjectStreamException;
import java.security.SecureRandom;
import static com.dabsquared.gitlabjenkins.trigger.filter.BranchFilterConfig.BranchFilterConfigBuilder.branchFilterConfig;
import static com.dabsquared.gitlabjenkins.trigger.handler.merge.MergeRequestHookTriggerHandlerFactory.newMergeRequestHookTriggerHandler;
@ -65,7 +68,10 @@ import static com.dabsquared.gitlabjenkins.trigger.handler.push.PushHookTriggerH
* @author Daniel Brooks
*/
public class GitLabPushTrigger extends Trigger<Job<?, ?>> {
private boolean triggerOnPush = true;
private static final SecureRandom RANDOM = new SecureRandom();
private boolean triggerOnPush = true;
private boolean triggerOnMergeRequest = true;
private final TriggerOpenMergeRequest triggerOpenMergeRequestOnPush;
private boolean triggerOnNoteRequest = true;
@ -83,6 +89,7 @@ public class GitLabPushTrigger extends Trigger<Job<?, ?>> {
private String excludeBranchesSpec;
private String targetBranchRegex;
private final MergeRequestLabelFilterConfig mergeRequestLabelFilterConfig;
private volatile Secret secretToken;
private transient BranchFilter branchFilter;
private transient PushHookTriggerHandler pushHookTriggerHandler;
@ -99,7 +106,7 @@ public class GitLabPushTrigger extends Trigger<Job<?, ?>> {
boolean setBuildDescription, boolean addNoteOnMergeRequest, boolean addCiMessage, boolean addVoteOnMergeRequest,
boolean acceptMergeRequestOnSuccess, BranchFilterType branchFilterType,
String includeBranchesSpec, String excludeBranchesSpec, String targetBranchRegex,
MergeRequestLabelFilterConfig mergeRequestLabelFilterConfig) {
MergeRequestLabelFilterConfig mergeRequestLabelFilterConfig, String secretToken) {
this.triggerOnPush = triggerOnPush;
this.triggerOnMergeRequest = triggerOnMergeRequest;
this.triggerOnNoteRequest = triggerOnNoteRequest;
@ -117,6 +124,7 @@ public class GitLabPushTrigger extends Trigger<Job<?, ?>> {
this.targetBranchRegex = targetBranchRegex;
this.acceptMergeRequestOnSuccess = acceptMergeRequestOnSuccess;
this.mergeRequestLabelFilterConfig = mergeRequestLabelFilterConfig;
this.secretToken = Secret.fromString(secretToken);
initializeTriggerHandler();
initializeBranchFilter();
@ -223,6 +231,15 @@ public class GitLabPushTrigger extends Trigger<Job<?, ?>> {
return mergeRequestLabelFilterConfig;
}
public String getSecretToken() {
return secretToken == null ? null : secretToken.getPlainText();
}
public boolean isWebHookAuthorized(String secretToken) {
String plainText = this.secretToken.getPlainText();
return StringUtils.isEmpty(plainText) || StringUtils.equals(plainText, secretToken);
}
// executes when the Trigger receives a push request
public void onPost(final PushHook hook) {
pushHookTriggerHandler.handle(job, hook, ciSkip, branchFilter, mergeRequestLabelFilter);
@ -238,6 +255,12 @@ public class GitLabPushTrigger extends Trigger<Job<?, ?>> {
noteHookTriggerHandler.handle(job, hook, ciSkip, branchFilter, mergeRequestLabelFilter);
}
private void generateSecretToken() {
byte[] random = new byte[16]; // 16x8=128bit worth of randomness, since we use md5 digest as the API token
RANDOM.nextBytes(random);
secretToken = Secret.fromString(Util.toHexString(random));
}
private void initializeTriggerHandler() {
mergeRequestHookTriggerHandler = newMergeRequestHookTriggerHandler(triggerOnMergeRequest, triggerOpenMergeRequestOnPush, skipWorkInProgressMergeRequest);
noteHookTriggerHandler = newNoteHookTriggerHandler(triggerOnNoteRequest, noteRegex);
@ -383,5 +406,11 @@ public class GitLabPushTrigger extends Trigger<Job<?, ?>> {
public FormValidation doCheckExcludeMergeRequestLabels(@AncestorInPath final Job<?, ?> project, @QueryParameter final String value) {
return ProjectLabelsProvider.instance().doCheckLabels(project, value);
}
public void doGenerateSecretToken(@AncestorInPath final Job<?, ?> project, StaplerResponse response) {
GitLabPushTrigger trigger = getFromJob(project);
trigger.generateSecretToken();
response.setHeader("script", "document.getElementById('secretToken').value='" + trigger.getSecretToken() + "'");
}
}
}

View File

@ -1,6 +1,5 @@
package com.dabsquared.gitlabjenkins.webhook;
import com.dabsquared.gitlabjenkins.connection.GitLabConnectionConfig;
import com.dabsquared.gitlabjenkins.util.ACLUtil;
import com.dabsquared.gitlabjenkins.webhook.build.MergeRequestBuildAction;
import com.dabsquared.gitlabjenkins.webhook.build.NoteBuildAction;
@ -16,8 +15,6 @@ import hudson.model.Item;
import hudson.model.ItemGroup;
import hudson.model.Job;
import hudson.security.ACL;
import hudson.security.AccessDeniedException2;
import hudson.security.Permission;
import hudson.util.HttpResponses;
import jenkins.model.Jenkins;
import jenkins.scm.api.SCMSourceOwner;
@ -57,7 +54,6 @@ public class ActionResolver {
private WebHookAction resolveAction(Item project, String restOfPath, StaplerRequest request) {
String method = request.getMethod();
if (method.equals("POST")) {
checkPermission(Item.BUILD);
return onPost(project, request);
} else if (method.equals("GET")) {
if (project instanceof Job<?, ?>) {
@ -106,14 +102,15 @@ public class ActionResolver {
LOGGER.log(Level.FINE, "Missing X-Gitlab-Event header");
return new NoopAction();
}
String tokenHeader = request.getHeader("X-Gitlab-Token");
switch (eventHeader) {
case "Merge Request Hook":
return new MergeRequestBuildAction(project, getRequestBody(request));
return new MergeRequestBuildAction(project, getRequestBody(request), tokenHeader);
case "Push Hook":
case "Tag Push Hook":
return new PushBuildAction(project, getRequestBody(request));
return new PushBuildAction(project, getRequestBody(request), tokenHeader);
case "Note Hook":
return new NoteBuildAction(project, getRequestBody(request));
return new NoteBuildAction(project, getRequestBody(request), tokenHeader);
default:
LOGGER.log(Level.FINE, "Unsupported X-Gitlab-Event header: {0}", eventHeader);
return new NoopAction();
@ -150,16 +147,6 @@ public class ActionResolver {
});
}
private void checkPermission(Permission permission) {
if (((GitLabConnectionConfig) Jenkins.getInstance().getDescriptor(GitLabConnectionConfig.class)).isUseAuthenticatedEndpoint()) {
try {
Jenkins.getInstance().checkPermission(permission);
} catch (AccessDeniedException2 e) {
throw HttpResponses.errorWithoutStack(403, e.getMessage());
}
}
}
static class NoopAction implements WebHookAction {
public void execute(StaplerResponse response) {
}

View File

@ -1,6 +1,15 @@
package com.dabsquared.gitlabjenkins.webhook.build;
import com.dabsquared.gitlabjenkins.GitLabPushTrigger;
import com.dabsquared.gitlabjenkins.connection.GitLabConnectionConfig;
import com.dabsquared.gitlabjenkins.webhook.WebHookAction;
import hudson.model.Item;
import hudson.model.Job;
import hudson.security.AccessDeniedException2;
import hudson.security.Permission;
import hudson.util.HttpResponses;
import jenkins.model.Jenkins;
import org.apache.commons.lang.StringUtils;
import org.kohsuke.stapler.StaplerResponse;
/**
@ -8,10 +17,46 @@ import org.kohsuke.stapler.StaplerResponse;
*/
abstract class BuildWebHookAction implements WebHookAction {
abstract void processForCompatibility();
abstract void execute();
public final void execute(StaplerResponse response) {
processForCompatibility();
execute();
}
protected abstract static class TriggerNotifier implements Runnable {
private final Item project;
private final String secretToken;
public TriggerNotifier(Item project, String secretToken) {
this.project = project;
this.secretToken = secretToken;
}
public void run() {
GitLabPushTrigger trigger = GitLabPushTrigger.getFromJob((Job<?, ?>) project);
if (trigger != null) {
if (StringUtils.isEmpty(trigger.getSecretToken())) {
checkPermission(Item.BUILD);
} else if (!StringUtils.equals(trigger.getSecretToken(), secretToken)) {
throw HttpResponses.errorWithoutStack(401, "Invalid token");
}
performOnPost(trigger);
}
}
private void checkPermission(Permission permission) {
if (((GitLabConnectionConfig) Jenkins.getInstance().getDescriptor(GitLabConnectionConfig.class)).isUseAuthenticatedEndpoint()) {
try {
Jenkins.getInstance().checkPermission(permission);
} catch (AccessDeniedException2 e) {
throw HttpResponses.errorWithoutStack(403, e.getMessage());
}
}
}
protected abstract void performOnPost(GitLabPushTrigger trigger);
}
}

View File

@ -23,11 +23,13 @@ public class MergeRequestBuildAction extends BuildWebHookAction {
private final static Logger LOGGER = Logger.getLogger(MergeRequestBuildAction.class.getName());
private Item project;
private MergeRequestHook mergeRequestHook;
private final String secretToken;
public MergeRequestBuildAction(Item project, String json) {
public MergeRequestBuildAction(Item project, String json, String secretToken) {
LOGGER.log(Level.FINE, "MergeRequest: {0}", toPrettyPrint(json));
this.project = project;
this.mergeRequestHook = JsonUtil.read(json, MergeRequestHook.class);
this.secretToken = secretToken;
}
void processForCompatibility() {
@ -50,12 +52,10 @@ public class MergeRequestBuildAction extends BuildWebHookAction {
if (!(project instanceof Job<?, ?>)) {
throw HttpResponses.errorWithoutStack(409, "Merge Request Hook is not supported for this project");
}
ACL.impersonate(ACL.SYSTEM, new Runnable() {
public void run() {
GitLabPushTrigger trigger = GitLabPushTrigger.getFromJob((Job<?, ?>) project);
if (trigger != null) {
trigger.onPost(mergeRequestHook);
}
ACL.impersonate(ACL.SYSTEM, new TriggerNotifier(project, secretToken) {
@Override
protected void performOnPost(GitLabPushTrigger trigger) {
trigger.onPost(mergeRequestHook);
}
});
throw HttpResponses.ok();

View File

@ -23,23 +23,23 @@ public class NoteBuildAction implements WebHookAction {
private final static Logger LOGGER = Logger.getLogger(NoteBuildAction.class.getName());
private Item project;
private NoteHook noteHook;
private final String secretToken;
public NoteBuildAction(Item project, String json) {
public NoteBuildAction(Item project, String json, String secretToken) {
LOGGER.log(Level.FINE, "Note: {0}", toPrettyPrint(json));
this.project = project;
this.noteHook = JsonUtil.read(json, NoteHook.class);
this.secretToken = secretToken;
}
public void execute(StaplerResponse response) {
if (!(project instanceof Job<?, ?>)) {
throw HttpResponses.errorWithoutStack(409, "Note Hook is not supported for this project");
}
ACL.impersonate(ACL.SYSTEM, new Runnable() {
public void run() {
GitLabPushTrigger trigger = GitLabPushTrigger.getFromJob((Job<?, ?>) project);
if (trigger != null) {
trigger.onPost(noteHook);
}
ACL.impersonate(ACL.SYSTEM, new BuildWebHookAction.TriggerNotifier(project, secretToken) {
@Override
protected void performOnPost(GitLabPushTrigger trigger) {
trigger.onPost(noteHook);
}
});
throw HttpResponses.ok();

View File

@ -30,13 +30,14 @@ public class PushBuildAction extends BuildWebHookAction {
private final static Logger LOGGER = Logger.getLogger(PushBuildAction.class.getName());
private final Item project;
private PushHook pushHook;
private final String secretToken;
public PushBuildAction(Item project, String json) {
public PushBuildAction(Item project, String json, String secretToken) {
LOGGER.log(Level.FINE, "Push: {0}", toPrettyPrint(json));
this.project = project;
this.pushHook = JsonUtil.read(json, PushHook.class);
this.secretToken = secretToken;
}
void processForCompatibility() {
@ -64,12 +65,10 @@ public class PushBuildAction extends BuildWebHookAction {
}
if (project instanceof Job<?, ?>) {
ACL.impersonate(ACL.SYSTEM, new Runnable() {
public void run() {
GitLabPushTrigger trigger = GitLabPushTrigger.getFromJob((Job<?, ?>) project);
if (trigger != null) {
trigger.onPost(pushHook);
}
ACL.impersonate(ACL.SYSTEM, new TriggerNotifier(project, secretToken) {
@Override
protected void performOnPost(GitLabPushTrigger trigger) {
trigger.onPost(pushHook);
}
});
throw HttpResponses.ok();
@ -104,4 +103,5 @@ public class PushBuildAction extends BuildWebHookAction {
}
}
}
}

View File

@ -69,5 +69,11 @@
</f:optionalBlock>
</table>
</f:entry>
<f:entry title="${%Secret token}" help="/plugin/gitlab-plugin/help/help-secretToken.html">
<table>
<f:readOnlyTextbox field="secretToken" id="secretToken"/>
<f:validateButton title="${%Generate}" method="generateSecretToken"/>
</table>
</f:entry>
</f:advanced>
</j:jelly>

View File

@ -0,0 +1,5 @@
<div>
<div>
<p>If this is configured only WebHooks that have configured the same token can trigger a build.</p>
</div>
</div>

View File

@ -75,7 +75,7 @@ public class MergeRequestBuildActionTest {
testProject.addTrigger(trigger);
exception.expect(HttpResponses.HttpResponseException.class);
new MergeRequestBuildAction(testProject, getJson("MergeRequestEvent.json")).execute(response);
new MergeRequestBuildAction(testProject, getJson("MergeRequestEvent.json"), null).execute(response);
verify(trigger).onPost(any(MergeRequestHook.class));
}
@ -86,7 +86,7 @@ public class MergeRequestBuildActionTest {
testProject.addTrigger(trigger);
exception.expect(HttpResponses.HttpResponseException.class);
new MergeRequestBuildAction(testProject, getJson("MergeRequestEvent_closedMR.json")).execute(response);
new MergeRequestBuildAction(testProject, getJson("MergeRequestEvent_closedMR.json"), null).execute(response);
verify(trigger, never()).onPost(any(MergeRequestHook.class));
}
@ -100,7 +100,7 @@ public class MergeRequestBuildActionTest {
future.get();
exception.expect(HttpResponses.HttpResponseException.class);
new MergeRequestBuildAction(testProject, getJson("MergeRequestEvent_alreadyBuiltMR.json")).execute(response);
new MergeRequestBuildAction(testProject, getJson("MergeRequestEvent_alreadyBuiltMR.json"), null).execute(response);
verify(trigger, never()).onPost(any(MergeRequestHook.class));
}
@ -138,7 +138,7 @@ public class MergeRequestBuildActionTest {
future.get();
exception.expect(HttpResponses.HttpResponseException.class);
new MergeRequestBuildAction(testProject, getJson("MergeRequestEvent_alreadyBuiltMR.json")).execute(response);
new MergeRequestBuildAction(testProject, getJson("MergeRequestEvent_alreadyBuiltMR.json"), null).execute(response);
verify(trigger).onPost(any(MergeRequestHook.class));
}

View File

@ -74,7 +74,7 @@ public class NoteBuildActionTest {
testProject.addTrigger(trigger);
exception.expect(HttpResponses.HttpResponseException.class);
new NoteBuildAction(testProject, getJson("NoteEvent.json")).execute(response);
new NoteBuildAction(testProject, getJson("NoteEvent.json"), null).execute(response);
verify(trigger).onPost(any(NoteHook.class));
}
@ -88,7 +88,7 @@ public class NoteBuildActionTest {
future.get();
exception.expect(HttpResponses.HttpResponseException.class);
new NoteBuildAction(testProject, getJson("NoteEvent_alreadyBuiltMR.json")).execute(response);
new NoteBuildAction(testProject, getJson("NoteEvent_alreadyBuiltMR.json"), null).execute(response);
verify(trigger).onPost(any(NoteHook.class));
}
@ -126,7 +126,7 @@ public class NoteBuildActionTest {
future.get();
exception.expect(HttpResponses.HttpResponseException.class);
new NoteBuildAction(testProject, getJson("NoteEvent_alreadyBuiltMR.json")).execute(response);
new NoteBuildAction(testProject, getJson("NoteEvent_alreadyBuiltMR.json"), null).execute(response);
verify(trigger).onPost(any(NoteHook.class));
}

View File

@ -46,7 +46,7 @@ public class PushBuildActionTest {
FreeStyleProject testProject = jenkins.createFreeStyleProject();
testProject.addTrigger(trigger);
new PushBuildAction(testProject, getJson("PushEvent_missingRepositoryUrl.json")).execute(response);
new PushBuildAction(testProject, getJson("PushEvent_missingRepositoryUrl.json"), null).execute(response);
verify(trigger, never()).onPost(any(PushHook.class));
}
@ -58,11 +58,24 @@ public class PushBuildActionTest {
testProject.addTrigger(trigger);
exception.expect(HttpResponses.HttpResponseException.class);
new PushBuildAction(testProject, getJson("PushEvent.json")).execute(response);
new PushBuildAction(testProject, getJson("PushEvent.json"), null).execute(response);
verify(trigger).onPost(any(PushHook.class));
}
@Test
public void invalidToken() throws IOException {
FreeStyleProject testProject = jenkins.createFreeStyleProject();
when(trigger.getTriggerOpenMergeRequestOnPush()).thenReturn(TriggerOpenMergeRequest.never);
when(trigger.isWebHookAuthorized("test")).thenReturn(false);
testProject.addTrigger(trigger);
exception.expect(HttpResponses.HttpResponseException.class);
new PushBuildAction(testProject, getJson("PushEvent.json"), "test").execute(response);
verify(trigger, never()).onPost(any(PushHook.class));
}
private String getJson(String name) throws IOException {
return IOUtils.toString(getClass().getResourceAsStream(name));
}