diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java index 2286987d..299574e2 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java @@ -6,8 +6,10 @@ import com.microsoft.playwright.BrowserType; import com.microsoft.playwright.Page; import com.microsoft.playwright.Playwright; import com.microsoft.playwright.PlaywrightException; +import com.microsoft.playwright.Route; import lombok.Getter; import lombok.extern.slf4j.Slf4j; +import vip.mate.common.net.SsrfProperties; import org.springframework.stereotype.Component; import java.io.BufferedReader; @@ -40,9 +42,11 @@ public class BrowserLauncher { .toLowerCase(Locale.ROOT).contains("mac"); private final BrowserProperties props; + private final SsrfProperties ssrfProperties; - public BrowserLauncher(BrowserProperties props) { + public BrowserLauncher(BrowserProperties props, SsrfProperties ssrfProperties) { this.props = props; + this.ssrfProperties = ssrfProperties; } public BrowserProperties properties() { @@ -382,6 +386,42 @@ public class BrowserLauncher { private void applyContextDefaults(BrowserContext context) { context.setDefaultTimeout(props.getDefaultTimeoutSeconds() * 1000L); context.setDefaultNavigationTimeout(props.getDefaultNavigationTimeoutSeconds() * 1000L); + installSsrfInterceptor(context); + } + + /** + * Re-run the SSRF guard on every http(s) request the page makes, so redirects, + * subresources and script-initiated fetches cannot reach blocked hosts (above + * all cloud-metadata endpoints) after the initial navigation URL already passed + * the one-shot check in the tool layer. Non-network schemes (data:, blob:, + * about:, …) are not SSRF vectors and pass through untouched. + */ + private void installSsrfInterceptor(BrowserContext context) { + if (!props.isSsrfCheckEnabled()) { + return; + } + context.route("**/*", (Route route) -> { + String reqUrl = route.request().url(); + String lower = reqUrl == null ? "" : reqUrl.toLowerCase(Locale.ROOT); + if (!lower.startsWith("http://") && !lower.startsWith("https://")) { + route.resume(); + return; + } + try { + UrlSafetyChecker.check(reqUrl, ssrfProperties.getSsrfAllowlist(), + props.isAllowPrivateNetwork()); + route.resume(); + } catch (SecurityException se) { + log.warn("[BrowserLauncher] SSRF interceptor blocked request url={}: {}", + reqUrl, se.getMessage()); + route.abort(); + } catch (Exception e) { + // Unexpected checker/routing error — the initial navigation URL was + // already validated, so let the request proceed rather than wedging + // the page on a transient fault. + route.resume(); + } + }); } public static List chromiumLaunchArgs() { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/UrlSafetyChecker.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/UrlSafetyChecker.java index 2b3d6dba..af3b5757 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/browser/UrlSafetyChecker.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/UrlSafetyChecker.java @@ -52,6 +52,20 @@ public final class UrlSafetyChecker { "::1" ); + /** + * Subset of {@link #BLOCKED_HOSTNAMES} that are cloud instance-metadata endpoints. + * These are blocked unconditionally — even an explicit allowlist entry must never + * open a path to instance-metadata credential theft. + */ + private static final Set METADATA_HOSTNAMES = Set.of( + "metadata.google.internal", + "metadata.aws.internal", + "instance-data", + "169.254.169.254", + "100.100.100.200", + "192.0.0.192" + ); + private UrlSafetyChecker() {} /** @@ -113,24 +127,29 @@ 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; + String lowerHost = hostname.toLowerCase(); + // Cloud-metadata endpoints are blocked unconditionally — an allowlist entry + // must never open a path to instance-metadata credential theft. + if (METADATA_HOSTNAMES.contains(lowerHost)) { + throw new SecurityException("SSRF blocked: " + hostname + " is a cloud-metadata endpoint"); } - if (BLOCKED_HOSTNAMES.contains(hostname.toLowerCase())) { + // An explicit allowlist entry for the literal host bypasses the loopback / + // private / restricted-hostname checks below — but never the metadata checks. + boolean hostAllowlisted = SsrfAllowlist.matchesHost(hostname, allowlist); + if (!hostAllowlisted && BLOCKED_HOSTNAMES.contains(lowerHost)) { throw new SecurityException("SSRF blocked: " + hostname + " is a restricted hostname"); } try { for (InetAddress addr : InetAddress.getAllByName(hostname)) { - if (SsrfAllowlist.matchesAddress(addr, allowlist)) { - continue; - } - // Cloud-metadata endpoints are blocked in every mode — never exfiltrate - // cloud credentials via the browser tool, even in private-network-allow mode. + // Cloud-metadata IPs are blocked in every mode and regardless of the + // allowlist — never exfiltrate cloud credentials via the browser tool. if (isMetadataIp(addr)) { throw new SecurityException("SSRF blocked: " + hostname + " resolves to cloud-metadata endpoint " + addr.getHostAddress()); } + if (hostAllowlisted || SsrfAllowlist.matchesAddress(addr, allowlist)) { + continue; + } if (allowPrivateNetwork) { // Skip loopback / any-local / link-local / site-local / multicast checks. continue; diff --git a/mateclaw-server/src/test/java/vip/mate/tool/browser/BrowserLauncherManualProbe.java b/mateclaw-server/src/test/java/vip/mate/tool/browser/BrowserLauncherManualProbe.java index fb81aa7d..446ca1c7 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/browser/BrowserLauncherManualProbe.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/browser/BrowserLauncherManualProbe.java @@ -3,6 +3,7 @@ package vip.mate.tool.browser; import com.microsoft.playwright.Browser; import com.microsoft.playwright.Page; import com.microsoft.playwright.Playwright; +import vip.mate.common.net.SsrfProperties; import java.nio.file.Files; import java.nio.file.Path; @@ -25,7 +26,7 @@ public final class BrowserLauncherManualProbe { System.out.println("user = " + System.getProperty("user.name")); BrowserProperties props = new BrowserProperties(); - BrowserLauncher launcher = new BrowserLauncher(props); + BrowserLauncher launcher = new BrowserLauncher(props, new SsrfProperties()); System.out.println("\nCandidate paths on this OS:"); for (Path p : BrowserLauncher.systemBrowserCandidates()) { diff --git a/mateclaw-server/src/test/java/vip/mate/tool/browser/UrlSafetyCheckerTest.java b/mateclaw-server/src/test/java/vip/mate/tool/browser/UrlSafetyCheckerTest.java index 6246a95c..ee75c0c9 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/browser/UrlSafetyCheckerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/browser/UrlSafetyCheckerTest.java @@ -230,4 +230,37 @@ class UrlSafetyCheckerTest { assertThrows(SecurityException.class, () -> UrlSafetyChecker.check("http://metadata.google.internal/", true)); } + + @Test + @DisplayName("An allowlist entry can never open a cloud-metadata endpoint") + void allowlistCannotOverrideMetadata() { + // Exact-IP allowlist of the metadata address must not let it through. + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://169.254.169.254/latest/meta-data/", + List.of("169.254.169.254"))); + // A CIDR that covers the metadata IP must not let it through either. + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://169.254.169.254/", List.of("169.254.0.0/16"))); + // Allowlisting the metadata hostname must not bypass the block. + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://metadata.google.internal/", + List.of("metadata.google.internal"))); + // Even with private-network mode enabled AND an allowlist entry, metadata stays blocked. + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://169.254.169.254/", List.of("169.254.0.0/16"), true)); + // Alibaba and Oracle metadata IPs are equally non-overridable. + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://100.100.100.200/", List.of("100.100.100.200"), true)); + assertThrows(SecurityException.class, () -> + UrlSafetyChecker.check("http://192.0.0.192/", List.of("192.0.0.0/24"), true)); + } + + @Test + @DisplayName("Allowlisting a non-metadata private host still works after the metadata-first reorder") + void allowlistStillWorksForNonMetadata() { + // Regression guard: making metadata unconditional must not break legitimate + // allowlisting of ordinary private hosts. + assertDoesNotThrow(() -> + UrlSafetyChecker.check("http://192.168.50.10/", List.of("192.168.50.0/24"))); + } }