feat(webhook): test ping — admin верифицирует receiver одной кнопкой

После создания subscription / rotate secret admin не имел способа
проверить что receiver жив и валидирует HMAC без ожидания реального
business event. Теперь:

- POST /api/v1/admin/webhooks/subscriptions/{id}/test
- Synchronous: receiver получает synthetic event с теми же headers
  что у production delivery (X-Ordinis-Signature, X-Ordinis-Event-Type,
  X-Ordinis-Scope) + дополнительный X-Ordinis-Test=true чтобы
  receiver мог логировать/филтровать как тест.
- HMAC sign'ится через тот же HmacSigner — admin раскопировал secret
  правильно если receiver валидирует. Иначе receiver вернёт 4xx
  и UI покажет ошибку.
- SSRF guard validate URL host (private CIDR + allowed-hosts override)
  — same policy как у dispatcher.

Bypass'ит outbox/dispatcher — нет webhook_deliveries row, нет attempt
count, нет следов в аудите доставок (это test, не business event).

UI:
- Кнопка 'Тест ping' с PaperPlaneTilt иконкой рядом с 'Сменить secret'
  на webhook detail page.
- Inline Alert после ответа: success/failure/rejected с HTTP status,
  latency, body preview / error message. Dismiss '✕'.

RBAC: RESTRICTED — endpoint отправляет HTTP request на user-controlled
URL (potential abuse vector если бы был INTERNAL).

