mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +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.Page;
|
||||||
import com.microsoft.playwright.Playwright;
|
import com.microsoft.playwright.Playwright;
|
||||||
import com.microsoft.playwright.PlaywrightException;
|
import com.microsoft.playwright.PlaywrightException;
|
||||||
|
import com.microsoft.playwright.Route;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import vip.mate.common.net.SsrfProperties;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
@ -40,9 +42,11 @@ public class BrowserLauncher {
|
|||||||
.toLowerCase(Locale.ROOT).contains("mac");
|
.toLowerCase(Locale.ROOT).contains("mac");
|
||||||
|
|
||||||
private final BrowserProperties props;
|
private final BrowserProperties props;
|
||||||
|
private final SsrfProperties ssrfProperties;
|
||||||
|
|
||||||
public BrowserLauncher(BrowserProperties props) {
|
public BrowserLauncher(BrowserProperties props, SsrfProperties ssrfProperties) {
|
||||||
this.props = props;
|
this.props = props;
|
||||||
|
this.ssrfProperties = ssrfProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
public BrowserProperties properties() {
|
public BrowserProperties properties() {
|
||||||
@ -382,6 +386,42 @@ public class BrowserLauncher {
|
|||||||
private void applyContextDefaults(BrowserContext context) {
|
private void applyContextDefaults(BrowserContext context) {
|
||||||
context.setDefaultTimeout(props.getDefaultTimeoutSeconds() * 1000L);
|
context.setDefaultTimeout(props.getDefaultTimeoutSeconds() * 1000L);
|
||||||
context.setDefaultNavigationTimeout(props.getDefaultNavigationTimeoutSeconds() * 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() {
|
public static List<String> chromiumLaunchArgs() {
|
||||||
|
|||||||
@ -52,6 +52,20 @@ public final class UrlSafetyChecker {
|
|||||||
"::1"
|
"::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() {}
|
private UrlSafetyChecker() {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -113,24 +127,29 @@ 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.
|
String lowerHost = hostname.toLowerCase();
|
||||||
if (SsrfAllowlist.matchesHost(hostname, allowlist)) {
|
// Cloud-metadata endpoints are blocked unconditionally — an allowlist entry
|
||||||
return;
|
// 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");
|
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)) {
|
// Cloud-metadata IPs are blocked in every mode and regardless of the
|
||||||
continue;
|
// allowlist — never exfiltrate cloud credentials via the browser tool.
|
||||||
}
|
|
||||||
// Cloud-metadata endpoints are blocked in every mode — never exfiltrate
|
|
||||||
// cloud credentials via the browser tool, even in private-network-allow mode.
|
|
||||||
if (isMetadataIp(addr)) {
|
if (isMetadataIp(addr)) {
|
||||||
throw new SecurityException("SSRF blocked: " + hostname
|
throw new SecurityException("SSRF blocked: " + hostname
|
||||||
+ " resolves to cloud-metadata endpoint " + addr.getHostAddress());
|
+ " resolves to cloud-metadata endpoint " + addr.getHostAddress());
|
||||||
}
|
}
|
||||||
|
if (hostAllowlisted || SsrfAllowlist.matchesAddress(addr, allowlist)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (allowPrivateNetwork) {
|
if (allowPrivateNetwork) {
|
||||||
// Skip loopback / any-local / link-local / site-local / multicast checks.
|
// Skip loopback / any-local / link-local / site-local / multicast checks.
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package vip.mate.tool.browser;
|
|||||||
import com.microsoft.playwright.Browser;
|
import com.microsoft.playwright.Browser;
|
||||||
import com.microsoft.playwright.Page;
|
import com.microsoft.playwright.Page;
|
||||||
import com.microsoft.playwright.Playwright;
|
import com.microsoft.playwright.Playwright;
|
||||||
|
import vip.mate.common.net.SsrfProperties;
|
||||||
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
@ -25,7 +26,7 @@ public final class BrowserLauncherManualProbe {
|
|||||||
System.out.println("user = " + System.getProperty("user.name"));
|
System.out.println("user = " + System.getProperty("user.name"));
|
||||||
|
|
||||||
BrowserProperties props = new BrowserProperties();
|
BrowserProperties props = new BrowserProperties();
|
||||||
BrowserLauncher launcher = new BrowserLauncher(props);
|
BrowserLauncher launcher = new BrowserLauncher(props, new SsrfProperties());
|
||||||
|
|
||||||
System.out.println("\nCandidate paths on this OS:");
|
System.out.println("\nCandidate paths on this OS:");
|
||||||
for (Path p : BrowserLauncher.systemBrowserCandidates()) {
|
for (Path p : BrowserLauncher.systemBrowserCandidates()) {
|
||||||
|
|||||||
@ -230,4 +230,37 @@ class UrlSafetyCheckerTest {
|
|||||||
assertThrows(SecurityException.class, () ->
|
assertThrows(SecurityException.class, () ->
|
||||||
UrlSafetyChecker.check("http://metadata.google.internal/", true));
|
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