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:
+163
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user