Tests: rest-api compiles, 76 admin-ui passed.
This commit is contained in:
Zimin A.N.
2026-05-06 20:59:47 +03:00
parent a2d5df2430
commit 66e89ce707
6 changed files with 304 additions and 0 deletions
@@ -0,0 +1,163 @@
package cloud.nstart.terravault.ordinis.restapi.service.webhook;
import cloud.nstart.terravault.ordinis.domain.webhook.WebhookSubscription;
import cloud.nstart.terravault.ordinis.outbox.webhook.HmacSigner;
import cloud.nstart.terravault.ordinis.outbox.webhook.SsrfGuard;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
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.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Synchronous test ping для webhook subscription. Admin кликает кнопку в UI →
* receiver получает synthetic event ровно с теми же headers/HMAC что и
* production delivery, ответ возвращается сразу для inline feedback.
*
* <p>Use case: после создания subscription verify что:
* <ul>
* <li>URL достижим (DNS, network, TLS)
* <li>Receiver не отвергает HMAC signature (admin раскопировал secret правильно)
* <li>Receiver обрабатывает payload без 5xx
* </ul>
*
* <p>Bypass'ит outbox/dispatcher — sync request, immediate response. Не
* порождает webhook_deliveries row (test event, не реальный business event).
*
* <p>SSRF guard validate'ит URL host против allowed-hosts list (override) +
* private CIDR check. Тот же что у dispatcher — единая security policy.
*/
@Service
public class WebhookTestPingService {
private static final Logger log = LoggerFactory.getLogger(WebhookTestPingService.class);
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3);
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10);
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
@Value("${ordinis.webhook.allowed-hosts:}")
private String allowedHostsCsv;
private SsrfGuard ssrfGuard;
public WebhookTestPingService(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(CONNECT_TIMEOUT)
.build();
}
@PostConstruct
void init() {
List<String> allowed = allowedHostsCsv == null || allowedHostsCsv.isBlank()
? List.of()
: Arrays.stream(allowedHostsCsv.split(",")).map(String::trim).toList();
this.ssrfGuard = new SsrfGuard(allowed);
}
public Result testPing(WebhookSubscription sub) {
String ssrfReason = ssrfGuard.validate(sub.getUrl());
if (ssrfReason != null) {
return Result.rejected("SSRF guard rejected URL: " + ssrfReason);
}
Map<String, Object> payload = buildSyntheticPayload(sub);
byte[] body;
try {
body = objectMapper.writeValueAsBytes(payload);
} catch (JsonProcessingException e) {
return Result.rejected("Failed to serialize test payload: " + e.getMessage());
}
String signature = HmacSigner.sign(sub.getHmacSecret(), body);
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(sub.getUrl()))
.header("Content-Type", "application/json")
.header(HmacSigner.HEADER_NAME, signature)
.header("X-Ordinis-Event-Id", "test-ping")
.header("X-Ordinis-Event-Type", "test.ping")
.header("X-Ordinis-Scope", "PUBLIC")
.header("X-Ordinis-Test", "true")
.timeout(REQUEST_TIMEOUT)
.POST(BodyPublishers.ofByteArray(body))
.build();
long start = System.nanoTime();
try {
HttpResponse<String> resp = httpClient.send(req, BodyHandlers.ofString(StandardCharsets.UTF_8));
long ms = (System.nanoTime() - start) / 1_000_000L;
int status = resp.statusCode();
String preview = trim(resp.body(), 200);
if (status >= 200 && status < 300) {
return Result.success(status, ms, preview);
}
return Result.failure(status, ms, "HTTP " + status + "" + preview);
} catch (Exception e) {
long ms = (System.nanoTime() - start) / 1_000_000L;
log.warn("Test ping to {} failed", sub.getUrl(), e);
return Result.failure(null, ms, e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
private Map<String, Object> buildSyntheticPayload(WebhookSubscription sub) {
Map<String, Object> p = new LinkedHashMap<>();
p.put("eventId", "test-ping");
p.put("eventType", "test.ping");
p.put("scope", "PUBLIC");
p.put("subscriptionId", sub.getId().toString());
p.put("subscriptionName", sub.getName());
p.put("timestamp", OffsetDateTime.now().toString());
p.put("message", "Test event from Ordinis admin. Если receiver получил — он живой и валидирует HMAC.");
return p;
}
private static String trim(String s, int max) {
if (s == null) return "";
return s.length() <= max ? s : s.substring(0, max) + "";
}
/**
* Result test ping'а. status:
* <ul>
* <li>success — HTTP 2xx
* <li>failure — HTTP non-2xx ИЛИ network exception (timeout, connection refused)
* <li>rejected — SSRF guard / serialization error, request даже не отправлен
* </ul>
*/
public record Result(
String status,
Integer httpStatus,
Long latencyMs,
String message) {
public static Result success(int httpStatus, long latencyMs, String body) {
return new Result("success", httpStatus, latencyMs, body);
}
public static Result failure(Integer httpStatus, long latencyMs, String error) {
return new Result("failure", httpStatus, latencyMs, error);
}
public static Result rejected(String reason) {
return new Result("rejected", null, null, reason);
}
}
}
@@ -11,6 +11,7 @@ import cloud.nstart.terravault.ordinis.restapi.dto.webhook.WebhookDeliveryRespon
import cloud.nstart.terravault.ordinis.restapi.dto.webhook.WebhookSubscriptionResponse;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import cloud.nstart.terravault.ordinis.restapi.service.webhook.WebhookSubscriptionService;
import cloud.nstart.terravault.ordinis.restapi.service.webhook.WebhookTestPingService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
@@ -55,16 +56,19 @@ public class WebhookSubscriptionController {
private final WebhookSubscriptionService service;
private final WebhookSubscriptionRepository subscriptionRepository;
private final WebhookDeliveryRepository deliveryRepository;
private final WebhookTestPingService testPingService;
private final ScopeContext scopeContext;
public WebhookSubscriptionController(
WebhookSubscriptionService service,
WebhookSubscriptionRepository subscriptionRepository,
WebhookDeliveryRepository deliveryRepository,
WebhookTestPingService testPingService,
ScopeContext scopeContext) {
this.service = service;
this.subscriptionRepository = subscriptionRepository;
this.deliveryRepository = deliveryRepository;
this.testPingService = testPingService;
this.scopeContext = scopeContext;
}
@@ -152,6 +156,22 @@ public class WebhookSubscriptionController {
*
* <p>RBAC: RESTRICTED — это destructive op (можно spam'ить receiver).
*/
/**
* Synchronous test ping — admin кликает кнопку в UI, receiver получает
* synthetic event с теми же headers/HMAC что у production delivery.
* Возвращает result inline для immediate feedback (latency, http status,
* body preview / error).
*
* <p>Не порождает webhook_deliveries row — это test, не business event.
* RBAC: RESTRICTED — потому что отправляет HTTP request на user-controlled URL.
*/
@PostMapping("/subscriptions/{id}/test")
public WebhookTestPingService.Result testSubscription(@PathVariable UUID id) {
requireAdmin();
WebhookSubscription sub = service.findById(id);
return testPingService.testPing(sub);
}
@PostMapping("/deliveries/{id}/retry")
public WebhookDeliveryResponse retryDelivery(@PathVariable Long id) {
requireAdmin();