diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java index 32ec283..8d7eb6c 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java @@ -1,10 +1,29 @@ package com.onixbyte.deltaforceguide.config; +import com.onixbyte.deltaforceguide.interceptor.GitLabWebhookInterceptor; import com.onixbyte.deltaforceguide.properties.AppProperties; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration @EnableConfigurationProperties(AppProperties.class) -public class AppConfig { +public class AppConfig implements WebMvcConfigurer { + + private final GitLabWebhookInterceptor gitLabWebhookInterceptor; + + @Autowired + public AppConfig( + GitLabWebhookInterceptor gitLabWebhookInterceptor + ) { + this.gitLabWebhookInterceptor = gitLabWebhookInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(gitLabWebhookInterceptor) + .addPathPatterns("/webhook/gitlab"); + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/FilterConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/FilterConfig.java new file mode 100644 index 0000000..a80bcfc --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/config/FilterConfig.java @@ -0,0 +1,21 @@ +package com.onixbyte.deltaforceguide.config; + +import com.onixbyte.deltaforceguide.filter.WebhookFilter; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class FilterConfig { + + @Bean + public FilterRegistrationBean loggingFilter(WebhookFilter webhookFilter) { + var registrationBean = new FilterRegistrationBean(); + + registrationBean.setFilter(webhookFilter); + registrationBean.addUrlPatterns("/webhook/*"); + registrationBean.setOrder(1); + + return registrationBean; + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/WebhookConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/WebhookConfig.java new file mode 100644 index 0000000..1cb1a6c --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/config/WebhookConfig.java @@ -0,0 +1,10 @@ +package com.onixbyte.deltaforceguide.config; + +import com.onixbyte.deltaforceguide.properties.WebhookProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(WebhookProperties.class) +public class WebhookConfig { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/filter/WebhookFilter.java b/src/main/java/com/onixbyte/deltaforceguide/filter/WebhookFilter.java new file mode 100644 index 0000000..ddfd071 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/filter/WebhookFilter.java @@ -0,0 +1,22 @@ +package com.onixbyte.deltaforceguide.filter; + +import com.onixbyte.deltaforceguide.wrapper.RepeatedlyReadRequestWrapper; +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class WebhookFilter implements Filter { + @Override + public void doFilter( + ServletRequest request, + ServletResponse response, + FilterChain chain + ) throws IOException, ServletException { + var httpRequest = (HttpServletRequest) request; + var wrappedRequest = new RepeatedlyReadRequestWrapper(httpRequest); + chain.doFilter(wrappedRequest, response); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitLabWebhookInterceptor.java b/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitLabWebhookInterceptor.java new file mode 100644 index 0000000..34eb9fd --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitLabWebhookInterceptor.java @@ -0,0 +1,109 @@ +package com.onixbyte.deltaforceguide.interceptor; + +import com.onixbyte.deltaforceguide.exeption.BizException; +import com.onixbyte.deltaforceguide.properties.WebhookProperties; +import com.onixbyte.deltaforceguide.wrapper.RepeatedlyReadRequestWrapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; + +@Component +public class GitLabWebhookInterceptor implements HandlerInterceptor { + + private static final Logger log = LoggerFactory.getLogger(GitLabWebhookInterceptor.class); + private static final String TOKEN_PREFIX = "whsec_"; + + private final WebhookProperties webhookProperties; + + public GitLabWebhookInterceptor(WebhookProperties webhookProperties) { + this.webhookProperties = webhookProperties; + } + + @Override + public boolean preHandle( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull Object handler + ) { + if (!(request instanceof RepeatedlyReadRequestWrapper req)) { + throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, + "Request body is not readable"); + } + + var webhookId = request.getHeader("webhook-id"); + var webhookTimestamp = request.getHeader("webhook-timestamp"); + var webhookSignature = request.getHeader("webhook-signature"); + + if (webhookId == null || webhookTimestamp == null || webhookSignature == null) { + log.warn("Missing webhook headers from ip={}", request.getRemoteAddr()); + throw new BizException(HttpStatus.UNAUTHORIZED, + "Missing webhook verification headers"); + } + + var signingToken = webhookProperties.gitlab().signingToken(); + if (signingToken == null || signingToken.isBlank()) { + log.debug("No GitLab signing token configured, skipping signature verification"); + return true; + } + + var body = req.getBodyString(); + var signedContent = "%s.%s.%s".formatted(webhookId, webhookTimestamp, body); + + try { + var decodedKey = decodeSigningToken(signingToken); + var computedDigest = computeHmacSha256(decodedKey, signedContent); + var computedSignature = "v1,%s".formatted(computedDigest); + + var signatures = webhookSignature.split(" "); + var matched = false; + for (var sig : signatures) { + if (MessageDigest.isEqual(computedSignature.getBytes(StandardCharsets.UTF_8), + sig.trim().getBytes(StandardCharsets.UTF_8))) { + matched = true; + break; + } + } + + if (!matched) { + log.warn("Invalid webhook signature from ip={}", request.getRemoteAddr()); + throw new BizException(HttpStatus.UNAUTHORIZED, "Invalid webhook signature"); + } + } catch (BizException e) { + throw e; + } catch (Exception e) { + log.error("Failed to verify webhook signature", e); + throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, + "Failed to verify webhook signature"); + } + + return true; + } + + private byte[] decodeSigningToken(String token) { + if (!token.startsWith(TOKEN_PREFIX)) { + throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, + "Signing token must start with " + TOKEN_PREFIX); + } + var encoded = token.substring(TOKEN_PREFIX.length()); + return Base64.getDecoder().decode(encoded); + } + + private String computeHmacSha256(byte[] key, String data) throws Exception { + var mac = Mac.getInstance("HmacSHA256"); + var secretKey = new SecretKeySpec(key, "HmacSHA256"); + mac.init(secretKey); + var hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(hash); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/WebhookManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/WebhookManager.java new file mode 100644 index 0000000..a4512ba --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/WebhookManager.java @@ -0,0 +1,21 @@ +package com.onixbyte.deltaforceguide.manager; + +import com.onixbyte.deltaforceguide.properties.GitLabWebhookProperties; +import com.onixbyte.deltaforceguide.properties.WebhookProperties; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class WebhookManager { + + private final WebhookProperties webhookProperties; + + @Autowired + public WebhookManager(WebhookProperties webhookProperties) { + this.webhookProperties = webhookProperties; + } + + public GitLabWebhookProperties getGitLabWebhookProperties() { + return webhookProperties.gitlab(); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/GitLabWebhookProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/GitLabWebhookProperties.java new file mode 100644 index 0000000..318830e --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/properties/GitLabWebhookProperties.java @@ -0,0 +1,6 @@ +package com.onixbyte.deltaforceguide.properties; + +public record GitLabWebhookProperties( + String signingToken +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/WebhookProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/WebhookProperties.java new file mode 100644 index 0000000..bcd439c --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/properties/WebhookProperties.java @@ -0,0 +1,9 @@ +package com.onixbyte.deltaforceguide.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.webhook") +public record WebhookProperties( + GitLabWebhookProperties gitlab +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java new file mode 100644 index 0000000..4a1f50d --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java @@ -0,0 +1,14 @@ +package com.onixbyte.deltaforceguide.service; + +import com.onixbyte.deltaforceguide.manager.WebhookManager; +import org.springframework.stereotype.Service; + +@Service +public class WebhookService { + + private final WebhookManager webhookManager; + + public WebhookService(WebhookManager webhookManager) { + this.webhookManager = webhookManager; + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/wrapper/RepeatedlyReadRequestWrapper.java b/src/main/java/com/onixbyte/deltaforceguide/wrapper/RepeatedlyReadRequestWrapper.java new file mode 100644 index 0000000..ee31c22 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/wrapper/RepeatedlyReadRequestWrapper.java @@ -0,0 +1,56 @@ +package com.onixbyte.deltaforceguide.wrapper; + +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import org.springframework.util.StreamUtils; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +public class RepeatedlyReadRequestWrapper extends HttpServletRequestWrapper { + + private final byte[] body; + + public RepeatedlyReadRequestWrapper(HttpServletRequest request) throws IOException { + super(request); + this.body = StreamUtils.copyToByteArray(request.getInputStream()); + } + + @Override + public ServletInputStream getInputStream() { + final var byteArrayInputStream = new ByteArrayInputStream(body); + return new ServletInputStream() { + @Override + public boolean isFinished() { + return byteArrayInputStream.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener readListener) { + } + + @Override + public int read() throws IOException { + return byteArrayInputStream.read(); + } + }; + } + + @Override + public BufferedReader getReader() throws IOException { + return new BufferedReader(new InputStreamReader(this.getInputStream(), StandardCharsets.UTF_8)); + } + + public String getBodyString() { + return new String(body, StandardCharsets.UTF_8); + } +}