From 5ebc729b8277a6a33941a811141000100942ef7b Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:17:44 +0200 Subject: [PATCH] feat(alerting): SSRF guard on outbound connection URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rejects webhook URLs that resolve to loopback, link-local, or RFC-1918 private ranges (IPv4 + IPv6 ULA fc00::/7). Enforced on both create and update in OutboundConnectionServiceImpl before persistence; returns 400 Bad Request with "private or loopback" in the body. Bypass via `cameleer.server.outbound-http.allow-private-targets=true` for dev environments where webhooks legitimately point at local services. Production default is `false`. Test profile sets the flag to `true` in application-test.yml so the existing ITs that post webhooks to WireMock on https://localhost:PORT keep working. A dedicated OutboundConnectionSsrfIT overrides the flag back to false (via @TestPropertySource + @DirtiesContext) to exercise the reject path end-to-end through the admin controller. Plan 01 scope; required before SaaS exposure (spec §17). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../OutboundConnectionServiceImpl.java | 26 +++++++ .../server/app/outbound/SsrfGuard.java | 69 ++++++++++++++++++ .../outbound/config/OutboundBeanConfig.java | 4 +- .../server/app/outbound/SsrfGuardTest.java | 73 +++++++++++++++++++ .../controller/OutboundConnectionSsrfIT.java | 67 +++++++++++++++++ .../src/test/resources/application-test.yml | 2 + 6 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/SsrfGuard.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/SsrfGuardTest.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionSsrfIT.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java index 328a68e6..81d6719f 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java @@ -7,6 +7,8 @@ import com.cameleer.server.core.outbound.OutboundConnectionService; import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; +import java.net.URI; +import java.net.URISyntaxException; import java.time.Instant; import java.util.List; import java.util.UUID; @@ -15,20 +17,24 @@ public class OutboundConnectionServiceImpl implements OutboundConnectionService private final OutboundConnectionRepository repo; private final AlertRuleRepository ruleRepo; + private final SsrfGuard ssrfGuard; private final String tenantId; public OutboundConnectionServiceImpl( OutboundConnectionRepository repo, AlertRuleRepository ruleRepo, + SsrfGuard ssrfGuard, String tenantId) { this.repo = repo; this.ruleRepo = ruleRepo; + this.ssrfGuard = ssrfGuard; this.tenantId = tenantId; } @Override public OutboundConnection create(OutboundConnection draft, String actingUserId) { assertNameUnique(draft.name(), null); + validateUrl(draft.url()); OutboundConnection c = new OutboundConnection( UUID.randomUUID(), tenantId, draft.name(), draft.description(), draft.url(), draft.method(), draft.defaultHeaders(), draft.defaultBodyTmpl(), @@ -46,6 +52,7 @@ public class OutboundConnectionServiceImpl implements OutboundConnectionService if (!existing.name().equals(draft.name())) { assertNameUnique(draft.name(), id); } + validateUrl(draft.url()); // Narrowing allowed-envs guard: if the new draft restricts to a non-empty set of envs, // find any envs that existed before but are absent in the draft. @@ -107,4 +114,23 @@ public class OutboundConnectionServiceImpl implements OutboundConnectionService } }); } + + /** + * Validate the webhook URL against SSRF pitfalls. Translates the guard's + * {@link IllegalArgumentException} into a 400 Bad Request with the guard's + * message preserved, so the client sees e.g. "private or loopback". + */ + private void validateUrl(String url) { + URI uri; + try { + uri = new URI(url); + } catch (URISyntaxException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid URL: " + url); + } + try { + ssrfGuard.validate(uri); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage(), e); + } + } } diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/SsrfGuard.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/SsrfGuard.java new file mode 100644 index 00000000..557f6f19 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/SsrfGuard.java @@ -0,0 +1,69 @@ +package com.cameleer.server.app.outbound; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.URI; +import java.net.UnknownHostException; + +/** + * Validates outbound webhook URLs against SSRF pitfalls: rejects hosts that resolve to + * loopback, link-local, or RFC-1918 private ranges (and IPv6 equivalents). + * + * Per spec §17. The `cameleer.server.outbound-http.allow-private-targets` flag bypasses + * the check for dev environments where webhooks legitimately point at local services. + */ +@Component +public class SsrfGuard { + + private final boolean allowPrivate; + + public SsrfGuard( + @Value("${cameleer.server.outbound-http.allow-private-targets:false}") boolean allowPrivate + ) { + this.allowPrivate = allowPrivate; + } + + public void validate(URI uri) { + if (allowPrivate) return; + String host = uri.getHost(); + if (host == null || host.isBlank()) { + throw new IllegalArgumentException("URL must include a host: " + uri); + } + if ("localhost".equalsIgnoreCase(host)) { + throw new IllegalArgumentException("URL host resolves to private or loopback range: " + host); + } + InetAddress[] addrs; + try { + addrs = InetAddress.getAllByName(host); + } catch (UnknownHostException e) { + throw new IllegalArgumentException("URL host does not resolve: " + host, e); + } + for (InetAddress addr : addrs) { + if (isPrivate(addr)) { + throw new IllegalArgumentException("URL host resolves to private or loopback range: " + host + " -> " + addr.getHostAddress()); + } + } + } + + private static boolean isPrivate(InetAddress addr) { + if (addr.isLoopbackAddress()) return true; + if (addr.isLinkLocalAddress()) return true; + if (addr.isSiteLocalAddress()) return true; // 10/8, 172.16/12, 192.168/16 + if (addr.isAnyLocalAddress()) return true; // 0.0.0.0, :: + if (addr instanceof Inet6Address ip6) { + byte[] raw = ip6.getAddress(); + // fc00::/7 unique-local + if ((raw[0] & 0xfe) == 0xfc) return true; + } + if (addr instanceof Inet4Address ip4) { + byte[] raw = ip4.getAddress(); + // 169.254.0.0/16 link-local (also matches isLinkLocalAddress but doubled-up for safety) + if ((raw[0] & 0xff) == 169 && (raw[1] & 0xff) == 254) return true; + } + return false; + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java index bea1fab5..6c8a2182 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java @@ -1,6 +1,7 @@ package com.cameleer.server.app.outbound.config; import com.cameleer.server.app.outbound.OutboundConnectionServiceImpl; +import com.cameleer.server.app.outbound.SsrfGuard; import com.cameleer.server.app.outbound.crypto.SecretCipher; import com.cameleer.server.app.outbound.storage.PostgresOutboundConnectionRepository; import com.cameleer.server.core.alerting.AlertRuleRepository; @@ -31,7 +32,8 @@ public class OutboundBeanConfig { public OutboundConnectionService outboundConnectionService( OutboundConnectionRepository repo, AlertRuleRepository ruleRepo, + SsrfGuard ssrfGuard, @Value("${cameleer.server.tenant.id:default}") String tenantId) { - return new OutboundConnectionServiceImpl(repo, ruleRepo, tenantId); + return new OutboundConnectionServiceImpl(repo, ruleRepo, ssrfGuard, tenantId); } } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/SsrfGuardTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/SsrfGuardTest.java new file mode 100644 index 00000000..9614d0c6 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/SsrfGuardTest.java @@ -0,0 +1,73 @@ +package com.cameleer.server.app.outbound; + +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SsrfGuardTest { + + private final SsrfGuard guard = new SsrfGuard(false); // allow-private disabled by default + + @Test + void rejectsLoopbackIpv4() { + assertThatThrownBy(() -> guard.validate(URI.create("https://127.0.0.1/webhook"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("private or loopback"); + } + + @Test + void rejectsLocalhostHostname() { + assertThatThrownBy(() -> guard.validate(URI.create("https://localhost:8080/x"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsRfc1918Ranges() { + for (String url : Set.of( + "https://10.0.0.1/x", + "https://172.16.5.6/x", + "https://192.168.1.1/x" + )) { + assertThatThrownBy(() -> guard.validate(URI.create(url))) + .as(url) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Test + void rejectsLinkLocal() { + assertThatThrownBy(() -> guard.validate(URI.create("https://169.254.169.254/latest/meta-data/"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsIpv6Loopback() { + assertThatThrownBy(() -> guard.validate(URI.create("https://[::1]/x"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsIpv6UniqueLocal() { + assertThatThrownBy(() -> guard.validate(URI.create("https://[fc00::1]/x"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void acceptsPublicHttps() { + // DNS resolution happens inside validate(); this test relies on a public hostname. + // Use a literal public IP to avoid network flakiness. + // 8.8.8.8 is a public Google DNS IP — not in any private range. + assertThat(new SsrfGuard(false)).isNotNull(); + guard.validate(URI.create("https://8.8.8.8/")); // does not throw + } + + @Test + void allowPrivateFlagBypassesCheck() { + SsrfGuard permissive = new SsrfGuard(true); + permissive.validate(URI.create("https://127.0.0.1/")); // must not throw + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionSsrfIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionSsrfIT.java new file mode 100644 index 00000000..6f791b8d --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionSsrfIT.java @@ -0,0 +1,67 @@ +package com.cameleer.server.app.outbound.controller; + +import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.app.TestSecurityHelper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Dedicated IT that overrides the test-profile default `allow-private-targets=true` + * back to `false` so the SSRF guard's production behavior (reject loopback) is + * exercised end-to-end through the admin controller. + * + * Uses {@link DirtiesContext} to avoid polluting the shared context used by the + * other ITs which rely on the flag being `true` to hit WireMock on localhost. + */ +@TestPropertySource(properties = "cameleer.server.outbound-http.allow-private-targets=false") +@DirtiesContext +class OutboundConnectionSsrfIT extends AbstractPostgresIT { + + @Autowired private TestRestTemplate restTemplate; + @Autowired private TestSecurityHelper securityHelper; + + private String adminJwt; + + @BeforeEach + void setUp() { + adminJwt = securityHelper.adminToken(); + // Seed admin user row since users(user_id) is an FK target. + jdbcTemplate.update( + "INSERT INTO users (user_id, provider, email, display_name) VALUES (?, 'test', ?, ?) ON CONFLICT (user_id) DO NOTHING", + "test-admin", "test-admin@example.com", "test-admin"); + jdbcTemplate.update("DELETE FROM outbound_connections WHERE tenant_id = 'default'"); + } + + @AfterEach + void cleanup() { + jdbcTemplate.update("DELETE FROM outbound_connections WHERE tenant_id = 'default'"); + jdbcTemplate.update("DELETE FROM users WHERE user_id = 'test-admin'"); + } + + @Test + void rejectsLoopbackUrlOnCreate() { + String body = """ + {"name":"evil","url":"https://127.0.0.1/abuse","method":"POST", + "tlsTrustMode":"SYSTEM_DEFAULT","auth":{}}"""; + + ResponseEntity resp = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.POST, + new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)), + String.class); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(resp.getBody()).isNotNull(); + assertThat(resp.getBody()).contains("private or loopback"); + } +} diff --git a/cameleer-server-app/src/test/resources/application-test.yml b/cameleer-server-app/src/test/resources/application-test.yml index ce02814a..ec845496 100644 --- a/cameleer-server-app/src/test/resources/application-test.yml +++ b/cameleer-server-app/src/test/resources/application-test.yml @@ -17,3 +17,5 @@ cameleer: bootstraptokenprevious: old-bootstrap-token infrastructureendpoints: true jwtsecret: test-jwt-secret-for-integration-tests-only + outbound-http: + allow-private-targets: true