feat(webhook): Phase 3 — REST API CRUD + HMAC/SSRF tests
REST API endpoints под /api/v1/admin/webhooks/:
- GET /subscriptions — list (INTERNAL+ scope)
- POST /subscriptions — create (RESTRICTED scope)
- GET /subscriptions/{id} — get one (INTERNAL+)
- PUT /subscriptions/{id} — update (RESTRICTED)
- DELETE /subscriptions/{id} — delete (RESTRICTED)
- POST /subscriptions/{id}/rotate-secret — regenerate HMAC (RESTRICTED)
- GET /subscriptions/{id}/deliveries — delivery history per sub
- GET /deliveries/dlq — DLQ listing
RBAC через ScopeContext: RESTRICTED для mutating, INTERNAL+ для read.
PUBLIC scope получает 403.
DTOs:
- CreateWebhookSubscriptionRequest (Bean Validation: URL regex, length)
- WebhookSubscriptionResponse (с factory `from()` маскированный secret
vs `fromWithSecret()` plaintext только для create response)
- WebhookDeliveryResponse
Service:
- HMAC secret 32 random bytes → 64 hex chars через SecureRandom
- Plaintext secret виден только в create/rotate response (single-time)
- list/get показывают `sk_****<last4>` masked
Tests (15 SsrfGuard + 8 HmacSigner = 23 new):
- HmacSigner: known vector verification, prefix, hex length, distinct
inputs produce distinct sigs, empty secret rejected
- SsrfGuard: rejects 127/8, 169.254/16, 10/8, 192.168/16, 0.0.0.0,
non-http schemes, missing host, unresolvable DNS. Allowlist bypass.
isPrivate() unit tests для loopback/link-local/site-local/public IP.
Total: 109 → 132 unit tests.
Phase 4 далее: admin-ui /webhooks page (list + create + delivery history).
This commit is contained in:
+73
@@ -0,0 +1,73 @@
|
||||
package cloud.nstart.terravault.ordinis.outbox.webhook;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class HmacSignerTest {
|
||||
|
||||
// Reference value computed via:
|
||||
// echo -n "hello" | openssl dgst -sha256 -hmac "secret"
|
||||
// = 88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b
|
||||
private static final String KNOWN_SECRET = "secret";
|
||||
private static final String KNOWN_BODY = "hello";
|
||||
private static final String KNOWN_SIG = "sha256=88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b";
|
||||
|
||||
@Test
|
||||
void signsKnownVectorCorrectly() {
|
||||
String sig = HmacSigner.sign(KNOWN_SECRET, KNOWN_BODY.getBytes(StandardCharsets.UTF_8));
|
||||
assertThat(sig).isEqualTo(KNOWN_SIG);
|
||||
}
|
||||
|
||||
@Test
|
||||
void signaturePrefixedWithSha256() {
|
||||
String sig = HmacSigner.sign("any-secret", "body".getBytes());
|
||||
assertThat(sig).startsWith("sha256=");
|
||||
}
|
||||
|
||||
@Test
|
||||
void hexLengthIs64Chars() {
|
||||
String sig = HmacSigner.sign("any-secret", "body".getBytes());
|
||||
// "sha256=" = 7 chars + 64 hex = 71
|
||||
assertThat(sig).hasSize(71);
|
||||
assertThat(sig.substring(7)).matches("[0-9a-f]{64}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentBodyProducesDifferentSignature() {
|
||||
String sig1 = HmacSigner.sign("secret", "body1".getBytes());
|
||||
String sig2 = HmacSigner.sign("secret", "body2".getBytes());
|
||||
assertThat(sig1).isNotEqualTo(sig2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentSecretProducesDifferentSignature() {
|
||||
String sig1 = HmacSigner.sign("secret-a", "body".getBytes());
|
||||
String sig2 = HmacSigner.sign("secret-b", "body".getBytes());
|
||||
assertThat(sig1).isNotEqualTo(sig2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptySecretRejected() {
|
||||
assertThatThrownBy(() -> HmacSigner.sign("", "body".getBytes()))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> HmacSigner.sign(null, "body".getBytes()))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyBodySignsCleanly() {
|
||||
// Empty body must produce a deterministic signature, not throw
|
||||
String sig = HmacSigner.sign("secret", new byte[0]);
|
||||
assertThat(sig).startsWith("sha256=");
|
||||
assertThat(sig.substring(7)).matches("[0-9a-f]{64}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void headerNameConstant() {
|
||||
assertThat(HmacSigner.HEADER_NAME).isEqualTo("X-Ordinis-Signature");
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package cloud.nstart.terravault.ordinis.outbox.webhook;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* SSRF guard тесты. Часть тестов используют InetAddress.getByName() с
|
||||
* IP literal — DNS не нужен. Тесты с hostname (e.g. example.com) не
|
||||
* запускаем чтобы CI работал офлайн.
|
||||
*/
|
||||
class SsrfGuardTest {
|
||||
|
||||
private final SsrfGuard guard = new SsrfGuard(List.of("trusted.internal.test"));
|
||||
|
||||
@Test
|
||||
void rejectsLoopbackIp() {
|
||||
String reason = guard.validate("http://127.0.0.1:8080/hook");
|
||||
assertThat(reason).contains("private/loopback");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsLinkLocalIp() {
|
||||
String reason = guard.validate("http://169.254.169.254/latest/meta-data");
|
||||
assertThat(reason).contains("private/loopback");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsRfc1918Ip10() {
|
||||
String reason = guard.validate("http://10.0.0.1/hook");
|
||||
assertThat(reason).contains("private/loopback");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsRfc1918Ip192_168() {
|
||||
String reason = guard.validate("http://192.168.100.161/hook");
|
||||
assertThat(reason).contains("private/loopback");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnyLocalIp() {
|
||||
String reason = guard.validate("http://0.0.0.0/hook");
|
||||
assertThat(reason).contains("private/loopback");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonHttpScheme() {
|
||||
String reason = guard.validate("ftp://example.com/hook");
|
||||
assertThat(reason).contains("URL scheme must be http or https");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingHost() {
|
||||
String reason = guard.validate("http:///hook");
|
||||
assertThat(reason).contains("must have a host");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnresolvableHost() {
|
||||
String reason = guard.validate("http://this-host-must-not-exist-ordinis-test.invalid/");
|
||||
assertThat(reason).contains("DNS resolution failed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowedHostsBypassPrivateCheck() {
|
||||
// trusted.internal.test в allowlist — даже если резолвится в private, OK
|
||||
SsrfGuard guardWithAllow = new SsrfGuard(List.of("127.0.0.1"));
|
||||
// Подаём 127.0.0.1 как hostname — но literal IP всё равно private check
|
||||
// НО allowlist обрабатывается ПЕРЕД resolution через host string match,
|
||||
// не IP. Если URL hostname == allowlist entry, OK.
|
||||
String reason = guardWithAllow.validate("http://127.0.0.1/hook");
|
||||
assertThat(reason).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyAllowlistDefaultsToBlockingPrivate() {
|
||||
SsrfGuard guardNone = new SsrfGuard(List.of());
|
||||
assertThat(guardNone.validate("http://127.0.0.1/")).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullAllowlistTreatedAsEmpty() {
|
||||
SsrfGuard guardNull = new SsrfGuard(null);
|
||||
assertThat(guardNull.validate("http://127.0.0.1/")).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isPrivateRecognizesLoopback() throws UnknownHostException {
|
||||
assertThat(SsrfGuard.isPrivate(InetAddress.getByName("127.0.0.1"))).isTrue();
|
||||
assertThat(SsrfGuard.isPrivate(InetAddress.getByName("::1"))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isPrivateRecognizesLinkLocal() throws UnknownHostException {
|
||||
assertThat(SsrfGuard.isPrivate(InetAddress.getByName("169.254.0.1"))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isPrivateRecognizesSiteLocal() throws UnknownHostException {
|
||||
assertThat(SsrfGuard.isPrivate(InetAddress.getByName("10.0.0.1"))).isTrue();
|
||||
assertThat(SsrfGuard.isPrivate(InetAddress.getByName("172.16.0.1"))).isTrue();
|
||||
assertThat(SsrfGuard.isPrivate(InetAddress.getByName("192.168.1.1"))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isPrivateAllowsPublicIp() throws UnknownHostException {
|
||||
// 1.1.1.1 = Cloudflare DNS, public
|
||||
assertThat(SsrfGuard.isPrivate(InetAddress.getByName("1.1.1.1"))).isFalse();
|
||||
// 8.8.8.8 = Google DNS, public
|
||||
assertThat(SsrfGuard.isPrivate(InetAddress.getByName("8.8.8.8"))).isFalse();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user