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:
matevip 2026-07-02 09:43:34 +08:00
parent b9f01db6f7
commit c95df54949
4 changed files with 104 additions and 11 deletions

View File

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

View File

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

View File

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

View File

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