From c3da1ce8171b2eca4de871b4882b16ab45d68c19 Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 2 Jul 2026 10:31:33 +0800 Subject: [PATCH] fix(browser): actually block redirect targets in SSRF interceptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut of the per-request interceptor called route.resume() for every request. Playwright follows server-side 3xx redirects internally on resume() WITHOUT re-invoking the route handler, so a public page that 302s to a metadata IP still reached it — verified via runtime E2E (the handler only ever saw the httpbin.org URLs, never the 169.254.169.254 redirect target). Fix: for navigation requests, fetch with maxRedirects=0 and validate the Location of each hop through UrlSafetyChecker before fulfilling; abort when a hop resolves to a blocked host. Subresources/fetches keep the direct per-URL check + resume path. Non-navigation and non-http(s) requests are unaffected. Runtime-verified: httpbin.org 302 -> 169.254.169.254 is now aborted (net::ERR_FAILED; log "blocked redirect ... cloud-metadata endpoint"), while example.com and wikipedia.org (rich subresources) still load with no false blocks. --- .../mate/tool/browser/BrowserLauncher.java | 62 ++++++++++++++++--- 1 file changed, 53 insertions(+), 9 deletions(-) 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 299574e2..ba1e16f3 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 @@ -1,5 +1,6 @@ package vip.mate.tool.browser; +import com.microsoft.playwright.APIResponse; import com.microsoft.playwright.Browser; import com.microsoft.playwright.BrowserContext; import com.microsoft.playwright.BrowserType; @@ -10,6 +11,8 @@ import com.microsoft.playwright.Route; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import vip.mate.common.net.SsrfProperties; + +import java.net.URI; import org.springframework.stereotype.Component; import java.io.BufferedReader; @@ -390,11 +393,17 @@ public class BrowserLauncher { } /** - * 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. + * Re-run the SSRF guard on every http(s) request the page makes, so subresources, + * script-initiated fetches AND server-side redirect targets cannot reach blocked + * hosts (above all cloud-metadata endpoints) after the initial navigation URL + * already passed the one-shot check in the tool layer. + *

+ * Redirects need special handling: Playwright follows 3xx responses internally on + * {@code route.resume()} without re-invoking this handler, so a public page that + * 302s to a metadata IP would slip through. For navigation requests we therefore + * fetch with {@code maxRedirects=0} and validate the {@code Location} of every hop + * before fulfilling, so each redirect target is checked. Non-network schemes + * (data:, blob:, about:, …) are not SSRF vectors and pass through untouched. */ private void installSsrfInterceptor(BrowserContext context) { if (!props.isSsrfCheckEnabled()) { @@ -407,19 +416,54 @@ public class BrowserLauncher { route.resume(); return; } + // 1. Validate the request URL itself (covers subresources and fetches, + // which are each delivered to this handler as their own request). 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(); + return; } 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(); + return; + } + // 2. Non-navigation requests carry no auto-followed redirect chain, so the + // URL check above is sufficient — resume normally. + if (!route.request().isNavigationRequest()) { + route.resume(); + return; + } + // 3. Navigation requests: follow redirects manually so each hop is checked. + try { + APIResponse resp = route.fetch(new Route.FetchOptions().setMaxRedirects(0)); + int status = resp.status(); + if (status >= 300 && status < 400) { + String location = resp.headers().get("location"); + if (location != null && !location.isBlank()) { + String target = URI.create(reqUrl).resolve(location.trim()).toString(); + UrlSafetyChecker.check(target, ssrfProperties.getSsrfAllowlist(), + props.isAllowPrivateNetwork()); + } + } + // Hand the (possibly-3xx) response back; the browser follows any safe + // redirect, whose next hop re-enters this handler and is re-validated. + route.fulfill(new Route.FulfillOptions().setResponse(resp)); + } catch (SecurityException se) { + log.warn("[BrowserLauncher] SSRF interceptor blocked redirect from url={}: {}", + reqUrl, se.getMessage()); + route.abort(); + } catch (Exception e) { + // Manual fetch/proxy failed (network, unsupported response, …). The + // request URL itself already passed the check, so fall back to normal + // handling rather than wedging the page. + try { + route.resume(); + } catch (Exception ignore) { + // route may already be consumed by the failed fetch; nothing to do. + } } }); }