/
RequirePostWithGHHookPayload.java
197 lines (178 loc) · 8.78 KB
/
RequirePostWithGHHookPayload.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
package org.jenkinsci.plugins.github.webhook;
import com.cloudbees.jenkins.GitHubWebHook;
import com.google.common.base.Optional;
import hudson.util.Secret;
import org.jenkinsci.main.modules.instance_identity.InstanceIdentity;
import org.jenkinsci.plugins.github.GitHubPlugin;
import org.jenkinsci.plugins.github.config.GitHubPluginConfig;
import org.jenkinsci.plugins.github.util.FluentIterableWrapper;
import org.kohsuke.github.GHEvent;
import org.kohsuke.stapler.HttpResponses;
import org.kohsuke.stapler.StaplerRequest;
import org.kohsuke.stapler.StaplerResponse;
import org.kohsuke.stapler.interceptor.Interceptor;
import org.kohsuke.stapler.interceptor.InterceptorAnnotation;
import org.slf4j.Logger;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.lang.reflect.InvocationTargetException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.interfaces.RSAPublicKey;
import static com.cloudbees.jenkins.GitHubWebHook.X_INSTANCE_IDENTITY;
import static com.google.common.base.Charsets.UTF_8;
import static com.google.common.base.Predicates.instanceOf;
import static com.google.common.collect.Lists.newArrayList;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
import static javax.servlet.http.HttpServletResponse.SC_METHOD_NOT_ALLOWED;
import static org.apache.commons.codec.binary.Base64.encodeBase64;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
import static org.apache.commons.lang3.StringUtils.substringAfter;
import static org.jenkinsci.plugins.github.util.FluentIterableWrapper.from;
import static org.kohsuke.stapler.HttpResponses.error;
import static org.kohsuke.stapler.HttpResponses.errorWithoutStack;
import static org.slf4j.LoggerFactory.getLogger;
/**
* InterceptorAnnotation annotation to use on WebMethod signature.
* Encapsulates preprocess logic of parsing GHHook or test connection request
*
* @author lanwen (Merkushev Kirill)
* @see <a href=https://wiki.jenkins-ci.org/display/JENKINS/Web+Method>Web Method</a>
*/
@Retention(RUNTIME)
@Target({METHOD, FIELD})
@InterceptorAnnotation(RequirePostWithGHHookPayload.Processor.class)
public @interface RequirePostWithGHHookPayload {
class Processor extends Interceptor {
private static final Logger LOGGER = getLogger(Processor.class);
/**
* Header key being used for the payload signatures.
*
* @see <a href=https://developer.github.com/webhooks/>Developer manual</a>
*/
public static final String SIGNATURE_HEADER = "X-Hub-Signature";
private static final String SHA1_PREFIX = "sha1=";
@Override
public Object invoke(StaplerRequest req, StaplerResponse rsp, Object instance, Object[] arguments)
throws IllegalAccessException, InvocationTargetException {
shouldBePostMethod(req);
returnsInstanceIdentityIfLocalUrlTest(req);
shouldContainParseablePayload(arguments);
shouldProvideValidSignature(req, arguments);
return target.invoke(req, rsp, instance, arguments);
}
/**
* Duplicates {@link @org.kohsuke.stapler.interceptor.RequirePOST} precheck.
* As of it can't guarantee order of multiply interceptor calls,
* it should implement all features of required interceptors in one class
*
* @throws InvocationTargetException if method os not POST
*/
protected void shouldBePostMethod(StaplerRequest request) throws InvocationTargetException {
if (!request.getMethod().equals("POST")) {
throw new InvocationTargetException(error(SC_METHOD_NOT_ALLOWED, "Method POST required"));
}
}
/**
* Used for {@link GitHubPluginConfig#doCheckHookUrl(String)}}
*/
protected void returnsInstanceIdentityIfLocalUrlTest(StaplerRequest req) throws InvocationTargetException {
if (req.getHeader(GitHubWebHook.URL_VALIDATION_HEADER) != null) {
// when the configuration page provides the self-check button, it makes a request with this header.
throw new InvocationTargetException(new HttpResponses.HttpResponseException() {
@Override
public void generateResponse(StaplerRequest req, StaplerResponse rsp, Object node)
throws IOException, ServletException {
RSAPublicKey key = new InstanceIdentity().getPublic();
rsp.setStatus(HttpServletResponse.SC_OK);
rsp.setHeader(X_INSTANCE_IDENTITY, new String(encodeBase64(key.getEncoded()), UTF_8));
}
});
}
}
/**
* Precheck arguments contains not null GHEvent and not blank payload.
* If any other argument will be added to root action index method, then arg count check should be changed
*
* @param arguments event and payload. Both not null and not blank
*
* @throws InvocationTargetException if any of preconditions is not satisfied
*/
protected void shouldContainParseablePayload(Object[] arguments) throws InvocationTargetException {
isTrue(arguments.length == 2,
"GHHook root action should take <(GHEvent) event> and <(String) payload> only");
FluentIterableWrapper<Object> from = from(newArrayList(arguments));
isTrue(
from.firstMatch(instanceOf(GHEvent.class)).isPresent(),
"Hook should contain event type"
);
isTrue(
isNotBlank((String) from.firstMatch(instanceOf(String.class)).or("")),
"Hook should contain payload"
);
}
/**
* Checks that an incoming request has a valid signature, if there is specified a signature in the config.
*
* @param req Incoming request.
*
* @throws InvocationTargetException if any of preconditions is not satisfied
*/
protected void shouldProvideValidSignature(StaplerRequest req, Object[] args) throws InvocationTargetException {
Optional<String> signHeader = Optional.fromNullable(req.getHeader(SIGNATURE_HEADER));
Secret secret = GitHubPlugin.configuration().getHookSecretConfig().getHookSecret();
if (signHeader.isPresent() && Optional.fromNullable(secret).isPresent()) {
String digest = substringAfter(signHeader.get(), SHA1_PREFIX);
LOGGER.trace("Trying to verify sign from header {}", signHeader.get());
isTrue(
GHWebhookSignature.webhookSignature(payloadFrom(req, args), secret).matches(digest),
String.format("Provided signature [%s] did not match to calculated", digest)
);
}
}
/**
* Extracts parsed payload from args and prepare it to calculating hash
* (if json - pass as is, if form - url-encode it with prefix)
*
* @return ready-to-hash payload
*/
protected String payloadFrom(StaplerRequest req, Object[] args) {
final String parsedPayload = (String) args[1];
if (req.getContentType().equals(GHEventPayload.PayloadHandler.APPLICATION_JSON)) {
return parsedPayload;
} else if (req.getContentType().equals(GHEventPayload.PayloadHandler.FORM_URLENCODED)) {
try {
return String.format("payload=%s", URLEncoder.encode(
parsedPayload,
StandardCharsets.UTF_8.toString())
);
} catch (UnsupportedEncodingException e) {
LOGGER.error(e.getMessage(), e);
}
} else {
LOGGER.error("Unknown content type {}", req.getContentType());
}
return "";
}
/**
* Utility method to stop preprocessing if condition is false
*
* @param condition on false throws exception
* @param msg to add to exception
*
* @throws InvocationTargetException BAD REQUEST 400 status code with message
*/
private void isTrue(boolean condition, String msg) throws InvocationTargetException {
if (!condition) {
throw new InvocationTargetException(errorWithoutStack(SC_BAD_REQUEST, msg));
}
}
}
}