feat: add gitlab webhook verification

This commit is contained in:
2026-05-28 13:54:30 +08:00
parent 66b37ec20d
commit f866d93fb4
10 changed files with 288 additions and 1 deletions
@@ -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");
}
}
@@ -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<WebhookFilter> loggingFilter(WebhookFilter webhookFilter) {
var registrationBean = new FilterRegistrationBean<WebhookFilter>();
registrationBean.setFilter(webhookFilter);
registrationBean.addUrlPatterns("/webhook/*");
registrationBean.setOrder(1);
return registrationBean;
}
}
@@ -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 {
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -0,0 +1,6 @@
package com.onixbyte.deltaforceguide.properties;
public record GitLabWebhookProperties(
String signingToken
) {
}
@@ -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
) {
}
@@ -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;
}
}
@@ -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);
}
}