feat(webhook): Phase 2 — dispatcher service + HMAC + SSRF guard
WebhookDispatcher (@Scheduled, conditional ordinis.webhook.enabled=true): - matchTick (every 2s): scan unpublished outbox_events × active subs, INSERT pending deliveries idempotently (ON CONFLICT DO NOTHING) - sendTick (every 1s): scan due deliveries (status pending/retrying AND next_attempt_at <= now), POST URL with HMAC-SHA256 + SSRF check, mark delivered (2xx) / retrying (backoff) / dlq (>= 1000 attempts) HmacSigner: - HMAC-SHA256 hex signing для X-Ordinis-Signature header - Format: `sha256=<hex>` — receiver verifies через shared secret - JDK Mac, no external deps SsrfGuard: - Reject URLs резолвящиеся в private CIDR (10/8, 172.16/12, 192.168/16, 127/8, 169.254/16, link-local, multicast) - Через InetAddress.getAllByName() + isXxxAddress() checks - Allowlist через ordinis.webhook.allowed-hosts CSV для testing HttpClient: JDK 21 native, connectTimeout 3s, send timeout configurable ordinis.webhook.send-timeout-ms (default 5000ms) Метрики Micrometer: - nsi_webhook_delivered_total (success counter) - nsi_webhook_failed_total (retry counter) - nsi_webhook_dlq_total (DLQ counter) - nsi_webhook_ssrf_rejected_total (SSRF guard counter) - nsi_webhook_delivery_duration_seconds (timer) Headers отправляемые receiver'у: - Content-Type: application/json - User-Agent: Ordinis-Webhook/1.0 - X-Ordinis-Signature: sha256=<hex> - X-Ordinis-Event-Id: <id> - X-Ordinis-Event-Type: <event_type> - X-Ordinis-Scope: <PUBLIC|INTERNAL|RESTRICTED> Ещё впереди: REST API CRUD subscriptions (admin-only), unit tests для HmacSigner/SsrfGuard, admin-ui /webhooks page.
This commit is contained in:
+54
@@ -0,0 +1,54 @@
|
||||
package cloud.nstart.terravault.ordinis.outbox.webhook;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
/**
|
||||
* HMAC-SHA256 signing для webhook deliveries. Receiver проверяет подпись через
|
||||
* shared secret чтобы убедиться что запрос от Ordinis.
|
||||
*
|
||||
* <p>Header format: {@code X-Ordinis-Signature: sha256=<hex>}.
|
||||
*/
|
||||
public final class HmacSigner {
|
||||
|
||||
private static final String ALGORITHM = "HmacSHA256";
|
||||
public static final String HEADER_NAME = "X-Ordinis-Signature";
|
||||
|
||||
private HmacSigner() {}
|
||||
|
||||
/**
|
||||
* Подписать body shared secret'ом. Возвращает значение для header
|
||||
* (с префиксом {@code sha256=}).
|
||||
*
|
||||
* @param secret hex-encoded HMAC secret (64 chars = 32 bytes)
|
||||
* @param body raw bytes тела запроса
|
||||
* @return {@code sha256=<hex>}
|
||||
*/
|
||||
public static String sign(String secret, byte[] body) {
|
||||
if (secret == null || secret.isBlank()) {
|
||||
throw new IllegalArgumentException("secret must not be empty");
|
||||
}
|
||||
try {
|
||||
Mac mac = Mac.getInstance(ALGORITHM);
|
||||
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
|
||||
mac.init(new SecretKeySpec(keyBytes, ALGORITHM));
|
||||
byte[] hash = mac.doFinal(body);
|
||||
return "sha256=" + toHex(hash);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
// HmacSHA256 — required JDK algorithm, не должен fail
|
||||
throw new IllegalStateException("HmacSHA256 unavailable", e);
|
||||
} catch (java.security.InvalidKeyException e) {
|
||||
throw new IllegalArgumentException("invalid HMAC key", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String toHex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package cloud.nstart.terravault.ordinis.outbox.webhook;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* SSRF (Server-Side Request Forgery) guard для webhook URLs. Блокирует
|
||||
* delivery на private CIDR (10/8, 172.16/12, 192.168/16, 127/8, 169.254/16).
|
||||
*
|
||||
* <p>Без этой защиты пользователь с правом создать subscription мог бы
|
||||
* получить blind-SSRF против:
|
||||
* <ul>
|
||||
* <li>cloud metadata service (169.254.169.254 — AWS, GCP, Yandex Cloud)
|
||||
* <li>internal admin UI / Vault / etcd на 127.0.0.1
|
||||
* <li>kubelet API на 10.x внутри pod network
|
||||
* </ul>
|
||||
*
|
||||
* <p>Allowlist hosts через config — для testing с echo-сервером в
|
||||
* ordinis-staging кластере.
|
||||
*/
|
||||
public final class SsrfGuard {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SsrfGuard.class);
|
||||
|
||||
/** Hosts которые ВСЕГДА разрешены (даже если резолвятся в private CIDR). */
|
||||
private final Set<String> allowedHosts;
|
||||
|
||||
public SsrfGuard(List<String> allowedHosts) {
|
||||
this.allowedHosts = allowedHosts == null ? Set.of() : Set.copyOf(allowedHosts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить что URL безопасен для outbound webhook delivery.
|
||||
*
|
||||
* @return null если OK, иначе reason почему URL отклонён
|
||||
*/
|
||||
public String validate(String urlString) {
|
||||
URI uri;
|
||||
try {
|
||||
uri = URI.create(urlString);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return "Malformed URL: " + e.getMessage();
|
||||
}
|
||||
|
||||
String scheme = uri.getScheme();
|
||||
if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) {
|
||||
return "URL scheme must be http or https, got: " + scheme;
|
||||
}
|
||||
|
||||
String host = uri.getHost();
|
||||
if (host == null || host.isBlank()) {
|
||||
return "URL must have a host";
|
||||
}
|
||||
|
||||
// Allowlist override: внутренний echo-server, тестовые URLs
|
||||
if (allowedHosts.contains(host.toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// DNS resolution: получаем все A-records, проверяем все на private CIDR.
|
||||
// Если хотя бы один в private — отказ (защита от DNS rebinding атаки
|
||||
// тоже минимизирована, т.к. HttpClient может приконнектиться на
|
||||
// другой адрес позже; это best-effort).
|
||||
InetAddress[] addresses;
|
||||
try {
|
||||
addresses = InetAddress.getAllByName(host);
|
||||
} catch (UnknownHostException e) {
|
||||
return "DNS resolution failed: " + e.getMessage();
|
||||
}
|
||||
|
||||
for (InetAddress addr : addresses) {
|
||||
if (isPrivate(addr)) {
|
||||
log.warn("SSRF guard rejected URL {} → {} (private CIDR)", urlString, addr.getHostAddress());
|
||||
return "Address resolves to private/loopback range: " + addr.getHostAddress();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Private/special-use ranges per RFC 1918, RFC 3927, RFC 4193, RFC 5735.
|
||||
* Loopback, link-local, multicast, broadcast, IPv6 unique-local — все в bucket.
|
||||
*/
|
||||
static boolean isPrivate(InetAddress addr) {
|
||||
if (addr.isAnyLocalAddress()) return true;
|
||||
if (addr.isLoopbackAddress()) return true; // 127.0.0.0/8, ::1
|
||||
if (addr.isLinkLocalAddress()) return true; // 169.254/16, fe80::/10
|
||||
if (addr.isSiteLocalAddress()) return true; // 10/8, 172.16/12, 192.168/16
|
||||
if (addr.isMulticastAddress()) return true; // 224/4
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
package cloud.nstart.terravault.ordinis.outbox.webhook;
|
||||
|
||||
import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEvent;
|
||||
import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEventRepository;
|
||||
import cloud.nstart.terravault.ordinis.domain.webhook.WebhookDelivery;
|
||||
import cloud.nstart.terravault.ordinis.domain.webhook.WebhookDeliveryRepository;
|
||||
import cloud.nstart.terravault.ordinis.domain.webhook.WebhookSubscription;
|
||||
import cloud.nstart.terravault.ordinis.domain.webhook.WebhookSubscriptionRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpRequest.BodyPublishers;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.net.http.HttpResponse.BodyHandlers;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Webhook dispatcher: HTTP callback delivery параллельно с Kafka.
|
||||
*
|
||||
* <p>Два @Scheduled tick'а:
|
||||
* <ol>
|
||||
* <li><b>matchTick</b>: scan recent unpublished outbox_events × active
|
||||
* subscriptions. INSERT webhook_deliveries (idempotent ON CONFLICT) для
|
||||
* каждого match. Пишет PENDING rows.
|
||||
* <li><b>sendTick</b>: scan due deliveries (status pending/retrying AND
|
||||
* next_attempt_at <= now). Загружает event payload, signs HMAC, POSTs
|
||||
* URL. Mark delivered (2xx) / retrying (backoff) / dlq (после 1000 attempts).
|
||||
* </ol>
|
||||
*
|
||||
* <p>Активируется только при {@code ordinis.webhook.enabled=true}.
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "ordinis.webhook.enabled", havingValue = "true", matchIfMissing = false)
|
||||
public class WebhookDispatcher {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WebhookDispatcher.class);
|
||||
|
||||
private final OutboxEventRepository outboxRepository;
|
||||
private final WebhookSubscriptionRepository subscriptionRepository;
|
||||
private final WebhookDeliveryRepository deliveryRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final HttpClient httpClient;
|
||||
private SsrfGuard ssrfGuard;
|
||||
|
||||
private final Counter deliveredCounter;
|
||||
private final Counter failedCounter;
|
||||
private final Counter dlqCounter;
|
||||
private final Counter ssrfRejectedCounter;
|
||||
private final Timer deliveryTimer;
|
||||
|
||||
@Value("${ordinis.webhook.batch-size:50}")
|
||||
private int batchSize;
|
||||
|
||||
@Value("${ordinis.webhook.send-timeout-ms:5000}")
|
||||
private long sendTimeoutMs;
|
||||
|
||||
@Value("${ordinis.webhook.allowed-hosts:}")
|
||||
private String allowedHostsCsv;
|
||||
|
||||
public WebhookDispatcher(
|
||||
OutboxEventRepository outboxRepository,
|
||||
WebhookSubscriptionRepository subscriptionRepository,
|
||||
WebhookDeliveryRepository deliveryRepository,
|
||||
ObjectMapper objectMapper,
|
||||
MeterRegistry meterRegistry) {
|
||||
this.outboxRepository = outboxRepository;
|
||||
this.subscriptionRepository = subscriptionRepository;
|
||||
this.deliveryRepository = deliveryRepository;
|
||||
this.objectMapper = objectMapper;
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(3))
|
||||
.build();
|
||||
|
||||
this.deliveredCounter = Counter.builder("nsi_webhook_delivered_total")
|
||||
.description("Successful webhook deliveries (2xx response)")
|
||||
.register(meterRegistry);
|
||||
this.failedCounter = Counter.builder("nsi_webhook_failed_total")
|
||||
.description("Failed webhook delivery attempts (will retry)")
|
||||
.register(meterRegistry);
|
||||
this.dlqCounter = Counter.builder("nsi_webhook_dlq_total")
|
||||
.description("Webhook deliveries moved to DLQ (attempt cap reached)")
|
||||
.register(meterRegistry);
|
||||
this.ssrfRejectedCounter = Counter.builder("nsi_webhook_ssrf_rejected_total")
|
||||
.description("Webhook URLs rejected by SSRF guard")
|
||||
.register(meterRegistry);
|
||||
this.deliveryTimer = Timer.builder("nsi_webhook_delivery_duration_seconds")
|
||||
.description("HTTP POST duration for webhook delivery")
|
||||
.register(meterRegistry);
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void initSsrfGuard() {
|
||||
List<String> allowed = allowedHostsCsv == null || allowedHostsCsv.isBlank()
|
||||
? List.of()
|
||||
: Arrays.stream(allowedHostsCsv.split(",")).map(String::trim).toList();
|
||||
this.ssrfGuard = new SsrfGuard(allowed);
|
||||
log.info("WebhookDispatcher initialized. Allowed hosts: {}", allowed);
|
||||
}
|
||||
|
||||
// === Tick 1: matcher ===
|
||||
|
||||
/**
|
||||
* Scan recent unpublished events × active subscriptions, INSERT pending
|
||||
* deliveries. Idempotent: ON CONFLICT (subscription_id, outbox_event_id)
|
||||
* DO NOTHING.
|
||||
*
|
||||
* <p>Note: WebhookDispatcher работает по same outbox_events что и
|
||||
* Kafka publisher. Event попадает в дисптчер до того как Kafka publisher
|
||||
* marks published_at — это нормально, мы matching по существованию
|
||||
* row, не по published status.
|
||||
*/
|
||||
@Scheduled(fixedDelayString = "${ordinis.webhook.match-interval-ms:2000}")
|
||||
@Transactional
|
||||
public void matchTick() {
|
||||
List<WebhookSubscription> subs = subscriptionRepository.findActive();
|
||||
if (subs.isEmpty()) return;
|
||||
|
||||
// Берём recent events (последние 1000 самых новых). Старше — уже либо
|
||||
// published в Kafka (значит и сюда match'нулись если был active sub
|
||||
// в момент создания), либо в DLQ. Поллер не нужен для catch-up.
|
||||
List<OutboxEvent> recent = outboxRepository
|
||||
.findUnpublished(PageRequest.of(0, 1000));
|
||||
|
||||
for (OutboxEvent event : recent) {
|
||||
for (WebhookSubscription sub : subs) {
|
||||
if (matches(event, sub)) {
|
||||
deliveryRepository.insertIfAbsent(sub.getId(), event.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static boolean matches(OutboxEvent event, WebhookSubscription sub) {
|
||||
if (sub.getScopeFilter() != null && !sub.getScopeFilter().isEmpty()
|
||||
&& !sub.getScopeFilter().contains(event.getDataScope())) {
|
||||
return false;
|
||||
}
|
||||
if (sub.getDictionaryFilter() != null && !sub.getDictionaryFilter().isEmpty()
|
||||
&& (event.getDictionaryName() == null
|
||||
|| !sub.getDictionaryFilter().contains(event.getDictionaryName()))) {
|
||||
return false;
|
||||
}
|
||||
if (sub.getEventTypeFilter() != null && !sub.getEventTypeFilter().isEmpty()
|
||||
&& !sub.getEventTypeFilter().contains(event.getEventType())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// === Tick 2: sender ===
|
||||
|
||||
@Scheduled(fixedDelayString = "${ordinis.webhook.send-interval-ms:1000}")
|
||||
public void sendTick() {
|
||||
List<WebhookDelivery> due = deliveryRepository.findDue(
|
||||
OffsetDateTime.now(), PageRequest.of(0, batchSize));
|
||||
if (due.isEmpty()) return;
|
||||
|
||||
log.debug("Webhook sendTick: {} due deliveries", due.size());
|
||||
|
||||
for (WebhookDelivery delivery : due) {
|
||||
attemptDelivery(delivery);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Одна попытка доставки. @Transactional чтобы изменения delivery атомарны.
|
||||
* SELF-call через bean reference не делаем — каждая итерация в своей tx
|
||||
* через wrapper public method.
|
||||
*/
|
||||
@Transactional
|
||||
void attemptDelivery(WebhookDelivery delivery) {
|
||||
Optional<WebhookSubscription> subOpt = subscriptionRepository.findById(delivery.getSubscriptionId());
|
||||
if (subOpt.isEmpty() || !subOpt.get().isActive()) {
|
||||
// Subscription deleted/disabled между match и send. Mark dlq чтобы не
|
||||
// тратить попытки впустую (admin может resetDlq если subscription
|
||||
// вернётся).
|
||||
delivery.markFailed("Subscription not found or inactive", null);
|
||||
deliveryRepository.save(delivery);
|
||||
return;
|
||||
}
|
||||
WebhookSubscription sub = subOpt.get();
|
||||
|
||||
Optional<OutboxEvent> eventOpt = outboxRepository.findById(delivery.getOutboxEventId());
|
||||
if (eventOpt.isEmpty()) {
|
||||
delivery.markFailed("Outbox event not found", null);
|
||||
deliveryRepository.save(delivery);
|
||||
return;
|
||||
}
|
||||
OutboxEvent event = eventOpt.get();
|
||||
|
||||
// SSRF guard: блокируем delivery на private CIDR.
|
||||
String ssrfReason = ssrfGuard.validate(sub.getUrl());
|
||||
if (ssrfReason != null) {
|
||||
ssrfRejectedCounter.increment();
|
||||
delivery.markFailed("SSRF guard: " + ssrfReason, null);
|
||||
if (delivery.isDlq()) dlqCounter.increment();
|
||||
deliveryRepository.save(delivery);
|
||||
return;
|
||||
}
|
||||
|
||||
Timer.Sample sample = Timer.start();
|
||||
try {
|
||||
byte[] body = objectMapper.writeValueAsBytes(event.getPayload());
|
||||
String signature = HmacSigner.sign(sub.getHmacSecret(), body);
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(sub.getUrl()))
|
||||
.timeout(Duration.ofMillis(sendTimeoutMs))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("User-Agent", "Ordinis-Webhook/1.0")
|
||||
.header(HmacSigner.HEADER_NAME, signature)
|
||||
.header("X-Ordinis-Event-Id", String.valueOf(event.getId()))
|
||||
.header("X-Ordinis-Event-Type", event.getEventType())
|
||||
.header("X-Ordinis-Scope", event.getDataScope().name())
|
||||
.POST(BodyPublishers.ofByteArray(body))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> resp = httpClient.send(req, BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
sample.stop(deliveryTimer);
|
||||
|
||||
if (resp.statusCode() >= 200 && resp.statusCode() < 300) {
|
||||
delivery.markDelivered(resp.statusCode());
|
||||
deliveredCounter.increment();
|
||||
} else {
|
||||
// 4xx — клиент скорее всего не починится, но стандартный retry
|
||||
// (admin может deactivate subscription). 5xx — transient.
|
||||
String body2xx = resp.body() == null ? "" : resp.body();
|
||||
String error = "HTTP " + resp.statusCode() + ": "
|
||||
+ body2xx.substring(0, Math.min(200, body2xx.length()));
|
||||
delivery.markFailed(error, resp.statusCode());
|
||||
failedCounter.increment();
|
||||
if (delivery.isDlq()) dlqCounter.increment();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
delivery.markFailed("Interrupted: " + e.getMessage(), null);
|
||||
failedCounter.increment();
|
||||
} catch (Exception e) {
|
||||
delivery.markFailed(
|
||||
e.getClass().getSimpleName() + ": " + e.getMessage(), null);
|
||||
failedCounter.increment();
|
||||
if (delivery.isDlq()) dlqCounter.increment();
|
||||
}
|
||||
deliveryRepository.save(delivery);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user