mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +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.http.client.JdkClientHttpRequestFactory;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.client.RestClient;
|
import org.springframework.web.client.RestClient;
|
||||||
|
import vip.mate.common.net.SsrfProperties;
|
||||||
import vip.mate.hook.action.*;
|
import vip.mate.hook.action.*;
|
||||||
import vip.mate.hook.model.HookEntity;
|
import vip.mate.hook.model.HookEntity;
|
||||||
|
|
||||||
@ -26,6 +27,7 @@ public class HookActionFactory {
|
|||||||
|
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final HookProperties props;
|
private final HookProperties props;
|
||||||
|
private final SsrfProperties ssrfProperties;
|
||||||
|
|
||||||
/** 懒加载的共享 RestClient;所有 HttpAction 复用同一连接池。 */
|
/** 懒加载的共享 RestClient;所有 HttpAction 复用同一连接池。 */
|
||||||
private volatile RestClient httpRestClient;
|
private volatile RestClient httpRestClient;
|
||||||
@ -48,6 +50,7 @@ public class HookActionFactory {
|
|||||||
URI.create(required(cfg, "url")),
|
URI.create(required(cfg, "url")),
|
||||||
text(cfg, "body", null),
|
text(cfg, "body", null),
|
||||||
props.getTrustedDomains(),
|
props.getTrustedDomains(),
|
||||||
|
ssrfProperties.getSsrfAllowlist(),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
text(cfg, "hmacSecret", null),
|
text(cfg, "hmacSecret", null),
|
||||||
text(cfg, "signatureHeader", null));
|
text(cfg, "signatureHeader", null));
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import org.springframework.http.HttpStatusCode;
|
|||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.web.client.RestClient;
|
import org.springframework.web.client.RestClient;
|
||||||
import org.springframework.web.client.RestClientException;
|
import org.springframework.web.client.RestClientException;
|
||||||
|
import vip.mate.common.net.SsrfAllowlist;
|
||||||
import vip.mate.hook.event.MateHookEvent;
|
import vip.mate.hook.event.MateHookEvent;
|
||||||
|
|
||||||
import javax.crypto.Mac;
|
import javax.crypto.Mac;
|
||||||
@ -41,6 +42,8 @@ public final class HttpAction implements HookAction {
|
|||||||
private final URI url;
|
private final URI url;
|
||||||
private final String bodyTemplate; // 可含 {{event.xxx}} 占位
|
private final String bodyTemplate; // 可含 {{event.xxx}} 占位
|
||||||
private final List<String> trustedDomains;
|
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;
|
private final long timeoutMs;
|
||||||
/** RFC-03 Lane H1 — when set, the rendered body is signed with HMAC-SHA-256
|
/** 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
|
* 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);
|
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,
|
public HttpAction(RestClient restClient, String method, URI url, String bodyTemplate,
|
||||||
List<String> trustedDomains, long timeoutMs,
|
List<String> trustedDomains, long timeoutMs,
|
||||||
String hmacSecret, String signatureHeader) {
|
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.restClient = restClient;
|
||||||
this.method = (method == null) ? "POST" : method.toUpperCase();
|
this.method = (method == null) ? "POST" : method.toUpperCase();
|
||||||
this.url = url;
|
this.url = url;
|
||||||
this.bodyTemplate = bodyTemplate;
|
this.bodyTemplate = bodyTemplate;
|
||||||
this.trustedDomains = List.copyOf(trustedDomains == null ? List.of() : trustedDomains);
|
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.timeoutMs = Math.max(100L, timeoutMs);
|
||||||
this.hmacSecret = (hmacSecret == null || hmacSecret.isBlank()) ? null : hmacSecret;
|
this.hmacSecret = (hmacSecret == null || hmacSecret.isBlank()) ? null : hmacSecret;
|
||||||
this.signatureHeader = (signatureHeader == null || signatureHeader.isBlank())
|
this.signatureHeader = (signatureHeader == null || signatureHeader.isBlank())
|
||||||
@ -82,7 +94,7 @@ public final class HttpAction implements HookAction {
|
|||||||
if (!isAllowedHost(url.getHost())) {
|
if (!isAllowedHost(url.getHost())) {
|
||||||
throw new IllegalArgumentException("host not in trusted-domains: " + 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());
|
throw new IllegalArgumentException("private/loopback host is forbidden: " + url.getHost());
|
||||||
}
|
}
|
||||||
if (!"GET".equals(method) && !"POST".equals(method)) {
|
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. */
|
/** Maximum concurrent browser sessions across all agents. Prevents runaway memory usage. */
|
||||||
private int maxSessions = 5;
|
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;
|
private boolean ssrfCheckEnabled = true;
|
||||||
|
|
||||||
/** Viewport width (px) for launched browsers. */
|
/** Viewport width (px) for launched browsers. */
|
||||||
|
|||||||
@ -1,15 +1,20 @@
|
|||||||
package vip.mate.tool.browser;
|
package vip.mate.tool.browser;
|
||||||
|
|
||||||
|
import vip.mate.common.net.SsrfAllowlist;
|
||||||
|
|
||||||
import java.net.InetAddress;
|
import java.net.InetAddress;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SSRF guard — rejects URLs that resolve to loopback, link-local, private, or
|
* 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
|
* <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 {
|
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.
|
* Throw {@link SecurityException} if the URL is unsafe. Accepts http:// and https:// only.
|
||||||
*/
|
*/
|
||||||
public static void check(String url) {
|
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()) {
|
if (url == null || url.isBlank()) {
|
||||||
throw new SecurityException("URL is required");
|
throw new SecurityException("URL is required");
|
||||||
}
|
}
|
||||||
@ -53,11 +68,18 @@ public final class UrlSafetyChecker {
|
|||||||
String hostname = host.startsWith("[") && host.endsWith("]")
|
String hostname = host.startsWith("[") && host.endsWith("]")
|
||||||
? host.substring(1, host.length() - 1)
|
? host.substring(1, host.length() - 1)
|
||||||
: host;
|
: 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())) {
|
if (BLOCKED_HOSTNAMES.contains(hostname.toLowerCase())) {
|
||||||
throw new SecurityException("SSRF blocked: " + hostname + " is a restricted hostname");
|
throw new SecurityException("SSRF blocked: " + hostname + " is a restricted hostname");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
for (InetAddress addr : InetAddress.getAllByName(hostname)) {
|
for (InetAddress addr : InetAddress.getAllByName(hostname)) {
|
||||||
|
if (SsrfAllowlist.matchesAddress(addr, allowlist)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (addr.isLoopbackAddress() || addr.isAnyLocalAddress()
|
if (addr.isLoopbackAddress() || addr.isAnyLocalAddress()
|
||||||
|| addr.isLinkLocalAddress() || addr.isSiteLocalAddress()
|
|| addr.isLinkLocalAddress() || addr.isSiteLocalAddress()
|
||||||
|| addr.isMulticastAddress() || isMetadataIp(addr)) {
|
|| addr.isMulticastAddress() || isMetadataIp(addr)) {
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import org.springframework.lang.Nullable;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import vip.mate.tool.browser.BrowserDiagnosticsService;
|
import vip.mate.tool.browser.BrowserDiagnosticsService;
|
||||||
import vip.mate.tool.browser.BrowserLauncher;
|
import vip.mate.tool.browser.BrowserLauncher;
|
||||||
|
import vip.mate.common.net.SsrfProperties;
|
||||||
import vip.mate.tool.browser.UrlSafetyChecker;
|
import vip.mate.tool.browser.UrlSafetyChecker;
|
||||||
|
|
||||||
import java.net.Socket;
|
import java.net.Socket;
|
||||||
@ -46,13 +47,16 @@ public class BrowserUseTool {
|
|||||||
private final vip.mate.channel.web.ChatStreamTracker streamTracker;
|
private final vip.mate.channel.web.ChatStreamTracker streamTracker;
|
||||||
private final BrowserLauncher launcher;
|
private final BrowserLauncher launcher;
|
||||||
private final BrowserDiagnosticsService diagnostics;
|
private final BrowserDiagnosticsService diagnostics;
|
||||||
|
private final SsrfProperties ssrfProperties;
|
||||||
|
|
||||||
public BrowserUseTool(vip.mate.channel.web.ChatStreamTracker streamTracker,
|
public BrowserUseTool(vip.mate.channel.web.ChatStreamTracker streamTracker,
|
||||||
BrowserLauncher launcher,
|
BrowserLauncher launcher,
|
||||||
BrowserDiagnosticsService diagnostics) {
|
BrowserDiagnosticsService diagnostics,
|
||||||
|
SsrfProperties ssrfProperties) {
|
||||||
this.streamTracker = streamTracker;
|
this.streamTracker = streamTracker;
|
||||||
this.launcher = launcher;
|
this.launcher = launcher;
|
||||||
this.diagnostics = diagnostics;
|
this.diagnostics = diagnostics;
|
||||||
|
this.ssrfProperties = ssrfProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -428,7 +432,7 @@ public class BrowserUseTool {
|
|||||||
|
|
||||||
if (launcher.properties().isSsrfCheckEnabled()) {
|
if (launcher.properties().isSsrfCheckEnabled()) {
|
||||||
try {
|
try {
|
||||||
UrlSafetyChecker.check(normalizedUrl);
|
UrlSafetyChecker.check(normalizedUrl, ssrfProperties.getSsrfAllowlist());
|
||||||
} catch (SecurityException se) {
|
} catch (SecurityException se) {
|
||||||
log.warn("[BrowserUse] SSRF check rejected url={}: {}", normalizedUrl, se.getMessage());
|
log.warn("[BrowserUse] SSRF check rejected url={}: {}", normalizedUrl, se.getMessage());
|
||||||
return error(se.getMessage());
|
return error(se.getMessage());
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import cn.hutool.http.HttpUtil;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
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.ConversationService;
|
||||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||||
import vip.mate.workspace.conversation.model.MessageEntity;
|
import vip.mate.workspace.conversation.model.MessageEntity;
|
||||||
@ -48,6 +50,7 @@ public class ImageReferenceLoader {
|
|||||||
private static final int HTTP_TIMEOUT_MS = 30_000;
|
private static final int HTTP_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
private final ConversationService conversationService;
|
private final ConversationService conversationService;
|
||||||
|
private final SsrfProperties ssrfProperties;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a list of input strings; null / blank entries are skipped.
|
* Resolve a list of input strings; null / blank entries are skipped.
|
||||||
@ -144,15 +147,8 @@ public class ImageReferenceLoader {
|
|||||||
if (host == null) {
|
if (host == null) {
|
||||||
throw new IOException("URL has no host: " + url);
|
throw new IOException("URL has no host: " + url);
|
||||||
}
|
}
|
||||||
// Conservative SSRF guard: reject obvious internal targets. Refine later
|
// SSRF guard: reject obvious internal targets unless explicitly allowlisted.
|
||||||
// if the project gains a dedicated SsrFPolicy module.
|
if (isInternalHost(host) && !SsrfAllowlist.matchesHost(host, ssrfProperties.getSsrfAllowlist())) {
|
||||||
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.")) {
|
|
||||||
throw new IOException("Refusing to download image from internal host: " + host);
|
throw new IOException("Refusing to download image from internal host: " + host);
|
||||||
}
|
}
|
||||||
try {
|
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> ====================
|
// ==================== form: msg:<messageId>:<partIndex> ====================
|
||||||
|
|
||||||
private ImageReference loadConversationMessageRef(String ref, String conversationId) throws IOException {
|
private ImageReference loadConversationMessageRef(String ref, String conversationId) throws IOException {
|
||||||
|
|||||||
@ -245,6 +245,12 @@ mateclaw:
|
|||||||
audit:
|
audit:
|
||||||
enabled: true # 每次派发写 mate_hook_run
|
enabled: true # 每次派发写 mate_hook_run
|
||||||
retain-days: 7
|
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 标记
|
# RFC-014: Anthropic prompt cache 标记
|
||||||
llm:
|
llm:
|
||||||
cache:
|
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
|
## Security best practices
|
||||||
@ -527,7 +566,7 @@ server {
|
|||||||
|
|
||||||
## Security configuration reference
|
## 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
|
```yaml
|
||||||
mateclaw:
|
mateclaw:
|
||||||
@ -543,6 +582,12 @@ mateclaw:
|
|||||||
sandbox:
|
sandbox:
|
||||||
enabled: true
|
enabled: true
|
||||||
root: ${user.dir}/data/workspace
|
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:**
|
**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
|
```yaml
|
||||||
mateclaw:
|
mateclaw:
|
||||||
@ -542,6 +581,11 @@ mateclaw:
|
|||||||
sandbox:
|
sandbox:
|
||||||
enabled: true
|
enabled: true
|
||||||
root: ${user.dir}/data/workspace
|
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/*`)管理**:
|
**其余安全配置不走 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.DisplayName;
|
||||||
import org.junit.jupiter.api.Tag;
|
import org.junit.jupiter.api.Tag;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import vip.mate.common.net.SsrfProperties;
|
||||||
import vip.mate.workspace.conversation.ConversationService;
|
import vip.mate.workspace.conversation.ConversationService;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@ -33,7 +34,7 @@ class ImageReferenceLoaderTest {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() throws IOException {
|
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-");
|
tmpDir = Files.createTempDirectory("img-ref-loader-test-");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user