diff --git a/mateclaw-server/src/main/java/vip/mate/common/net/SsrfAllowlist.java b/mateclaw-server/src/main/java/vip/mate/common/net/SsrfAllowlist.java new file mode 100644 index 00000000..083c7935 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/common/net/SsrfAllowlist.java @@ -0,0 +1,138 @@ +package vip.mate.common.net; + +import java.net.Inet4Address; +import java.net.InetAddress; +import java.util.Collection; + +/** + * Shared matching logic for the outbound-request SSRF allowlist. + * + *

Outbound HTTP guards (browser navigation, hook webhooks, image download) + * block loopback, private, link-local and cloud-metadata targets by default. + * Administrators can punch a narrow hole for a specific internal host via the + * allowlist; each entry is one of: + *

+ * + *

{@link #matchesHost} compares against the URL host string as written (no + * DNS lookup); {@link #matchesAddress} compares against an already-resolved + * address. A guard that resolves DNS should consult both so that neither the + * literal host nor any resolved address is missed. + */ +public final class SsrfAllowlist { + + private SsrfAllowlist() {} + + /** True when the literal host string (hostname or IP literal) matches an allowlist entry. */ + public static boolean matchesHost(String host, Collection allowlist) { + if (host == null || host.isBlank() || allowlist == null || allowlist.isEmpty()) { + return false; + } + String h = stripBrackets(host.trim()); + Integer hostIp = ipv4ToInt(h); // non-null only when h is an IPv4 literal + for (String raw : allowlist) { + String entry = trimOrNull(raw); + if (entry == null) { + continue; + } + if (entry.indexOf('/') >= 0) { + if (hostIp != null && ipv4InCidr(hostIp, entry)) { + return true; + } + } else if (entry.equalsIgnoreCase(h)) { + return true; + } + } + return false; + } + + /** True when a resolved address matches an allowlist entry (literal IP or IPv4 CIDR). */ + public static boolean matchesAddress(InetAddress addr, Collection allowlist) { + if (addr == null || allowlist == null || allowlist.isEmpty()) { + return false; + } + String ip = addr.getHostAddress(); + Integer addrIp = (addr instanceof Inet4Address) ? bytesToInt(addr.getAddress()) : null; + for (String raw : allowlist) { + String entry = trimOrNull(raw); + if (entry == null) { + continue; + } + if (entry.indexOf('/') >= 0) { + if (addrIp != null && ipv4InCidr(addrIp, entry)) { + return true; + } + } else if (entry.equalsIgnoreCase(ip)) { + return true; + } + } + return false; + } + + private static String trimOrNull(String raw) { + if (raw == null) { + return null; + } + String t = raw.trim(); + return t.isEmpty() ? null : t; + } + + private static String stripBrackets(String host) { + return host.startsWith("[") && host.endsWith("]") + ? host.substring(1, host.length() - 1) + : host; + } + + /** Membership test for an IPv4 address (as a 32-bit int) against a {@code a.b.c.d/prefix} block. */ + private static boolean ipv4InCidr(int addrBits, String cidr) { + int slash = cidr.indexOf('/'); + Integer networkBits = ipv4ToInt(cidr.substring(0, slash).trim()); + if (networkBits == null) { + return false; + } + int prefix; + try { + prefix = Integer.parseInt(cidr.substring(slash + 1).trim()); + } catch (NumberFormatException e) { + return false; + } + if (prefix < 0 || prefix > 32) { + return false; + } + int mask = prefix == 0 ? 0 : 0xFFFFFFFF << (32 - prefix); + return (addrBits & mask) == (networkBits & mask); + } + + /** Parse a dotted-quad IPv4 literal into a 32-bit int, or null if it is not one. */ + private static Integer ipv4ToInt(String ip) { + String[] parts = ip.split("\\."); + if (parts.length != 4) { + return null; + } + int result = 0; + for (String part : parts) { + int octet; + try { + octet = Integer.parseInt(part); + } catch (NumberFormatException e) { + return null; + } + if (octet < 0 || octet > 255) { + return null; + } + result = (result << 8) | octet; + } + return result; + } + + private static int bytesToInt(byte[] bytes) { + int result = 0; + for (byte b : bytes) { + result = (result << 8) | (b & 0xFF); + } + return result; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/common/net/SsrfProperties.java b/mateclaw-server/src/main/java/vip/mate/common/net/SsrfProperties.java new file mode 100644 index 00000000..ea281a2c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/common/net/SsrfProperties.java @@ -0,0 +1,28 @@ +package vip.mate.common.net; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * Shared SSRF guard configuration consulted by every outbound-request tool + * (browser navigation, hook webhooks, image download). + * + *

By default outbound guards block loopback, private, link-local and + * cloud-metadata targets. {@link #ssrfAllowlist} lets an administrator reach a + * specific internal host through all of those guards at once. Each entry is a + * literal hostname, a literal IP, or an IPv4 CIDR block — see {@link SsrfAllowlist}. + * Keep the list as narrow as possible; entries here can re-expose + * cloud-metadata endpoints too. + */ +@Data +@Component +@ConfigurationProperties(prefix = "mateclaw.security") +public class SsrfProperties { + + /** Hosts/IPs/CIDR blocks permitted through the SSRF guards despite being otherwise restricted. */ + private List ssrfAllowlist = new ArrayList<>(); +} diff --git a/mateclaw-server/src/main/java/vip/mate/hook/HookActionFactory.java b/mateclaw-server/src/main/java/vip/mate/hook/HookActionFactory.java index c2c073ab..1e71f171 100644 --- a/mateclaw-server/src/main/java/vip/mate/hook/HookActionFactory.java +++ b/mateclaw-server/src/main/java/vip/mate/hook/HookActionFactory.java @@ -7,6 +7,7 @@ import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; +import vip.mate.common.net.SsrfProperties; import vip.mate.hook.action.*; import vip.mate.hook.model.HookEntity; @@ -26,6 +27,7 @@ public class HookActionFactory { private final ObjectMapper objectMapper; private final HookProperties props; + private final SsrfProperties ssrfProperties; /** 懒加载的共享 RestClient;所有 HttpAction 复用同一连接池。 */ private volatile RestClient httpRestClient; @@ -48,6 +50,7 @@ public class HookActionFactory { URI.create(required(cfg, "url")), text(cfg, "body", null), props.getTrustedDomains(), + ssrfProperties.getSsrfAllowlist(), timeoutMs, text(cfg, "hmacSecret", null), text(cfg, "signatureHeader", null)); diff --git a/mateclaw-server/src/main/java/vip/mate/hook/action/HttpAction.java b/mateclaw-server/src/main/java/vip/mate/hook/action/HttpAction.java index 38e0623c..8269c588 100644 --- a/mateclaw-server/src/main/java/vip/mate/hook/action/HttpAction.java +++ b/mateclaw-server/src/main/java/vip/mate/hook/action/HttpAction.java @@ -5,6 +5,7 @@ import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; +import vip.mate.common.net.SsrfAllowlist; import vip.mate.hook.event.MateHookEvent; import javax.crypto.Mac; @@ -41,6 +42,8 @@ public final class HttpAction implements HookAction { private final URI url; private final String bodyTemplate; // 可含 {{event.xxx}} 占位 private final List trustedDomains; + /** Hosts/IPs/CIDR blocks permitted through the private-address SSRF block (shared SSRF allowlist). */ + private final List ssrfAllowlist; private final long timeoutMs; /** RFC-03 Lane H1 — when set, the rendered body is signed with HMAC-SHA-256 * and the resulting hex digest is placed in the header named by @@ -56,14 +59,23 @@ public final class HttpAction implements HookAction { this(restClient, method, url, bodyTemplate, trustedDomains, timeoutMs, null, null); } + /** Constructor without an SSRF allowlist — preserved for existing callers. */ public HttpAction(RestClient restClient, String method, URI url, String bodyTemplate, List trustedDomains, long timeoutMs, String hmacSecret, String signatureHeader) { + this(restClient, method, url, bodyTemplate, trustedDomains, List.of(), timeoutMs, + hmacSecret, signatureHeader); + } + + public HttpAction(RestClient restClient, String method, URI url, String bodyTemplate, + List trustedDomains, List ssrfAllowlist, long timeoutMs, + String hmacSecret, String signatureHeader) { this.restClient = restClient; this.method = (method == null) ? "POST" : method.toUpperCase(); this.url = url; this.bodyTemplate = bodyTemplate; this.trustedDomains = List.copyOf(trustedDomains == null ? List.of() : trustedDomains); + this.ssrfAllowlist = List.copyOf(ssrfAllowlist == null ? List.of() : ssrfAllowlist); this.timeoutMs = Math.max(100L, timeoutMs); this.hmacSecret = (hmacSecret == null || hmacSecret.isBlank()) ? null : hmacSecret; this.signatureHeader = (signatureHeader == null || signatureHeader.isBlank()) @@ -82,7 +94,7 @@ public final class HttpAction implements HookAction { if (!isAllowedHost(url.getHost())) { throw new IllegalArgumentException("host not in trusted-domains: " + url.getHost()); } - if (isPrivateAddress(url.getHost())) { + if (isPrivateAddress(url.getHost()) && !SsrfAllowlist.matchesHost(url.getHost(), ssrfAllowlist)) { throw new IllegalArgumentException("private/loopback host is forbidden: " + url.getHost()); } if (!"GET".equals(method) && !"POST".equals(method)) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserProperties.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserProperties.java index c51c16bf..1c83f7bb 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserProperties.java @@ -48,7 +48,10 @@ public class BrowserProperties { /** Maximum concurrent browser sessions across all agents. Prevents runaway memory usage. */ private int maxSessions = 5; - /** Block navigations to loopback, private, link-local and cloud-metadata hosts. */ + /** + * Block navigations to loopback, private, link-local and cloud-metadata hosts. + * Narrow exceptions are configured via {@code mateclaw.security.ssrf-allowlist}. + */ private boolean ssrfCheckEnabled = true; /** Viewport width (px) for launched browsers. */ diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/UrlSafetyChecker.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/UrlSafetyChecker.java index bed67935..1ddf302b 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/browser/UrlSafetyChecker.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/UrlSafetyChecker.java @@ -1,15 +1,20 @@ package vip.mate.tool.browser; +import vip.mate.common.net.SsrfAllowlist; + import java.net.InetAddress; import java.net.URI; +import java.util.Collection; +import java.util.List; import java.util.Set; /** * SSRF guard — rejects URLs that resolve to loopback, link-local, private, or - * known cloud-metadata endpoints. Mirrors openfang's {@code check_ssrf} behaviour. + * known cloud-metadata endpoints. * *

Call this before passing any user-controlled URL to the browser or to an - * outbound HTTP client. + * outbound HTTP client. An optional allowlist lets administrators reach specific + * internal hosts/IPs/CIDR blocks while every other restricted address stays blocked. */ public final class UrlSafetyChecker { @@ -33,6 +38,16 @@ public final class UrlSafetyChecker { * Throw {@link SecurityException} if the URL is unsafe. Accepts http:// and https:// only. */ public static void check(String url) { + check(url, List.of()); + } + + /** + * Throw {@link SecurityException} if the URL is unsafe. + * + * @param allowlist hostnames, literal IPs, or IPv4 CIDR blocks that are permitted even + * when they would otherwise be blocked (loopback, private, metadata, …). + */ + public static void check(String url, Collection allowlist) { if (url == null || url.isBlank()) { throw new SecurityException("URL is required"); } @@ -53,11 +68,18 @@ public final class UrlSafetyChecker { String hostname = host.startsWith("[") && host.endsWith("]") ? host.substring(1, host.length() - 1) : host; + // An explicit allowlist entry for the literal host short-circuits all checks. + if (SsrfAllowlist.matchesHost(hostname, allowlist)) { + return; + } if (BLOCKED_HOSTNAMES.contains(hostname.toLowerCase())) { throw new SecurityException("SSRF blocked: " + hostname + " is a restricted hostname"); } try { for (InetAddress addr : InetAddress.getAllByName(hostname)) { + if (SsrfAllowlist.matchesAddress(addr, allowlist)) { + continue; + } if (addr.isLoopbackAddress() || addr.isAnyLocalAddress() || addr.isLinkLocalAddress() || addr.isSiteLocalAddress() || addr.isMulticastAddress() || isMetadataIp(addr)) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java index 25f017e4..e9586c6b 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java @@ -19,6 +19,7 @@ import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import vip.mate.tool.browser.BrowserDiagnosticsService; import vip.mate.tool.browser.BrowserLauncher; +import vip.mate.common.net.SsrfProperties; import vip.mate.tool.browser.UrlSafetyChecker; import java.net.Socket; @@ -46,13 +47,16 @@ public class BrowserUseTool { private final vip.mate.channel.web.ChatStreamTracker streamTracker; private final BrowserLauncher launcher; private final BrowserDiagnosticsService diagnostics; + private final SsrfProperties ssrfProperties; public BrowserUseTool(vip.mate.channel.web.ChatStreamTracker streamTracker, BrowserLauncher launcher, - BrowserDiagnosticsService diagnostics) { + BrowserDiagnosticsService diagnostics, + SsrfProperties ssrfProperties) { this.streamTracker = streamTracker; this.launcher = launcher; this.diagnostics = diagnostics; + this.ssrfProperties = ssrfProperties; } /** @@ -428,7 +432,7 @@ public class BrowserUseTool { if (launcher.properties().isSsrfCheckEnabled()) { try { - UrlSafetyChecker.check(normalizedUrl); + UrlSafetyChecker.check(normalizedUrl, ssrfProperties.getSsrfAllowlist()); } catch (SecurityException se) { log.warn("[BrowserUse] SSRF check rejected url={}: {}", normalizedUrl, se.getMessage()); return error(se.getMessage()); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/image/ImageReferenceLoader.java b/mateclaw-server/src/main/java/vip/mate/tool/image/ImageReferenceLoader.java index 613d23ab..1d178b0b 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/image/ImageReferenceLoader.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/image/ImageReferenceLoader.java @@ -4,6 +4,8 @@ import cn.hutool.http.HttpUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import vip.mate.common.net.SsrfAllowlist; +import vip.mate.common.net.SsrfProperties; import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.model.MessageContentPart; import vip.mate.workspace.conversation.model.MessageEntity; @@ -48,6 +50,7 @@ public class ImageReferenceLoader { private static final int HTTP_TIMEOUT_MS = 30_000; private final ConversationService conversationService; + private final SsrfProperties ssrfProperties; /** * Resolve a list of input strings; null / blank entries are skipped. @@ -144,15 +147,8 @@ public class ImageReferenceLoader { if (host == null) { throw new IOException("URL has no host: " + url); } - // Conservative SSRF guard: reject obvious internal targets. Refine later - // if the project gains a dedicated SsrFPolicy module. - String lowered = host.toLowerCase(); - if (lowered.equals("localhost") - || lowered.equals("127.0.0.1") - || lowered.startsWith("10.") - || lowered.startsWith("192.168.") - || lowered.startsWith("169.254.") - || lowered.startsWith("172.")) { + // SSRF guard: reject obvious internal targets unless explicitly allowlisted. + if (isInternalHost(host) && !SsrfAllowlist.matchesHost(host, ssrfProperties.getSsrfAllowlist())) { throw new IOException("Refusing to download image from internal host: " + host); } try { @@ -171,6 +167,32 @@ public class ImageReferenceLoader { } } + /** Match common private / loopback / link-local hosts by string form (no DNS lookup). */ + private static boolean isInternalHost(String host) { + if (host == null) { + return true; + } + String h = host.toLowerCase(); + if (h.equals("localhost") || h.equals("127.0.0.1") || h.equals("::1")) { + return true; + } + if (h.startsWith("10.") || h.startsWith("192.168.") || h.startsWith("169.254.")) { + return true; + } + if (h.startsWith("172.")) { + String[] parts = h.split("\\."); + if (parts.length >= 2) { + try { + int second = Integer.parseInt(parts[1]); + return second >= 16 && second <= 31; // 172.16.0.0 – 172.31.255.255 + } catch (NumberFormatException ignore) { + return false; + } + } + } + return false; + } + // ==================== form: msg:: ==================== private ImageReference loadConversationMessageRef(String ref, String conversationId) throws IOException { diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index 74b4f78e..950cabfd 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -245,6 +245,12 @@ mateclaw: audit: enabled: true # 每次派发写 mate_hook_run retain-days: 7 + security: + # Hosts/IPs/CIDR blocks allowed through the SSRF guards (browser, hooks, image + # download) even though they are loopback/private/link-local/metadata. Each + # entry is a literal hostname, a literal IP, or an IPv4 CIDR block. Keep narrow. + # Example: [192.168.100.100, 192.168.100.0/24, internal.corp] + ssrf-allowlist: [] # RFC-014: Anthropic prompt cache 标记 llm: cache: diff --git a/mateclaw-server/src/main/resources/docs/en/security.md b/mateclaw-server/src/main/resources/docs/en/security.md index 0daae38f..2a564c2f 100644 --- a/mateclaw-server/src/main/resources/docs/en/security.md +++ b/mateclaw-server/src/main/resources/docs/en/security.md @@ -509,6 +509,45 @@ server { } ``` +### Outbound request protection (SSRF) + +Every **outbound HTTP request an agent can drive** carries SSRF protection by default, so a manipulated agent can't be steered into probing your internal network or a cloud metadata endpoint. Three outbound paths are covered: + +| Outbound path | Triggered by | Default behaviour | +|---------------|--------------|-------------------| +| **Browser tool** | the `open` action of `browser_use` | resolves the target host and rejects restricted addresses | +| **Hook webhook** | the HTTP call of a hook action | host must be in `trusted-domains` AND must not be a private address | +| **Image download** | the image tool fetching a URL reference | rejects private / loopback hosts | + +Address classes blocked by default: loopback (`127.0.0.0/8`, `::1`), private (`10/8`, `172.16/12`, `192.168/16`), link-local (`169.254/16`, `fe80::/10`), any-local, multicast, and cloud metadata endpoints (`169.254.169.254`, `100.100.100.200`, `192.0.0.192`, …). + +#### Allowing internal addresses: `mateclaw.security.ssrf-allowlist` + +When an agent legitimately needs to reach an internal service, add it to the shared allowlist. **One setting, applied across all three outbound paths.** Each entry is one of: + +| Form | Example | Meaning | +|------|---------|---------| +| Literal hostname | `internal.corp` | case-insensitive exact match | +| Literal IP | `192.168.100.100` | matches that exact address | +| IPv4 CIDR block | `192.168.100.0/24` | matches every IP in the range | + +```yaml +mateclaw: + security: + ssrf-allowlist: + - 192.168.100.100 # a single internal address + - 192.168.100.0/24 # a whole internal subnet + - internal.corp # an internal hostname +``` + +The allowlist opens **only the entries you list**: `192.168.100.0/24` does not also open `192.168.200.x`, and `192.168.100.100` does not open sibling IPs in the same subnet. Changes require a backend restart. + +::: warning Keep it narrow +Allowlist entries **can re-expose cloud metadata endpoints** (e.g. `169.254.169.254`). Once exposed, a compromised agent could use one to steal cloud credentials. Add only the internal addresses you actually need, and **never** open things up with a broad CIDR such as `0.0.0.0/0` or `10.0.0.0/8`. +::: + +The browser tool also has a master switch `mateclaw.browser.ssrf-check-enabled` (default `true`). Setting it to `false` **disables the SSRF check entirely** for the browser path — including the metadata endpoints — and is discouraged; prefer the allowlist above for precise exceptions. + --- ## Security best practices @@ -527,7 +566,7 @@ server { ## Security configuration reference -application.yml carries **only two** security-related blocks — JWT and the filesystem sandbox: +application.yml carries **three** security-related blocks — JWT, the filesystem sandbox, and the outbound request allowlist: ```yaml mateclaw: @@ -543,6 +582,12 @@ mateclaw: sandbox: enabled: true root: ${user.dir}/data/workspace + + # Outbound SSRF allowlist: permit specific internal hosts/IPs/CIDR blocks, + # shared by the browser, hook, and image-download outbound paths. Empty means + # every private address is blocked by the default policy. + security: + ssrf-allowlist: [] # e.g. [192.168.100.100, 192.168.100.0/24] ``` **Everything else is managed in the database — from the admin Security page (or `/api/v1/security/guard/*`), not application.yml:** diff --git a/mateclaw-server/src/main/resources/docs/zh/security.md b/mateclaw-server/src/main/resources/docs/zh/security.md index f0f922b0..964b3849 100644 --- a/mateclaw-server/src/main/resources/docs/zh/security.md +++ b/mateclaw-server/src/main/resources/docs/zh/security.md @@ -509,6 +509,45 @@ server { } ``` +### 出站请求防护(SSRF) + +凡是 Agent 能驱动的**对外 HTTP 请求**,都默认带 SSRF 防护,避免被诱导去探测内网或云厂商元数据端点。覆盖三条出站路径: + +| 出站路径 | 触发方 | 默认行为 | +|----------|--------|----------| +| **浏览器工具** | `browser_use` 的 `open` 动作 | 解析目标主机,命中受限地址即拒绝 | +| **Hook Webhook** | Hook 动作的 HTTP 调用 | 主机须在 `trusted-domains` 内,且不得是私网地址 | +| **图片下载** | 图片工具按 URL 拉取素材 | 命中私网/回环主机即拒绝 | + +默认拦截的地址类别:回环(`127.0.0.0/8`、`::1`)、私网(`10/8`、`172.16/12`、`192.168/16`)、链路本地(`169.254/16`、`fe80::/10`)、任意本地地址、组播,以及云厂商元数据端点(`169.254.169.254`、`100.100.100.200`、`192.0.0.192` 等)。 + +#### 放行内网地址:`mateclaw.security.ssrf-allowlist` + +需要让 Agent 访问某个内网服务时,把它加进统一白名单。**一处配置,三条出站路径同时生效。** 每个条目是以下三种之一: + +| 形态 | 例子 | 说明 | +|------|------|------| +| 字面主机名 | `internal.corp` | 大小写不敏感的精确匹配 | +| 字面 IP | `192.168.100.100` | 精确匹配该地址 | +| IPv4 CIDR 段 | `192.168.100.0/24` | 匹配该网段内的所有 IP | + +```yaml +mateclaw: + security: + ssrf-allowlist: + - 192.168.100.100 # 单个内网地址 + - 192.168.100.0/24 # 整段内网 + - internal.corp # 内网主机名 +``` + +放行规则**只放开列出的条目**:白名单里写 `192.168.100.0/24` 不会连带放开 `192.168.200.x`,写 `192.168.100.100` 也不会放开同段的其它 IP。改完需重启后台生效。 + +::: warning 保持最小化 +白名单条目**可以重新放开云厂商元数据端点**(如 `169.254.169.254`)。一旦放开,被攻陷的 Agent 可能借此窃取云凭据。只加确实需要的内网地址,**永远不要**用宽 CIDR(如 `0.0.0.0/0`、`10.0.0.0/8`)一把放开。 +::: + +浏览器工具另有一个总开关 `mateclaw.browser.ssrf-check-enabled`(默认 `true`)。把它设为 `false` 会**整体关闭**浏览器路径的 SSRF 校验——连元数据端点一起放开,不推荐;优先用上面的白名单做精确放行。 + --- ## 安全最佳实践 @@ -527,7 +566,7 @@ server { ## 安全配置参考 -application.yml 里**只有两块**安全相关配置——JWT 和文件沙箱: +application.yml 里有**三块**安全相关配置——JWT、文件沙箱,以及出站请求白名单: ```yaml mateclaw: @@ -542,6 +581,11 @@ mateclaw: sandbox: enabled: true root: ${user.dir}/data/workspace + + # 出站请求 SSRF 白名单:放行特定内网主机/IP/CIDR,浏览器、Hook、 + # 图片下载三条出站路径共用。留空表示按默认策略拦截全部私网地址。 + security: + ssrf-allowlist: [] # 例:[192.168.100.100, 192.168.100.0/24] ``` **其余安全配置不走 application.yml,而是存在数据库、从管理台「安全」页(或 `/api/v1/security/guard/*`)管理**: diff --git a/mateclaw-server/src/test/java/vip/mate/common/net/SsrfAllowlistTest.java b/mateclaw-server/src/test/java/vip/mate/common/net/SsrfAllowlistTest.java new file mode 100644 index 00000000..c6ebb230 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/common/net/SsrfAllowlistTest.java @@ -0,0 +1,56 @@ +package vip.mate.common.net; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.net.InetAddress; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SsrfAllowlistTest { + + @Test + @DisplayName("matchesHost: exact IP and hostname, case-insensitive") + void matchesHostExact() { + assertTrue(SsrfAllowlist.matchesHost("192.168.100.100", List.of("192.168.100.100"))); + assertTrue(SsrfAllowlist.matchesHost("Internal.Corp", List.of("internal.corp"))); + assertFalse(SsrfAllowlist.matchesHost("192.168.100.101", List.of("192.168.100.100"))); + assertFalse(SsrfAllowlist.matchesHost("evil.com", List.of("internal.corp"))); + } + + @Test + @DisplayName("matchesHost: IPv4 CIDR matches contained IP literals only") + void matchesHostCidr() { + assertTrue(SsrfAllowlist.matchesHost("192.168.100.1", List.of("192.168.100.0/24"))); + assertTrue(SsrfAllowlist.matchesHost("192.168.100.254", List.of("192.168.100.0/24"))); + assertFalse(SsrfAllowlist.matchesHost("192.168.101.1", List.of("192.168.100.0/24"))); + // A hostname is not an IP, so it never matches a CIDR entry. + assertFalse(SsrfAllowlist.matchesHost("internal.corp", List.of("192.168.100.0/24"))); + } + + @Test + @DisplayName("matchesHost: bracketed IPv6 literal compares stripped form") + void matchesHostIpv6Brackets() { + assertTrue(SsrfAllowlist.matchesHost("[fd00::1]", List.of("fd00::1"))); + } + + @Test + @DisplayName("matchesAddress: exact IP and CIDR against resolved address") + void matchesAddressIpv4() throws Exception { + InetAddress addr = InetAddress.getByName("192.168.100.100"); + assertTrue(SsrfAllowlist.matchesAddress(addr, List.of("192.168.100.100"))); + assertTrue(SsrfAllowlist.matchesAddress(addr, List.of("192.168.100.0/24"))); + assertFalse(SsrfAllowlist.matchesAddress(addr, List.of("10.0.0.0/8"))); + } + + @Test + @DisplayName("Empty / null allowlist never matches; whitespace and bad entries are ignored") + void emptyAndMalformed() { + assertFalse(SsrfAllowlist.matchesHost("192.168.1.1", List.of())); + assertFalse(SsrfAllowlist.matchesHost("192.168.1.1", null)); + assertFalse(SsrfAllowlist.matchesHost("192.168.1.1", List.of(" ", "not-a-cidr/99", "999.1.1.1"))); + assertTrue(SsrfAllowlist.matchesHost("192.168.1.1", List.of(" ", " 192.168.1.1 "))); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/hook/action/HttpActionSsrfAllowlistTest.java b/mateclaw-server/src/test/java/vip/mate/hook/action/HttpActionSsrfAllowlistTest.java new file mode 100644 index 00000000..c77c01c0 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/hook/action/HttpActionSsrfAllowlistTest.java @@ -0,0 +1,53 @@ +package vip.mate.hook.action; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestClient; + +import java.net.URI; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * The hook HTTP action requires the target host to be in {@code trustedDomains} + * AND to not be a private/loopback address. An entry in the shared SSRF + * allowlist lifts the private-address block for that specific host. + */ +class HttpActionSsrfAllowlistTest { + + private static HttpAction action(String host, List trusted, List ssrfAllowlist) { + return new HttpAction( + RestClient.builder().build(), + "POST", + URI.create("http://" + host + "/hook"), + null, + trusted, + ssrfAllowlist, + 3000L, + null, + null); + } + + @Test + @DisplayName("Private host is rejected even when trusted, without an allowlist entry") + void privateHostRejectedWithoutAllowlist() { + HttpAction a = action("192.168.100.100", List.of("192.168.100.100"), List.of()); + assertThrows(IllegalArgumentException.class, a::validate); + } + + @Test + @DisplayName("Allowlisting the private host (with trust) lets validate() pass") + void privateHostAllowedWithAllowlist() { + HttpAction a = action("192.168.100.100", List.of("192.168.100.100"), List.of("192.168.100.0/24")); + assertDoesNotThrow(a::validate); + } + + @Test + @DisplayName("Allowlist does not bypass the trusted-domains requirement") + void allowlistDoesNotBypassTrust() { + HttpAction a = action("192.168.100.100", List.of(), List.of("192.168.100.100")); + assertThrows(IllegalArgumentException.class, a::validate); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/browser/UrlSafetyCheckerTest.java b/mateclaw-server/src/test/java/vip/mate/tool/browser/UrlSafetyCheckerTest.java new file mode 100644 index 00000000..a0c486c1 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/browser/UrlSafetyCheckerTest.java @@ -0,0 +1,63 @@ +package vip.mate.tool.browser; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class UrlSafetyCheckerTest { + + @Test + @DisplayName("Rejects private, loopback and metadata addresses by default") + void blocksRestrictedAddressesWithoutAllowlist() { + assertThrows(SecurityException.class, () -> UrlSafetyChecker.check("http://192.168.100.100/admin")); + assertThrows(SecurityException.class, () -> UrlSafetyChecker.check("http://10.0.0.5/")); + assertThrows(SecurityException.class, () -> UrlSafetyChecker.check("http://127.0.0.1:8080/")); + assertThrows(SecurityException.class, () -> UrlSafetyChecker.check("http://localhost/")); + assertThrows(SecurityException.class, () -> UrlSafetyChecker.check("http://169.254.169.254/latest/meta-data/")); + } + + @Test + @DisplayName("Rejects non-http schemes") + void blocksNonHttpSchemes() { + assertThrows(SecurityException.class, () -> UrlSafetyChecker.check("file:///etc/passwd")); + assertThrows(SecurityException.class, () -> UrlSafetyChecker.check("ftp://192.168.1.1/")); + } + + @Test + @DisplayName("Allowlisting a literal private IP lets it through") + void allowlistExactIp() { + assertDoesNotThrow(() -> + UrlSafetyChecker.check("http://192.168.100.100/admin", List.of("192.168.100.100"))); + } + + @Test + @DisplayName("Allowlisting a CIDR block lets matching private IPs through") + void allowlistCidr() { + assertDoesNotThrow(() -> + UrlSafetyChecker.check("http://192.168.100.100/x", List.of("192.168.100.0/24"))); + assertDoesNotThrow(() -> + UrlSafetyChecker.check("http://192.168.100.250/y", List.of("192.168.100.0/24"))); + } + + @Test + @DisplayName("An allowlist entry does not open up addresses outside it") + void allowlistIsNarrow() { + // 192.168.100.0/24 must not unblock a different private subnet. + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://192.168.200.5/", List.of("192.168.100.0/24"))); + // Exact-IP allowlist must not unblock a sibling host. + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://192.168.100.101/", List.of("192.168.100.100"))); + } + + @Test + @DisplayName("Public addresses are always allowed") + void allowsPublicAddresses() { + assertDoesNotThrow(() -> UrlSafetyChecker.check("http://8.8.8.8/")); + assertDoesNotThrow(() -> UrlSafetyChecker.check("https://1.1.1.1/")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/image/ImageReferenceLoaderTest.java b/mateclaw-server/src/test/java/vip/mate/tool/image/ImageReferenceLoaderTest.java index 5690c069..eeea593a 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/image/ImageReferenceLoaderTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/image/ImageReferenceLoaderTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import vip.mate.common.net.SsrfProperties; import vip.mate.workspace.conversation.ConversationService; import java.io.IOException; @@ -33,7 +34,7 @@ class ImageReferenceLoaderTest { @BeforeEach void setUp() throws IOException { - loader = new ImageReferenceLoader(mock(ConversationService.class)); + loader = new ImageReferenceLoader(mock(ConversationService.class), new SsrfProperties()); tmpDir = Files.createTempDirectory("img-ref-loader-test-"); }