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:
+ *
+ * - a literal hostname — {@code internal.corp}
+ * - a literal IP — {@code 192.168.100.100}
+ * - an IPv4 CIDR block — {@code 192.168.100.0/24}
+ *
+ *
+ * {@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-");
}