feat(alerting): SSRF guard on outbound connection URL
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) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,8 @@ import com.cameleer.server.core.outbound.OutboundConnectionService;
|
|||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URISyntaxException;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -15,20 +17,24 @@ public class OutboundConnectionServiceImpl implements OutboundConnectionService
|
|||||||
|
|
||||||
private final OutboundConnectionRepository repo;
|
private final OutboundConnectionRepository repo;
|
||||||
private final AlertRuleRepository ruleRepo;
|
private final AlertRuleRepository ruleRepo;
|
||||||
|
private final SsrfGuard ssrfGuard;
|
||||||
private final String tenantId;
|
private final String tenantId;
|
||||||
|
|
||||||
public OutboundConnectionServiceImpl(
|
public OutboundConnectionServiceImpl(
|
||||||
OutboundConnectionRepository repo,
|
OutboundConnectionRepository repo,
|
||||||
AlertRuleRepository ruleRepo,
|
AlertRuleRepository ruleRepo,
|
||||||
|
SsrfGuard ssrfGuard,
|
||||||
String tenantId) {
|
String tenantId) {
|
||||||
this.repo = repo;
|
this.repo = repo;
|
||||||
this.ruleRepo = ruleRepo;
|
this.ruleRepo = ruleRepo;
|
||||||
|
this.ssrfGuard = ssrfGuard;
|
||||||
this.tenantId = tenantId;
|
this.tenantId = tenantId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public OutboundConnection create(OutboundConnection draft, String actingUserId) {
|
public OutboundConnection create(OutboundConnection draft, String actingUserId) {
|
||||||
assertNameUnique(draft.name(), null);
|
assertNameUnique(draft.name(), null);
|
||||||
|
validateUrl(draft.url());
|
||||||
OutboundConnection c = new OutboundConnection(
|
OutboundConnection c = new OutboundConnection(
|
||||||
UUID.randomUUID(), tenantId, draft.name(), draft.description(),
|
UUID.randomUUID(), tenantId, draft.name(), draft.description(),
|
||||||
draft.url(), draft.method(), draft.defaultHeaders(), draft.defaultBodyTmpl(),
|
draft.url(), draft.method(), draft.defaultHeaders(), draft.defaultBodyTmpl(),
|
||||||
@@ -46,6 +52,7 @@ public class OutboundConnectionServiceImpl implements OutboundConnectionService
|
|||||||
if (!existing.name().equals(draft.name())) {
|
if (!existing.name().equals(draft.name())) {
|
||||||
assertNameUnique(draft.name(), id);
|
assertNameUnique(draft.name(), id);
|
||||||
}
|
}
|
||||||
|
validateUrl(draft.url());
|
||||||
|
|
||||||
// Narrowing allowed-envs guard: if the new draft restricts to a non-empty set of envs,
|
// 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.
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.cameleer.server.app.outbound.config;
|
package com.cameleer.server.app.outbound.config;
|
||||||
|
|
||||||
import com.cameleer.server.app.outbound.OutboundConnectionServiceImpl;
|
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.crypto.SecretCipher;
|
||||||
import com.cameleer.server.app.outbound.storage.PostgresOutboundConnectionRepository;
|
import com.cameleer.server.app.outbound.storage.PostgresOutboundConnectionRepository;
|
||||||
import com.cameleer.server.core.alerting.AlertRuleRepository;
|
import com.cameleer.server.core.alerting.AlertRuleRepository;
|
||||||
@@ -31,7 +32,8 @@ public class OutboundBeanConfig {
|
|||||||
public OutboundConnectionService outboundConnectionService(
|
public OutboundConnectionService outboundConnectionService(
|
||||||
OutboundConnectionRepository repo,
|
OutboundConnectionRepository repo,
|
||||||
AlertRuleRepository ruleRepo,
|
AlertRuleRepository ruleRepo,
|
||||||
|
SsrfGuard ssrfGuard,
|
||||||
@Value("${cameleer.server.tenant.id:default}") String tenantId) {
|
@Value("${cameleer.server.tenant.id:default}") String tenantId) {
|
||||||
return new OutboundConnectionServiceImpl(repo, ruleRepo, tenantId);
|
return new OutboundConnectionServiceImpl(repo, ruleRepo, ssrfGuard, tenantId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,3 +17,5 @@ cameleer:
|
|||||||
bootstraptokenprevious: old-bootstrap-token
|
bootstraptokenprevious: old-bootstrap-token
|
||||||
infrastructureendpoints: true
|
infrastructureendpoints: true
|
||||||
jwtsecret: test-jwt-secret-for-integration-tests-only
|
jwtsecret: test-jwt-secret-for-integration-tests-only
|
||||||
|
outbound-http:
|
||||||
|
allow-private-targets: true
|
||||||
|
|||||||
Reference in New Issue
Block a user