mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
harden(browser): re-check SSRF on every request + make metadata block unbypassable
Two SSRF hardenings on top of the private-network deployment mode: 1. Redirect / subresource re-validation. The SSRF guard previously ran only on the initial navigation URL in the tool layer, so a public page that 302s to 169.254.169.254 (or a script fetch / img to a metadata IP) reached the target unchecked — worse now that private-network mode exists. Install a per-context request interceptor (BrowserLauncher.applyContextDefaults) that re-runs UrlSafetyChecker on every http(s) request and aborts blocked ones. Non-network schemes (data:/blob:/about:) pass through; unexpected checker faults fail open so a transient error cannot wedge the page (the initial URL was already checked). 2. Allowlist can no longer open a cloud-metadata endpoint. Metadata hostnames and IPs are now checked BEFORE the allowlist short-circuits, so an operator entry like 169.254.0.0/16 or metadata.google.internal can never expose instance metadata. Ordinary private-host allowlisting is unaffected (regression-tested). Also correct the 192.0.0.192 comment (Oracle Cloud IMDS, not Azure).
This commit is contained in:
parent
b9f01db6f7
commit
c95df54949
@ -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<String> chromiumLaunchArgs() {
|
||||
|
||||
@ -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<String> 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;
|
||||
|
||||
@ -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()) {
|
||||
|
||||
@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user