feat(tool): configurable SSRF allowlist for outbound HTTP guards

This commit is contained in:
matevip 2026-06-29 10:33:38 +08:00
parent 83660893a6
commit d512643960
15 changed files with 518 additions and 18 deletions

View File

@ -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;
}
}

View File

@ -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<>();
}

View File

@ -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));

View File

@ -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)) {

View File

@ -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. */

View File

@ -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)) {

View File

@ -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());

View File

@ -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 {

View File

@ -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:

View File

@ -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:**

View File

@ -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/*`)管理**

View File

@ -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 ")));
}
}

View File

@ -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);
}
}

View File

@ -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/"));
}
}

View File

@ -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-");
}