Skip to content
This repository has been archived by the owner on Apr 25, 2023. It is now read-only.

Commit

Permalink
Fix GitHub webhook endpoint
Browse files Browse the repository at this point in the history
Prior to this commit, the webhook endpoint would be protected by CSRF,
preventing all existing integration from working.

This commit selectively disables CSRF protection on this endpoint, and
rewrites completely the Controller in order to simplify it.
  • Loading branch information
bclozel committed Oct 23, 2020
1 parent c289703 commit da73b30
Show file tree
Hide file tree
Showing 15 changed files with 428 additions and 469 deletions.
2 changes: 1 addition & 1 deletion sagan-site/src/main/java/sagan/site/SecurityConfig.java
Expand Up @@ -39,7 +39,7 @@ protected void configure(HttpSecurity http) throws Exception {
http
.addFilterAfter(githubBasicAuthFilter(), BasicAuthenticationFilter.class)
.exceptionHandling(handling -> handling.authenticationEntryPoint(entryPoint()))
.csrf(csrf -> csrf.ignoringAntMatchers("/api/**"))
.csrf(csrf -> csrf.ignoringAntMatchers("/api/**", "/webhook/**"))
.requiresChannel(channel ->
channel.requestMatchers(request -> request.getHeader("x-forwarded-port") != null).requiresSecure())
.authorizeRequests(req ->
Expand Down
4 changes: 2 additions & 2 deletions sagan-site/src/main/java/sagan/site/SiteProperties.java
Expand Up @@ -13,7 +13,7 @@ public class SiteProperties {

private final Events events = new Events();

private final GitHub gitHub = new GitHub();
private final GitHub github = new GitHub();

private final Renderer renderer = new Renderer();

Expand All @@ -31,7 +31,7 @@ public Events getEvents() {
}

public GitHub getGithub() {
return this.gitHub;
return this.github;
}

public Renderer getRenderer() {
Expand Down
146 changes: 44 additions & 102 deletions sagan-site/src/main/java/sagan/site/guides/DocsWebhookController.java
@@ -1,30 +1,34 @@
package sagan.site.guides;

import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Map;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import sagan.site.SiteProperties;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;
import java.io.IOException;
import java.nio.charset.Charset;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Map;

import static org.springframework.web.bind.annotation.RequestMethod.POST;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* Controller that handles requests from GitHub webhook set up at <a
* href="https://github.com/spring-guides/">the guides, tutorials and understanding docs
* href="https://github.com/spring-guides/">the getting started, tutorial and topical guides
* repository</a> and clears the rendered docs from cache.
* <p>
* This allows to keep the rendered versions of those docs in cache until new changes
Expand All @@ -38,7 +42,7 @@ class DocsWebhookController {

private static final Log logger = LogFactory.getLog(DocsWebhookController.class);

private static final Charset CHARSET = Charset.forName("UTF-8");
private static final Charset CHARSET = StandardCharsets.UTF_8;

private static final String HMAC_ALGORITHM = "HmacSHA1";

Expand Down Expand Up @@ -75,112 +79,34 @@ public DocsWebhookController(ObjectMapper objectMapper,
@ExceptionHandler(WebhookAuthenticationException.class)
public ResponseEntity<String> handleWebhookAuthenticationFailure(WebhookAuthenticationException exception) {
logger.error("Webhook authentication failure: " + exception.getMessage());
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("{ \"message\": \"Forbidden\" }\n");
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("{ \"message\": \"Forbidden\" }");
}

@ExceptionHandler(IOException.class)
public ResponseEntity<String> handlePayloadParsingException(IOException exception) {
logger.error("Payload parsing exception", exception);
return ResponseEntity.badRequest().body("{ \"message\": \"Bad Request\" }\n");
}

@RequestMapping(value = "guides", method = POST,
consumes = "application/json", produces = "application/json")
public ResponseEntity<String> processGuidesUpdate(@RequestBody String payload,
@RequestHeader("X-Hub-Signature") String signature,
@RequestHeader("X-GitHub-Event") String event) throws IOException {

verifyHmacSignature(payload, signature);
if (PING_EVENT.equals(event)) {
return ResponseEntity.ok("{ \"message\": \"Successfully processed ping event\" }\n");
}
Map<?, ?> push = this.objectMapper.readValue(payload, Map.class);
logPayload(push);

String repositoryName = (String) ((Map<?, ?>) push.get("repository")).get("name");
String guideName = stripPrefix(repositoryName);
this.gettingStartedGuides.evictFromCache(guideName);
return ResponseEntity.ok("{ \"message\": \"Successfully processed update\" }\n");
return ResponseEntity.badRequest().body("{ \"message\": \"Bad Request\" }");
}

@RequestMapping(value = "guides/{repositoryName}", method = POST,
@PostMapping(path = "guides",
consumes = "application/json", produces = "application/json")
public ResponseEntity<String> processGuidesUpdate(@RequestBody String payload,
@RequestHeader("X-Hub-Signature") String signature,
@RequestHeader("X-GitHub-Event") String event,
@PathVariable String repositoryName) throws IOException {
@RequestHeader(name = "X-GitHub-Event", required = false, defaultValue = "push") String event) throws IOException {

verifyHmacSignature(payload, signature);
if (PING_EVENT.equals(event)) {
return ResponseEntity.ok("{ \"message\": \"Successfully processed ping event\" }\n");
}
logger.info("Received new webhook payload for push against " + repositoryName);

String guideName = stripPrefix(repositoryName);
this.gettingStartedGuides.evictFromCache(guideName);
return ResponseEntity.ok("{ \"message\": \"Successfully processed update\" }\n");
}

@RequestMapping(value = "tutorials", method = POST,
consumes = "application/json", produces = "application/json")
public ResponseEntity<String> processTutorialsUpdate(@RequestBody String payload,
@RequestHeader("X-Hub-Signature") String signature,
@RequestHeader("X-GitHub-Event") String event) throws IOException {

verifyHmacSignature(payload, signature);
if (PING_EVENT.equals(event)) {
return ResponseEntity.ok("{ \"message\": \"Successfully processed ping event\" }\n");
return ResponseEntity.ok("{ \"message\": \"Successfully processed ping event\" }");
}
Map<?, ?> push = this.objectMapper.readValue(payload, Map.class);
logPayload(push);

String repositoryName = (String) ((Map<?, ?>) push.get("repository")).get("name");
String tutorialName = stripPrefix(repositoryName);
this.tutorials.evictFromCache(tutorialName);
return ResponseEntity.ok("{ \"message\": \"Successfully processed update\" }\n");
logger.info("Clearing cache for guide: " + repositoryName);
evictFromCache(repositoryName);
return ResponseEntity.ok("{ \"message\": \"Successfully processed update\" }");
}

@RequestMapping(value = "tutorials/{repositoryName}", method = POST,
consumes = "application/json", produces = "application/json")
public ResponseEntity<String> processTutorialsUpdate(@RequestBody String payload,
@RequestHeader("X-Hub-Signature") String signature,
@RequestHeader("X-GitHub-Event") String event,
@PathVariable String repositoryName) throws IOException {

verifyHmacSignature(payload, signature);
if (PING_EVENT.equals(event)) {
return ResponseEntity.ok("{ \"message\": \"Successfully processed ping event\" }\n");
}
logger.info("Received new webhook payload for push against " + repositoryName);

String tutorialName = stripPrefix(repositoryName);
this.tutorials.evictFromCache(tutorialName);
return ResponseEntity.ok("{ \"message\": \"Successfully processed update\" }\n");
}

@RequestMapping(value = "topicals/{repositoryName}", method = POST,
consumes = "application/json", produces = "application/json")
public ResponseEntity<String> processTopicalsUpdate(@RequestBody String payload,
@RequestHeader("X-Hub-Signature") String signature,
@RequestHeader("X-GitHub-Event") String event,
@PathVariable String repositoryName) throws IOException {

verifyHmacSignature(payload, signature);
if (PING_EVENT.equals(event)) {
return ResponseEntity.ok("{ \"message\": \"Successfully processed ping event\" }\n");
}
logger.info("Received new webhook payload for push against " + repositoryName);

String guideName = stripPrefix(repositoryName);
this.topicals.evictFromCache(guideName);
return ResponseEntity.ok("{ \"message\": \"Successfully processed update\" }\n");
}

private String stripPrefix(String repositoryName) {
return repositoryName.substring(repositoryName.indexOf("-") + 1);
}

protected void verifyHmacSignature(String message, String signature) {
private void verifyHmacSignature(String message, String signature) {
byte[] sig = hmac.doFinal(message.getBytes(CHARSET));
String computedSignature = "sha1=" + DatatypeConverter.printHexBinary(sig);
if (!computedSignature.equalsIgnoreCase(signature)) {
Expand All @@ -202,4 +128,20 @@ private void logPayload(Map<?, ?> push) {
}
}

private void evictFromCache(String repositoryName) {
GuideType guideType = GuideType.fromRepositoryName(repositoryName);
String guideName = guideType.stripPrefix(repositoryName);
switch (guideType) {
case GETTING_STARTED:
this.gettingStartedGuides.evictFromCache(guideName);
break;
case TOPICAL:
this.topicals.evictFromCache(guideName);
break;
case TUTORIAL:
this.tutorials.evictFromCache(guideName);
break;
}
}

}
50 changes: 50 additions & 0 deletions sagan-site/src/main/java/sagan/site/guides/GuideType.java
@@ -0,0 +1,50 @@
package sagan.site.guides;

import java.util.Arrays;

/**
* Guide Types
*/
enum GuideType {

GETTING_STARTED("getting-started", "gs-"), TUTORIAL("tutorial", "tut-"),
TOPICAL("topical", "top-"), UNKNOWN("unknown", "");

private final String slug;
private final String prefix;


GuideType(String slug, String prefix) {
this.slug = slug;
this.prefix = prefix;
}

public static GuideType fromSlug(String slug) {
return Arrays.stream(GuideType.values())
.filter(type -> type.getSlug().equals(slug))
.findFirst().orElse(GuideType.UNKNOWN);
}

public static GuideType fromRepositoryName(String repositoryName) {
return Arrays.stream(GuideType.values())
.filter(type -> repositoryName.startsWith(type.getPrefix()))
.findFirst().orElse(GuideType.UNKNOWN);
}

public String stripPrefix(String repositoryName) {
return repositoryName.replaceFirst(this.prefix, "");
}

public String getSlug() {
return this.slug;
}

public String getPrefix() {
return this.prefix;
}

@Override
public String toString() {
return this.slug;
}
}

0 comments on commit da73b30

Please sign in to comment.