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));
}
}
}
}