mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 04:18:17 +08:00
feat(hook): HMAC-SHA-256 body signing for outbound webhooks
This commit is contained in:
parent
25e93c4a89
commit
22231e28a7
@ -39,13 +39,18 @@ public class HookActionFactory {
|
|||||||
case BUILTIN -> new BuiltinAction(
|
case BUILTIN -> new BuiltinAction(
|
||||||
text(cfg, "op", "log.info"),
|
text(cfg, "op", "log.info"),
|
||||||
text(cfg, "arg", ""));
|
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(
|
case HTTP -> new HttpAction(
|
||||||
sharedRestClient(),
|
sharedRestClient(),
|
||||||
text(cfg, "method", "POST"),
|
text(cfg, "method", "POST"),
|
||||||
URI.create(required(cfg, "url")),
|
URI.create(required(cfg, "url")),
|
||||||
text(cfg, "body", null),
|
text(cfg, "body", null),
|
||||||
props.getTrustedDomains(),
|
props.getTrustedDomains(),
|
||||||
timeoutMs);
|
timeoutMs,
|
||||||
|
text(cfg, "hmacSecret", null),
|
||||||
|
text(cfg, "signatureHeader", null));
|
||||||
case SHELL -> new ShellAction(required(cfg, "command"));
|
case SHELL -> new ShellAction(required(cfg, "command"));
|
||||||
case CHANNEL_MESSAGE -> new ChannelMessageAction(
|
case CHANNEL_MESSAGE -> new ChannelMessageAction(
|
||||||
required(cfg, "channelType"),
|
required(cfg, "channelType"),
|
||||||
|
|||||||
@ -7,7 +7,11 @@ import org.springframework.web.client.RestClient;
|
|||||||
import org.springframework.web.client.RestClientException;
|
import org.springframework.web.client.RestClientException;
|
||||||
import vip.mate.hook.event.MateHookEvent;
|
import vip.mate.hook.event.MateHookEvent;
|
||||||
|
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.HexFormat;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -27,21 +31,43 @@ import java.util.List;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public final class HttpAction implements HookAction {
|
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 RestClient restClient;
|
||||||
private final String method; // GET | POST
|
private final String method; // GET | POST
|
||||||
private final URI url;
|
private final URI url;
|
||||||
private final String bodyTemplate; // 可含 {{event.xxx}} 占位
|
private final String bodyTemplate; // 可含 {{event.xxx}} 占位
|
||||||
private final List<String> trustedDomains;
|
private final List<String> trustedDomains;
|
||||||
private final long timeoutMs;
|
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,
|
public HttpAction(RestClient restClient, String method, URI url, String bodyTemplate,
|
||||||
List<String> trustedDomains, long timeoutMs) {
|
List<String> trustedDomains, long timeoutMs) {
|
||||||
|
this(restClient, method, url, bodyTemplate, trustedDomains, timeoutMs, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpAction(RestClient restClient, String method, URI url, String bodyTemplate,
|
||||||
|
List<String> trustedDomains, long timeoutMs,
|
||||||
|
String hmacSecret, String signatureHeader) {
|
||||||
this.restClient = restClient;
|
this.restClient = restClient;
|
||||||
this.method = (method == null) ? "POST" : method.toUpperCase();
|
this.method = (method == null) ? "POST" : method.toUpperCase();
|
||||||
this.url = url;
|
this.url = url;
|
||||||
this.bodyTemplate = bodyTemplate;
|
this.bodyTemplate = bodyTemplate;
|
||||||
this.trustedDomains = List.copyOf(trustedDomains == null ? List.of() : trustedDomains);
|
this.trustedDomains = List.copyOf(trustedDomains == null ? List.of() : trustedDomains);
|
||||||
this.timeoutMs = Math.max(100L, timeoutMs);
|
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
|
@Override
|
||||||
@ -69,12 +95,24 @@ public final class HttpAction implements HookAction {
|
|||||||
long start = System.nanoTime();
|
long start = System.nanoTime();
|
||||||
try {
|
try {
|
||||||
String body = renderBody(event, ctx);
|
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) {
|
HttpStatusCode status = switch (method) {
|
||||||
case "GET" -> restClient.get().uri(url).retrieve().toBodilessEntity().getStatusCode();
|
case "GET" -> {
|
||||||
case "POST" -> restClient.post().uri(url)
|
var spec = restClient.get().uri(url);
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
if (signature != null) spec.header(signatureHeader, signature);
|
||||||
.body(body == null ? "" : body)
|
yield spec.retrieve().toBodilessEntity().getStatusCode();
|
||||||
.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");
|
default -> throw new IllegalStateException("unreachable");
|
||||||
};
|
};
|
||||||
long ms = (System.nanoTime() - start) / 1_000_000L;
|
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=<hex>"} 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) {
|
private String renderBody(MateHookEvent event, HookContext ctx) {
|
||||||
if (bodyTemplate == null || bodyTemplate.isEmpty()) return null;
|
if (bodyTemplate == null || bodyTemplate.isEmpty()) return null;
|
||||||
// 极简占位替换:仅支持 {{event.type}} / {{event.timestamp}} + ctx.templateVars
|
// 极简占位替换:仅支持 {{event.type}} / {{event.timestamp}} + ctx.templateVars
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user