From 8c8ca58b745061d00996f32b2feeeec2d394ac4c Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 1 Jun 2026 15:29:32 +0800 Subject: [PATCH] feat: implement GitHub webhook HMAC-SHA256 signature verification Verify X-Hub-Signature-256 header using CryptoUtil.hmacSha256 from onixbyte crypto-toolbox. Signature check is skipped when no secret is configured. Uses MessageDigest.isEqual for constant-time comparison. --- build.gradle.kts | 1 + gradle/libs.versions.toml | 1 + .../interceptor/GitHubWebhookInterceptor.java | 85 +++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java diff --git a/build.gradle.kts b/build.gradle.kts index 0ce2b78..878912f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation(libs.onixbyte.identityGenerator) implementation(libs.onixbyte.captcha) implementation(libs.onixbyte.regions) + implementation(libs.onixbyte.cryptoToolbox) implementation(libs.jwt.core) implementation(libs.spring.boot.configurationProcessor) implementation(libs.spring.boot.actuator) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 811ee52..d9650f6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,7 @@ jackson-jsr310 = { group = "com.fasterxml.jackson.datatype", name = "jackson-dat onixbyte-versionCatalogue = { group = "com.onixbyte", name = "version-catalogue", version.ref = "onixbyteVersion" } onixbyte-tuple = { group = "com.onixbyte", name = "tuple" } onixbyte-commonToolbox = { group = "com.onixbyte", name = "common-toolbox" } +onixbyte-cryptoToolbox = { group = "com.onixbyte", name = "crypto-toolbox", version.ref = "onixbyteVersion" } onixbyte-mathToolbox = { group = "com.onixbyte", name = "math-toolbox" } onixbyte-identityGenerator = { group = "com.onixbyte", name = "identity-generator" } onixbyte-captcha = { group = "com.onixbyte", name = "captcha", version.ref = "onixbyteCaptcha" } diff --git a/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java b/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java new file mode 100644 index 0000000..2f8e111 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java @@ -0,0 +1,85 @@ +package com.onixbyte.deltaforceguide.interceptor; + +import com.onixbyte.crypto.util.CryptoUtil; +import com.onixbyte.deltaforceguide.exeption.BizException; +import com.onixbyte.deltaforceguide.manager.WebhookManager; +import com.onixbyte.deltaforceguide.shared.GitHubWebhookHeader; +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.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * Verifies GitHub webhook requests by validating the {@code X-Hub-Signature-256} + * header against the configured secret using HMAC-SHA256. + * + *

Verification is skipped when no secret is configured. The signature format is + * {@code sha256=} as documented by GitHub. + */ +@Component +public class GitHubWebhookInterceptor implements HandlerInterceptor { + + private static final Logger log = LoggerFactory.getLogger(GitHubWebhookInterceptor.class); + + private final WebhookManager webhookManager; + + public GitHubWebhookInterceptor(WebhookManager webhookManager) { + this.webhookManager = webhookManager; + } + + @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 secret = webhookManager.github().secret(); + if (secret == null || secret.isBlank()) { + log.debug("No GitHub webhook secret configured, skipping signature verification"); + return true; + } + + var signatureHeader = req.getHeader(GitHubWebhookHeader.SIGNATURE_256); + if (signatureHeader == null || signatureHeader.isBlank()) { + log.warn("Missing {} header from ip={}", + GitHubWebhookHeader.SIGNATURE_256, request.getRemoteAddr()); + throw new BizException(HttpStatus.UNAUTHORIZED, + "Missing webhook signature header"); + } + + var body = req.getBodyString(); + try { + var computed = "sha256=" + CryptoUtil.hmacSha256(secret, body); + + if (!MessageDigest.isEqual( + computed.getBytes(StandardCharsets.UTF_8), + signatureHeader.getBytes(StandardCharsets.UTF_8))) { + log.warn("Invalid webhook signature from ip={}", request.getRemoteAddr()); + throw new BizException(HttpStatus.UNAUTHORIZED, "Invalid webhook signature"); + } + + return true; + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + log.error("Failed to compute HMAC-SHA256", e); + throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, + "Failed to verify webhook signature"); + } + } +}