mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
feat(tool): configurable SSRF allowlist for outbound HTTP guards
This commit is contained in:
parent
83660893a6
commit
d512643960
@ -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.
|
||||
*
|
||||
* <p>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:
|
||||
* <ul>
|
||||
* <li>a literal hostname — {@code internal.corp}</li>
|
||||
* <li>a literal IP — {@code 192.168.100.100}</li>
|
||||
* <li>an IPv4 CIDR block — {@code 192.168.100.0/24}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>{@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<String> 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<String> 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;
|
||||
}
|
||||
}
|
||||
@ -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).
|
||||
*
|
||||
* <p>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<String> ssrfAllowlist = new ArrayList<>();
|
||||
}
|
||||
@ -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));
|
||||
|
||||
@ -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<String> trustedDomains;
|
||||
/** Hosts/IPs/CIDR blocks permitted through the private-address SSRF block (shared SSRF allowlist). */
|
||||
private final List<String> 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<String> 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<String> trustedDomains, List<String> 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)) {
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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.
|
||||
*
|
||||
* <p>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<String> 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)) {
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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:<messageId>:<partIndex> ====================
|
||||
|
||||
private ImageReference loadConversationMessageRef(String ref, String conversationId) throws IOException {
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:**
|
||||
|
||||
@ -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/*`)管理**:
|
||||
|
||||
@ -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 ")));
|
||||
}
|
||||
}
|
||||
@ -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<String> trusted, List<String> 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);
|
||||
}
|
||||
}
|
||||
@ -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/"));
|
||||
}
|
||||
}
|
||||
@ -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-");
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user