From 22231e28a796a0ba922e1cd076a657d49ed831a0 Mon Sep 17 00:00:00 2001 From: matevip Date: Sat, 2 May 2026 15:41:13 +0800 Subject: [PATCH] feat(hook): HMAC-SHA-256 body signing for outbound webhooks --- .../java/vip/mate/hook/HookActionFactory.java | 7 +- .../java/vip/mate/hook/action/HttpAction.java | 68 +++++++++++++++++-- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/hook/HookActionFactory.java b/mateclaw-server/src/main/java/vip/mate/hook/HookActionFactory.java index e1ae4163..c2c073ab 100644 --- a/mateclaw-server/src/main/java/vip/mate/hook/HookActionFactory.java +++ b/mateclaw-server/src/main/java/vip/mate/hook/HookActionFactory.java @@ -39,13 +39,18 @@ public class HookActionFactory { case BUILTIN -> new BuiltinAction( text(cfg, "op", "log.info"), text(cfg, "arg", "")); + // RFC-03 Lane H1 — hmacSecret + signatureHeader are optional; null / + // blank disables signing (legacy behavior). Receivers that need + // origin-verification re-compute SHA-256 HMAC on the raw body. case HTTP -> new HttpAction( sharedRestClient(), text(cfg, "method", "POST"), URI.create(required(cfg, "url")), text(cfg, "body", null), props.getTrustedDomains(), - timeoutMs); + timeoutMs, + text(cfg, "hmacSecret", null), + text(cfg, "signatureHeader", null)); case SHELL -> new ShellAction(required(cfg, "command")); case CHANNEL_MESSAGE -> new ChannelMessageAction( required(cfg, "channelType"), diff --git a/mateclaw-server/src/main/java/vip/mate/hook/action/HttpAction.java b/mateclaw-server/src/main/java/vip/mate/hook/action/HttpAction.java index 7fd27280..38e0623c 100644 --- a/mateclaw-server/src/main/java/vip/mate/hook/action/HttpAction.java +++ b/mateclaw-server/src/main/java/vip/mate/hook/action/HttpAction.java @@ -7,7 +7,11 @@ import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; import vip.mate.hook.event.MateHookEvent; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; import java.util.List; /** @@ -27,21 +31,43 @@ import java.util.List; @Slf4j public final class HttpAction implements HookAction { + /** RFC-03 Lane H1 default header name; configurable per hook so receivers + * with existing conventions (X-Hub-Signature-256, etc.) can be served + * without code changes. */ + public static final String DEFAULT_SIGNATURE_HEADER = "X-MateClaw-Signature"; + private final RestClient restClient; private final String method; // GET | POST private final URI url; private final String bodyTemplate; // 可含 {{event.xxx}} 占位 private final List trustedDomains; private final long timeoutMs; + /** RFC-03 Lane H1 — when set, the rendered body is signed with HMAC-SHA-256 + * and the resulting hex digest is placed in the header named by + * {@link #signatureHeader}, prefixed with {@code "sha256="}. Receivers + * validate by re-computing the same digest from the raw body and a + * shared secret. Null / blank disables signing (the previous behavior). */ + private final String hmacSecret; + private final String signatureHeader; + /** Legacy constructor — preserved for callers that don't sign. */ public HttpAction(RestClient restClient, String method, URI url, String bodyTemplate, List trustedDomains, long timeoutMs) { + this(restClient, method, url, bodyTemplate, trustedDomains, timeoutMs, null, null); + } + + public HttpAction(RestClient restClient, String method, URI url, String bodyTemplate, + List trustedDomains, long timeoutMs, + String hmacSecret, String signatureHeader) { this.restClient = restClient; this.method = (method == null) ? "POST" : method.toUpperCase(); this.url = url; this.bodyTemplate = bodyTemplate; this.trustedDomains = List.copyOf(trustedDomains == null ? List.of() : trustedDomains); this.timeoutMs = Math.max(100L, timeoutMs); + this.hmacSecret = (hmacSecret == null || hmacSecret.isBlank()) ? null : hmacSecret; + this.signatureHeader = (signatureHeader == null || signatureHeader.isBlank()) + ? DEFAULT_SIGNATURE_HEADER : signatureHeader; } @Override @@ -69,12 +95,24 @@ public final class HttpAction implements HookAction { long start = System.nanoTime(); try { String body = renderBody(event, ctx); + String renderedBody = body == null ? "" : body; + // RFC-03 Lane H1 — sign the rendered body if a secret is configured. + // Computed once on the agreed-upon byte representation; receivers + // validate by re-computing on raw bytes before any JSON parsing. + String signature = (hmacSecret == null) ? null : hmacSign(renderedBody); HttpStatusCode status = switch (method) { - case "GET" -> restClient.get().uri(url).retrieve().toBodilessEntity().getStatusCode(); - case "POST" -> restClient.post().uri(url) - .contentType(MediaType.APPLICATION_JSON) - .body(body == null ? "" : body) - .retrieve().toBodilessEntity().getStatusCode(); + case "GET" -> { + var spec = restClient.get().uri(url); + if (signature != null) spec.header(signatureHeader, signature); + yield spec.retrieve().toBodilessEntity().getStatusCode(); + } + case "POST" -> { + var spec = restClient.post().uri(url) + .contentType(MediaType.APPLICATION_JSON) + .body(renderedBody); + if (signature != null) spec.header(signatureHeader, signature); + yield spec.retrieve().toBodilessEntity().getStatusCode(); + } default -> throw new IllegalStateException("unreachable"); }; long ms = (System.nanoTime() - start) / 1_000_000L; @@ -86,6 +124,26 @@ public final class HttpAction implements HookAction { } } + /** + * RFC-03 Lane H1 — compute {@code "sha256="} where {@code hex} is the + * lowercase HMAC-SHA-256 of {@code body} keyed by {@link #hmacSecret}. + * Format matches the GitHub / Stripe webhook convention so receivers can + * reuse off-the-shelf validators. Package-private for unit tests. + */ + String hmacSign(String body) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(hmacSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] digest = mac.doFinal(body.getBytes(StandardCharsets.UTF_8)); + return "sha256=" + HexFormat.of().formatHex(digest); + } catch (Exception e) { + // HmacSHA256 is mandatory in every JDK; failure here is unrecoverable + // and points at JVM corruption — rethrow as runtime so the action + // factory's validate() can surface a clear error before scheduling. + throw new IllegalStateException("HMAC-SHA-256 unavailable on this JVM", e); + } + } + private String renderBody(MateHookEvent event, HookContext ctx) { if (bodyTemplate == null || bodyTemplate.isEmpty()) return null; // 极简占位替换:仅支持 {{event.type}} / {{event.timestamp}} + ctx.templateVars