diff --git a/CHANGELOG.md b/CHANGELOG.md
index a0a23ac..33a9ccf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,16 @@ The source of truth for the entries below is [docs/changelog.md](docs/changelog.
This is the same shape as the uppercase-scheme bypass fixed in 3.1.1 — a filter at the collection stage letting a URL skip validation entirely — and was found by the first JVM ↔ JS parity audit. Affects `ssrf-guard-springai` and `ssrf-guard-langchain4j`.
+- **Redirect hops now get the same checks as the first request.** Each adapter previously improvised what to re-validate on a hop, and they disagreed: `httpclient5` re-checked the scheme and re-ran DNS but not port, userinfo or IP-literal rules; `jdkhttp` re-checked **nothing**, because the JDK client follows redirects internally and gives no hook. A redirect off an allowlisted host is the shape SSRF actually takes, so a hop with a weaker check than the first request is a hole with extra steps.
+
+ New `RedirectGuard` in core is the single definition of what a hop must pass — the full `UrlPolicy`, re-thrown as `blocked_redirect`. The loop itself cannot move to core the way it does in the JS sibling, because on the JVM each client owns its own redirect loop; the *decision* does.
+
+ `SsrfGuardedHttpClient` (jdkhttp) now follows and re-validates redirects itself, with fetch-specification semantics matching the JS sibling: `303` (and `301`/`302` on `POST`) downgrade to `GET` and drop the body, credential headers are stripped when a hop crosses an origin, and `maxRedirects` (default 5) bounds the chain. It **requires a delegate built with `HttpClient.Redirect.NEVER`** and throws `IllegalArgumentException` otherwise — a delegate that follows redirects internally would bypass the policy on every hop, and failing loudly beats a guard that quietly does nothing.
+
+ `SafeRedirectStrategy` (httpclient5) takes the `UrlPolicy` and calls the same seam. The three-argument constructor is deprecated; it keeps the pre-3.2.0 scheme-only behaviour so existing code compiles.
+
+ Found by the first JVM ↔ JS parity audit. **OkHttp is not covered by this change** — its `Dns` layer still re-checks the host allowlist and private IPs per hop, but scheme, port, userinfo and IP-literal rules are not re-applied. A network interceptor looked like the seam and is not one: OkHttp invokes it *after* the connection is established, so the request has already reached the internal host. Closing it properly needs the same loop treatment as jdkhttp and is tracked separately.
+
- **Tool-input URLs that `java.net.URI` cannot parse no longer skip validation.** Whole-string URLs with surrounding whitespace are trimmed before parsing, and URLs whose path contains `URI`-illegal characters (`/a[0]`) are re-validated on `scheme://authority` alone instead of being silently skipped. Affects `ssrf-guard-springai` and `ssrf-guard-langchain4j`.
### Migration
diff --git a/docs/changelog.ko.md b/docs/changelog.ko.md
index a16cb2b..89d71df 100644
--- a/docs/changelog.ko.md
+++ b/docs/changelog.ko.md
@@ -20,6 +20,16 @@ ssrf-guard의 주요 변경 사항을 기록합니다.
이것은 3.1.1에서 고친 대문자 스킴 우회와 **같은 형태**입니다 — 수집 단계의 필터가 URL을 검증에서 통째로 빠져나가게 하는 것 — 그리고 첫 JVM ↔ JS 정합성 감사에서 발견됐습니다. `ssrf-guard-springai`, `ssrf-guard-langchain4j` 영향.
+- **리다이렉트 홉이 첫 요청과 동일한 검사를 받습니다.** 이전에는 어댑터마다 홉에서 무엇을 재검증할지 즉흥적으로 정했고, 서로 달랐습니다: `httpclient5`는 스킴 재검사 + DNS 재실행만 하고 포트·userinfo·IP-리터럴은 안 봤고, `jdkhttp`는 **아무것도** 안 했습니다(JDK 클라이언트가 내부에서 리다이렉트를 따라가고 훅을 주지 않기 때문). 허용된 호스트에서 튕겨 나가는 리다이렉트야말로 SSRF의 실제 형태라, 첫 요청보다 약한 검사를 받는 홉은 단계만 늘어난 구멍입니다.
+
+ 코어에 신설한 `RedirectGuard`가 홉이 통과해야 할 것의 **단일 정의**입니다 — `UrlPolicy` 전체를 적용하고 `blocked_redirect`로 다시 던집니다. 루프 자체는 JS 자매처럼 코어로 옮길 수 없습니다(JVM에서는 각 클라이언트가 자기 루프를 소유). 옮길 수 있고 옮겨야 하는 건 **결정**입니다.
+
+ `SsrfGuardedHttpClient`(jdkhttp)가 이제 리다이렉트를 직접 따라가며 재검증합니다. fetch 스펙 시맨틱을 따르며 JS 자매와 동일합니다: `303`(및 `POST`의 `301`/`302`)은 `GET`으로 강등하고 본문을 버리며, 홉이 origin을 넘으면 자격증명 헤더를 제거하고, `maxRedirects`(기본 5)가 체인을 묶습니다. **`HttpClient.Redirect.NEVER`로 만든 delegate를 요구**하며 아니면 `IllegalArgumentException`을 던집니다 — 내부에서 리다이렉트를 따라가는 delegate는 모든 홉에서 정책을 우회시키고, 조용히 아무것도 안 하는 가드보다 시끄럽게 실패하는 편이 낫습니다.
+
+ `SafeRedirectStrategy`(httpclient5)는 `UrlPolicy`를 받아 같은 이음매를 호출합니다. 3인자 생성자는 deprecated이며 3.2.0 이전의 스킴 전용 동작을 유지해 기존 코드가 그대로 컴파일됩니다.
+
+ 첫 JVM ↔ JS 정합성 감사에서 발견됐습니다. **OkHttp는 이번 변경 범위 밖입니다** — `Dns` 계층이 홉마다 호스트 허용 목록과 사설 IP는 재검사하지만 스킴·포트·userinfo·IP-리터럴은 재적용되지 않습니다. network interceptor가 그 이음매처럼 보였지만 아니었습니다: OkHttp는 **연결이 맺어진 뒤에** 호출하므로 요청이 이미 내부 호스트에 닿습니다. 제대로 닫으려면 jdkhttp와 같은 루프 처리가 필요하고 별건으로 추적합니다.
+
- **`java.net.URI`가 파싱하지 못하는 툴 입력 URL이 검증을 건너뛰지 않도록 수정.** `JsonToolInputGuard`의 수집 단계 공백 두 개 수정: (1) 앞뒤 공백이 있는 whole-string URL(`" http://10.0.0.5/ "`)이 접두사 검사는 통과하지만 `URI` 파싱에 실패해 조용히 건너뛰어짐 — 이제 파싱 전에 trim; (2) 브라우저는 인코딩 없이 보내지만 `java.net.URI`는 거부하는 문자가 경로에 있는 URL(`http://10.0.0.5/a[0]`)도 파싱 실패로 검증을 건너뜀 — 정책은 `scheme://authority`만 판단하므로 authority 이후를 잘라내고 재시도. `ssrf-guard-springai`, `ssrf-guard-langchain4j` 영향.
### Migration
diff --git a/docs/changelog.md b/docs/changelog.md
index f2380ca..9e9f200 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -20,6 +20,16 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
This is the same shape as the uppercase-scheme bypass fixed in 3.1.1 — a filter at the collection stage letting a URL skip validation entirely — and was found by the first JVM ↔ JS parity audit. Affects `ssrf-guard-springai` and `ssrf-guard-langchain4j`.
+- **Redirect hops now get the same checks as the first request.** Each adapter previously improvised what to re-validate on a hop, and they disagreed: `httpclient5` re-checked the scheme and re-ran DNS but not port, userinfo or IP-literal rules; `jdkhttp` re-checked **nothing**, because the JDK client follows redirects internally and gives no hook. A redirect off an allowlisted host is the shape SSRF actually takes, so a hop with a weaker check than the first request is a hole with extra steps.
+
+ New `RedirectGuard` in core is the single definition of what a hop must pass — the full `UrlPolicy`, re-thrown as `blocked_redirect`. The loop itself cannot move to core the way it does in the JS sibling, because on the JVM each client owns its own redirect loop; the *decision* does.
+
+ `SsrfGuardedHttpClient` (jdkhttp) now follows and re-validates redirects itself, with fetch-specification semantics matching the JS sibling: `303` (and `301`/`302` on `POST`) downgrade to `GET` and drop the body, credential headers are stripped when a hop crosses an origin, and `maxRedirects` (default 5) bounds the chain. It **requires a delegate built with `HttpClient.Redirect.NEVER`** and throws `IllegalArgumentException` otherwise — a delegate that follows redirects internally would bypass the policy on every hop, and failing loudly beats a guard that quietly does nothing.
+
+ `SafeRedirectStrategy` (httpclient5) takes the `UrlPolicy` and calls the same seam. The three-argument constructor is deprecated; it keeps the pre-3.2.0 scheme-only behaviour so existing code compiles.
+
+ Found by the first JVM ↔ JS parity audit. **OkHttp is not covered by this change** — its `Dns` layer still re-checks the host allowlist and private IPs per hop, but scheme, port, userinfo and IP-literal rules are not re-applied. A network interceptor looked like the seam and is not one: OkHttp invokes it *after* the connection is established, so the request has already reached the internal host. Closing it properly needs the same loop treatment as jdkhttp and is tracked separately.
+
- **Tool-input URLs that `java.net.URI` cannot parse no longer skip validation.** Two collection-time gaps fixed in `JsonToolInputGuard`: (1) a whole-string URL with surrounding whitespace (`" http://10.0.0.5/ "`) passed the prefix test but failed `URI` parsing and was silently skipped — candidates are now trimmed before parsing; (2) a URL whose path contains characters browsers send unencoded but `java.net.URI` rejects (`http://10.0.0.5/a[0]`) also parse-failed and skipped validation — the guard now retries with everything after the authority dropped, since the policy only judges `scheme://authority`. Affects `ssrf-guard-springai` and `ssrf-guard-langchain4j`.
### Migration
diff --git a/ssrf-guard-core/src/main/java/kr/devslab/ssrfguard/core/RedirectGuard.java b/ssrf-guard-core/src/main/java/kr/devslab/ssrfguard/core/RedirectGuard.java
new file mode 100644
index 0000000..b4aba55
--- /dev/null
+++ b/ssrf-guard-core/src/main/java/kr/devslab/ssrfguard/core/RedirectGuard.java
@@ -0,0 +1,110 @@
+package kr.devslab.ssrfguard.core;
+
+import java.net.URI;
+import java.util.Locale;
+
+/**
+ * The one definition of what a redirect hop must pass, shared by every
+ * adapter.
+ *
+ *
Why this lives in core
+ * The JS sibling ({@code @devslab/ssrf-guard-js}) owns its own redirect
+ * loop, so "what do we check on a hop" is written once. On the JVM each
+ * adapter wraps someone else's client, and the client owns the loop — so
+ * the loop cannot move here. The decision can, and must: before this
+ * class existed each adapter improvised, and they disagreed.
+ *
+ *
+ * What each adapter re-checked per hop before this class
+ * | Adapter | Re-checked |
+ * | httpclient5 | scheme + DNS only — not port, userinfo or IP-literal |
+ * | okhttp | host allowlist + private IP (via the {@code Dns} layer) only |
+ * | jdkhttp | nothing — the JDK client followed redirects internally |
+ *
+ *
+ * A redirect is the whole point of an SSRF guard: the attacker controls an
+ * allowlisted host's response and points it somewhere else. A hop that gets
+ * a weaker check than the first request is a hole with extra steps.
+ *
+ * What a hop must pass
+ * The full {@link UrlPolicy} — the same checks the first request gets.
+ * Failures are re-thrown as {@link BlockReason#BLOCKED_REDIRECT} so callers
+ * and metrics can tell "the request you made was refused" from "something
+ * tried to bounce you elsewhere", with the original rule named in the
+ * message.
+ */
+public final class RedirectGuard {
+
+ private RedirectGuard() {
+ }
+
+ /**
+ * Re-validate one redirect target against the full policy.
+ *
+ * @param policy the same policy the first request was validated with
+ * @param location the resolved absolute redirect target
+ * @return {@code location}, so call sites can inline this
+ * @throws SsrfGuardException with {@link BlockReason#BLOCKED_REDIRECT}
+ */
+ public static URI validateHop(UrlPolicy policy, URI location) {
+ if (location == null) {
+ throw new SsrfGuardException(BlockReason.BLOCKED_REDIRECT, null, null,
+ "Blocked redirect: no location");
+ }
+ try {
+ policy.validate(location);
+ } catch (SsrfGuardException e) {
+ throw new SsrfGuardException(BlockReason.BLOCKED_REDIRECT, e.scheme(), e.host(),
+ "Blocked redirect: " + e.getMessage());
+ }
+ return location;
+ }
+
+ /**
+ * Whether a redirect from {@code from} to {@code to} crosses an origin,
+ * in which case credentials must not be replayed. Compares scheme, host
+ * and effective port — a scheme change alone moves the origin, and so
+ * does {@code https://h/} to {@code https://h:8443/}.
+ */
+ public static boolean crossOrigin(URI from, URI to) {
+ if (from == null || to == null) return true;
+ return !equalsIgnoreCaseNullSafe(from.getScheme(), to.getScheme())
+ || !equalsIgnoreCaseNullSafe(from.getHost(), to.getHost())
+ || effectivePort(from) != effectivePort(to);
+ }
+
+ /**
+ * Per the fetch specification's redirect handling, which the JS sibling
+ * follows: {@code 303} always becomes {@code GET}, and {@code 301}/
+ * {@code 302} downgrade a {@code POST}. The body must not be replayed
+ * in either case.
+ */
+ public static boolean downgradesToGet(int status, String method) {
+ String m = method == null ? "GET" : method.toUpperCase(Locale.ROOT);
+ if (status == 303) return !m.equals("GET") && !m.equals("HEAD");
+ return (status == 301 || status == 302) && m.equals("POST");
+ }
+
+ /** Headers that must be dropped when a redirect changes origin. */
+ public static boolean isCredentialHeader(String name) {
+ if (name == null) return false;
+ String n = name.toLowerCase(Locale.ROOT);
+ return n.equals("authorization") || n.equals("proxy-authorization") || n.equals("cookie");
+ }
+
+ private static int effectivePort(URI uri) {
+ int port = uri.getPort();
+ if (port != -1) return port;
+ String scheme = uri.getScheme();
+ if (scheme == null) return -1;
+ return switch (scheme.toLowerCase(Locale.ROOT)) {
+ case "http", "ws" -> 80;
+ case "https", "wss" -> 443;
+ default -> -1;
+ };
+ }
+
+ private static boolean equalsIgnoreCaseNullSafe(String a, String b) {
+ return a == null ? b == null : a.equalsIgnoreCase(b);
+ }
+}
diff --git a/ssrf-guard-httpclient5/src/main/java/kr/devslab/ssrfguard/httpclient5/SafeRedirectStrategy.java b/ssrf-guard-httpclient5/src/main/java/kr/devslab/ssrfguard/httpclient5/SafeRedirectStrategy.java
index c3469b5..21b6b2f 100644
--- a/ssrf-guard-httpclient5/src/main/java/kr/devslab/ssrfguard/httpclient5/SafeRedirectStrategy.java
+++ b/ssrf-guard-httpclient5/src/main/java/kr/devslab/ssrfguard/httpclient5/SafeRedirectStrategy.java
@@ -1,6 +1,9 @@
package kr.devslab.ssrfguard.httpclient5;
import kr.devslab.ssrfguard.core.BlockReason;
+import kr.devslab.ssrfguard.core.RedirectGuard;
+import kr.devslab.ssrfguard.core.SsrfGuardException;
+import kr.devslab.ssrfguard.core.UrlPolicy;
import kr.devslab.ssrfguard.core.SsrfGuardMetrics;
import org.apache.hc.client5.http.RedirectException;
import org.apache.hc.client5.http.impl.DefaultRedirectStrategy;
@@ -36,11 +39,31 @@ public final class SafeRedirectStrategy implements RedirectStrategy {
private final SafeDnsResolver dnsResolver;
private final Iterable allowedSchemes;
private final SsrfGuardMetrics metrics;
+ private final UrlPolicy policy;
- public SafeRedirectStrategy(SafeDnsResolver dnsResolver, Iterable allowedSchemes, SsrfGuardMetrics metrics) {
+ /**
+ * @param policy the same policy the first request is validated with. When
+ * {@code null} the hop falls back to the pre-3.2.0
+ * scheme-only check — kept so the older three-argument
+ * constructor keeps compiling, not because it is a good
+ * idea.
+ */
+ public SafeRedirectStrategy(SafeDnsResolver dnsResolver, Iterable allowedSchemes,
+ SsrfGuardMetrics metrics, UrlPolicy policy) {
this.dnsResolver = dnsResolver;
this.allowedSchemes = allowedSchemes;
this.metrics = metrics;
+ this.policy = policy;
+ }
+
+ /**
+ * @deprecated pass the {@link UrlPolicy} so redirect hops get the same
+ * checks as the first request. Without it, port, userinfo and
+ * IP-literal rules are not re-applied on a hop.
+ */
+ @Deprecated(since = "3.2.0")
+ public SafeRedirectStrategy(SafeDnsResolver dnsResolver, Iterable allowedSchemes, SsrfGuardMetrics metrics) {
+ this(dnsResolver, allowedSchemes, metrics, null);
}
@Override
@@ -58,21 +81,38 @@ public URI getLocationURI(HttpRequest request, HttpResponse response, HttpContex
}
String scheme = location.getScheme();
- boolean schemeAllowed = false;
- if (scheme != null) {
- for (String s : allowedSchemes) {
- if (s.equalsIgnoreCase(scheme)) {
- schemeAllowed = true;
- break;
+ String host = location.getHost();
+
+ // The FULL policy, via the shared core seam — scheme, host, port,
+ // userinfo and IP-literal, exactly what the first request got. This
+ // used to check the scheme alone and leave the rest to the resolver,
+ // so a hop to an allowlisted host on a blocked port, or to a public
+ // IP literal, was followed. See RedirectGuard for why the decision
+ // lives in core rather than here.
+ if (policy != null) {
+ try {
+ RedirectGuard.validateHop(policy, location);
+ } catch (SsrfGuardException e) {
+ // UrlPolicy already recorded the metric and logged the rule.
+ throw new RedirectException(e.getMessage());
+ }
+ } else {
+ // Deprecated constructor path: scheme only, as before 3.2.0.
+ boolean schemeAllowed = false;
+ if (scheme != null) {
+ for (String s : allowedSchemes) {
+ if (s.equalsIgnoreCase(scheme)) {
+ schemeAllowed = true;
+ break;
+ }
}
}
- }
- if (!schemeAllowed) {
- recordBlocked(BlockReason.BLOCKED_REDIRECT, scheme, location.getHost());
- throw new RedirectException("Blocked redirect scheme: " + scheme);
+ if (!schemeAllowed) {
+ recordBlocked(BlockReason.BLOCKED_REDIRECT, scheme, location.getHost());
+ throw new RedirectException("Blocked redirect scheme: " + scheme);
+ }
}
- String host = location.getHost();
if (host == null) {
recordBlocked(BlockReason.BLOCKED_REDIRECT, scheme, null);
throw new RedirectException("Blocked redirect: empty host");
diff --git a/ssrf-guard-httpclient5/src/main/java/kr/devslab/ssrfguard/httpclient5/SsrfGuardHttpClient5AutoConfiguration.java b/ssrf-guard-httpclient5/src/main/java/kr/devslab/ssrfguard/httpclient5/SsrfGuardHttpClient5AutoConfiguration.java
index 06b5b58..731c7b4 100644
--- a/ssrf-guard-httpclient5/src/main/java/kr/devslab/ssrfguard/httpclient5/SsrfGuardHttpClient5AutoConfiguration.java
+++ b/ssrf-guard-httpclient5/src/main/java/kr/devslab/ssrfguard/httpclient5/SsrfGuardHttpClient5AutoConfiguration.java
@@ -1,6 +1,7 @@
package kr.devslab.ssrfguard.httpclient5;
import kr.devslab.ssrfguard.core.HostPolicy;
+import kr.devslab.ssrfguard.core.UrlPolicy;
import kr.devslab.ssrfguard.core.NoOpSsrfGuardMetrics;
import kr.devslab.ssrfguard.core.SsrfGuardMetrics;
import kr.devslab.ssrfguard.core.SsrfGuardProperties;
@@ -49,11 +50,27 @@ SafeDnsResolver ssrfSafeDnsResolver(HostPolicy hostPolicy,
metrics.getIfAvailable(() -> NoOpSsrfGuardMetrics.INSTANCE));
}
+ @Bean
+ @ConditionalOnMissingBean
+ UrlPolicy ssrfUrlPolicy(HostPolicy hostPolicy,
+ SsrfGuardProperties props,
+ ObjectProvider metrics) {
+ return new UrlPolicy(
+ props.getAllowedSchemes(),
+ props.getAllowedPorts(),
+ hostPolicy,
+ props.isRejectIpLiteralHosts(),
+ props.isRejectUserInfo(),
+ metrics.getIfAvailable(() -> NoOpSsrfGuardMetrics.INSTANCE)
+ );
+ }
+
@Bean
@ConditionalOnMissingBean
CloseableHttpClient ssrfHttpClient(SafeDnsResolver dns,
SsrfGuardProperties props,
- ObjectProvider metrics) {
+ ObjectProvider metrics,
+ UrlPolicy policy) {
var builder = HttpClients.custom()
.setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create()
.setDnsResolver(dns)
@@ -64,7 +81,8 @@ CloseableHttpClient ssrfHttpClient(SafeDnsResolver dns,
builder.setRedirectStrategy(new SafeRedirectStrategy(
dns,
props.getAllowedSchemes(),
- metrics.getIfAvailable(() -> NoOpSsrfGuardMetrics.INSTANCE)
+ metrics.getIfAvailable(() -> NoOpSsrfGuardMetrics.INSTANCE),
+ policy
));
} else {
builder.disableRedirectHandling();
diff --git a/ssrf-guard-jdkhttp/src/main/java/kr/devslab/ssrfguard/jdkhttp/SsrfGuardedHttpClient.java b/ssrf-guard-jdkhttp/src/main/java/kr/devslab/ssrfguard/jdkhttp/SsrfGuardedHttpClient.java
index 1263409..5b7a9d9 100644
--- a/ssrf-guard-jdkhttp/src/main/java/kr/devslab/ssrfguard/jdkhttp/SsrfGuardedHttpClient.java
+++ b/ssrf-guard-jdkhttp/src/main/java/kr/devslab/ssrfguard/jdkhttp/SsrfGuardedHttpClient.java
@@ -1,15 +1,19 @@
package kr.devslab.ssrfguard.jdkhttp;
+import kr.devslab.ssrfguard.core.BlockReason;
import kr.devslab.ssrfguard.core.NetUtil;
+import kr.devslab.ssrfguard.core.RedirectGuard;
import kr.devslab.ssrfguard.core.SsrfGuardException;
import kr.devslab.ssrfguard.core.UrlPolicy;
import java.io.IOException;
import java.net.InetAddress;
+import java.net.URI;
import java.net.UnknownHostException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
+import java.util.Optional;
import java.util.concurrent.CompletableFuture;
/**
@@ -33,6 +37,20 @@
* HttpResponse.BodyHandlers.ofString());
* }
*
+ * Redirects
+ * The JDK client follows redirects internally when built with
+ * {@code Redirect.NORMAL} or {@code ALWAYS}, and gives no hook to inspect a
+ * hop — so before 3.2.0 a redirect off an allowlisted host was followed with
+ * NO validation at all. This wrapper therefore drives the redirect loop
+ * itself: the delegate is used with following disabled and each hop is
+ * re-validated through {@link RedirectGuard}, the same seam the other
+ * adapters use.
+ *
+ * Semantics follow the fetch specification, matching the JS sibling:
+ * {@code 303} (and {@code 301}/{@code 302} on {@code POST}) downgrade to
+ * {@code GET} and drop the body, and credential headers are stripped when a
+ * hop crosses an origin. {@code maxRedirects} bounds the chain.
+ *
*
What's NOT closed by this wrapper
* The JDK client uses {@code InetAddress.getAllByName} internally; we don't
* intercept that call. So a DNS rebinding attack could in theory
@@ -45,24 +63,58 @@
*/
public final class SsrfGuardedHttpClient {
+ /** Chain bound, matching the JS sibling's `maxRedirects` default. */
+ public static final int DEFAULT_MAX_REDIRECTS = 5;
+
private final HttpClient delegate;
private final UrlPolicy policy;
private final boolean blockPrivateNetworks;
+ private final int maxRedirects;
public SsrfGuardedHttpClient(HttpClient delegate, UrlPolicy policy) {
this(delegate, policy, true);
}
public SsrfGuardedHttpClient(HttpClient delegate, UrlPolicy policy, boolean blockPrivateNetworks) {
+ this(delegate, policy, blockPrivateNetworks, DEFAULT_MAX_REDIRECTS);
+ }
+
+ /**
+ * @param maxRedirects hops to follow before giving up. {@code 0} refuses
+ * to follow any redirect and hands the 3xx back.
+ */
+ public SsrfGuardedHttpClient(HttpClient delegate, UrlPolicy policy, boolean blockPrivateNetworks,
+ int maxRedirects) {
+ if (maxRedirects < 0) throw new IllegalArgumentException("maxRedirects must be >= 0");
+ // The guarantee this class makes is that every hop is validated. It
+ // cannot make that guarantee over a delegate that follows redirects
+ // itself: the JDK client would chase the Location header internally
+ // and this wrapper would never see the 3xx. Refusing here is loud;
+ // accepting it would be a guard that quietly does nothing on the one
+ // request shape SSRF is actually about.
+ if (delegate.followRedirects() != HttpClient.Redirect.NEVER) {
+ throw new IllegalArgumentException(
+ "SsrfGuardedHttpClient requires a delegate built with "
+ + "HttpClient.Redirect.NEVER (got " + delegate.followRedirects() + "). "
+ + "This wrapper follows and re-validates redirects itself; a delegate "
+ + "that follows them internally would bypass the policy on every hop.");
+ }
this.delegate = delegate;
this.policy = policy;
this.blockPrivateNetworks = blockPrivateNetworks;
+ this.maxRedirects = maxRedirects;
}
public HttpResponse send(HttpRequest request, HttpResponse.BodyHandler handler)
throws IOException, InterruptedException {
- guard(request);
- return delegate.send(request, handler);
+ HttpRequest current = request;
+ for (int hop = 0; ; hop++) {
+ guard(current);
+ HttpResponse response = delegate.send(current, handler);
+ HttpRequest next = nextHop(current, response, hop);
+ if (next == null) return response;
+ current = next;
+ }
}
public CompletableFuture> sendAsync(HttpRequest request, HttpResponse.BodyHandler handler) {
@@ -71,7 +123,91 @@ public CompletableFuture> sendAsync(HttpRequest request, Htt
} catch (SsrfGuardException e) {
return CompletableFuture.failedFuture(e);
}
- return delegate.sendAsync(request, handler);
+ return delegate.sendAsync(request, handler)
+ .thenCompose(response -> {
+ HttpRequest next;
+ try {
+ next = nextHop(request, response, 0);
+ } catch (SsrfGuardException e) {
+ return CompletableFuture.failedFuture(e);
+ }
+ if (next == null) return CompletableFuture.completedFuture(response);
+ return sendAsyncFrom(next, handler, 1);
+ });
+ }
+
+ private CompletableFuture> sendAsyncFrom(HttpRequest request,
+ HttpResponse.BodyHandler handler,
+ int hop) {
+ try {
+ guard(request);
+ } catch (SsrfGuardException e) {
+ return CompletableFuture.failedFuture(e);
+ }
+ return delegate.sendAsync(request, handler)
+ .thenCompose(response -> {
+ HttpRequest next;
+ try {
+ next = nextHop(request, response, hop);
+ } catch (SsrfGuardException e) {
+ return CompletableFuture.failedFuture(e);
+ }
+ if (next == null) return CompletableFuture.completedFuture(response);
+ return sendAsyncFrom(next, handler, hop + 1);
+ });
+ }
+
+ /**
+ * Build the next hop's request, or {@code null} when the response is not
+ * a redirect this client should follow. Every hop passes the full policy
+ * through {@link RedirectGuard} before it is returned, so a caller can
+ * never send a request this method produced without it having been
+ * validated.
+ */
+ private HttpRequest nextHop(HttpRequest current, HttpResponse> response, int hop) {
+ int status = response.statusCode();
+ if (status < 300 || status >= 400 || status == 304) return null;
+
+ Optional location = response.headers().firstValue("location");
+ // A 3xx with no Location is not a redirect anyone can follow — hand it
+ // back rather than inventing a target.
+ if (location.isEmpty() || location.get().isBlank()) return null;
+
+ if (hop >= maxRedirects) {
+ throw new SsrfGuardException(BlockReason.BLOCKED_REDIRECT, current.uri().getScheme(),
+ current.uri().getHost(), "Too many redirects: " + maxRedirects);
+ }
+
+ URI target;
+ try {
+ target = current.uri().resolve(location.get().trim());
+ } catch (IllegalArgumentException e) {
+ throw new SsrfGuardException(BlockReason.BLOCKED_REDIRECT, current.uri().getScheme(),
+ current.uri().getHost(), "Blocked redirect: unparseable location");
+ }
+ RedirectGuard.validateHop(policy, target);
+
+ String method = current.method();
+ boolean toGet = RedirectGuard.downgradesToGet(status, method);
+ boolean crossOrigin = RedirectGuard.crossOrigin(current.uri(), target);
+
+ HttpRequest.Builder builder = HttpRequest.newBuilder(target);
+ current.timeout().ifPresent(builder::timeout);
+ current.version().ifPresent(builder::version);
+ current.headers().map().forEach((name, values) -> {
+ // Content-* headers describe a body that is not being replayed,
+ // and credentials must not cross an origin.
+ if (toGet && name.toLowerCase(java.util.Locale.ROOT).startsWith("content-")) return;
+ if (crossOrigin && RedirectGuard.isCredentialHeader(name)) return;
+ for (String value : values) builder.header(name, value);
+ });
+
+ if (toGet) {
+ builder.GET();
+ } else {
+ builder.method(method, current.bodyPublisher().orElseGet(HttpRequest.BodyPublishers::noBody));
+ }
+ return builder.build();
}
public HttpClient delegate() {
diff --git a/ssrf-guard-jdkhttp/src/test/java/kr/devslab/ssrfguard/jdkhttp/SsrfGuardedHttpClientRedirectTest.java b/ssrf-guard-jdkhttp/src/test/java/kr/devslab/ssrfguard/jdkhttp/SsrfGuardedHttpClientRedirectTest.java
new file mode 100644
index 0000000..471bc34
--- /dev/null
+++ b/ssrf-guard-jdkhttp/src/test/java/kr/devslab/ssrfguard/jdkhttp/SsrfGuardedHttpClientRedirectTest.java
@@ -0,0 +1,203 @@
+package kr.devslab.ssrfguard.jdkhttp;
+
+import com.sun.net.httpserver.HttpServer;
+import kr.devslab.ssrfguard.core.BlockReason;
+import kr.devslab.ssrfguard.core.HostPolicy;
+import kr.devslab.ssrfguard.core.NoOpSsrfGuardMetrics;
+import kr.devslab.ssrfguard.core.SsrfGuardException;
+import kr.devslab.ssrfguard.core.UrlPolicy;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * The JDK client gives no hook to inspect a redirect hop, so before 3.2.0 a
+ * redirect off an allowlisted host was followed with no validation at all.
+ * This wrapper now drives the loop itself; these tests run it against a real
+ * loopback server, because "does the guard see the hop" is a question about
+ * behaviour, not about the shape of the code.
+ */
+class SsrfGuardedHttpClientRedirectTest {
+
+ private HttpServer server;
+ private String base;
+ private final List pathsServed = new ArrayList<>();
+ private final AtomicReference lastMethod = new AtomicReference<>();
+ private final AtomicReference lastAuthHeader = new AtomicReference<>();
+
+ @BeforeEach
+ void startServer() throws IOException {
+ server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
+ server.createContext("/", exchange -> {
+ String path = exchange.getRequestURI().getPath();
+ pathsServed.add(path);
+ lastMethod.set(exchange.getRequestMethod());
+ lastAuthHeader.set(exchange.getRequestHeaders().getFirst("Authorization"));
+
+ String location = switch (path) {
+ case "/to-metadata" -> "http://169.254.169.254/latest/meta-data/";
+ case "/to-other-port" -> base.replace(portOf(base), "9") + "/end";
+ case "/to-self" -> "/to-self";
+ case "/hop" -> "/end";
+ default -> null;
+ };
+
+ if (location != null) {
+ exchange.getResponseHeaders().add("Location", location);
+ exchange.sendResponseHeaders(302, -1);
+ } else if (path.equals("/see-other")) {
+ exchange.getResponseHeaders().add("Location", "/end");
+ exchange.sendResponseHeaders(303, -1);
+ } else {
+ byte[] body = "done".getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(200, body.length);
+ exchange.getResponseBody().write(body);
+ }
+ exchange.close();
+ });
+ server.start();
+ base = "http://" + InetAddress.getLoopbackAddress().getHostAddress() + ":" + server.getAddress().getPort();
+ }
+
+ @AfterEach
+ void stopServer() {
+ server.stop(0);
+ }
+
+ private static String portOf(String url) {
+ return url.substring(url.lastIndexOf(':') + 1);
+ }
+
+ /** Loopback is the target, so the private-IP rule has to be off to test redirects at all. */
+ private UrlPolicy loopbackPolicy() {
+ String host = InetAddress.getLoopbackAddress().getHostAddress();
+ return new UrlPolicy(
+ Set.of("http", "https"),
+ Set.of(-1, 80, 443, server.getAddress().getPort()),
+ new HostPolicy(List.of(host), List.of()),
+ false, // the target IS an IP literal here
+ true,
+ NoOpSsrfGuardMetrics.INSTANCE
+ );
+ }
+
+ private SsrfGuardedHttpClient client(UrlPolicy policy) {
+ return new SsrfGuardedHttpClient(
+ HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build(),
+ policy, false);
+ }
+
+ @Test
+ void refuses_a_delegate_that_follows_redirects_itself() {
+ // Accepting one would mean the JDK chases Location internally and this
+ // wrapper never sees a hop — a guard that silently does nothing.
+ assertThatIllegalArgumentException().isThrownBy(() -> new SsrfGuardedHttpClient(
+ HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build(),
+ loopbackPolicy(), false))
+ .withMessageContaining("Redirect.NEVER");
+ }
+
+ @Test
+ void follows_an_allowed_redirect_and_returns_the_final_response() throws Exception {
+ var response = client(loopbackPolicy()).send(
+ HttpRequest.newBuilder(URI.create(base + "/hop")).build(),
+ HttpResponse.BodyHandlers.ofString());
+
+ assertThat(response.statusCode()).isEqualTo(200);
+ assertThat(response.body()).isEqualTo("done");
+ assertThat(pathsServed).containsExactly("/hop", "/end");
+ }
+
+ @Test
+ void blocks_a_redirect_to_cloud_metadata() {
+ // The hop the whole library exists for: an allowlisted host answering
+ // with a Location that points at the metadata service.
+ assertThatExceptionOfType(SsrfGuardException.class)
+ .isThrownBy(() -> client(loopbackPolicy()).send(
+ HttpRequest.newBuilder(URI.create(base + "/to-metadata")).build(),
+ HttpResponse.BodyHandlers.ofString()))
+ .matches(e -> e.reason() == BlockReason.BLOCKED_REDIRECT);
+
+ assertThat(pathsServed).containsExactly("/to-metadata");
+ }
+
+ @Test
+ void blocks_a_redirect_to_a_port_outside_the_policy() {
+ // Not caught before 3.2.0: the old hop check looked at the scheme and
+ // the host, so a port change on an allowlisted host was followed.
+ assertThatExceptionOfType(SsrfGuardException.class)
+ .isThrownBy(() -> client(loopbackPolicy()).send(
+ HttpRequest.newBuilder(URI.create(base + "/to-other-port")).build(),
+ HttpResponse.BodyHandlers.ofString()))
+ .matches(e -> e.reason() == BlockReason.BLOCKED_REDIRECT);
+ }
+
+ @Test
+ void gives_up_after_max_redirects() {
+ var client = new SsrfGuardedHttpClient(
+ HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build(),
+ loopbackPolicy(), false, 2);
+
+ assertThatExceptionOfType(SsrfGuardException.class)
+ .isThrownBy(() -> client.send(
+ HttpRequest.newBuilder(URI.create(base + "/to-self")).build(),
+ HttpResponse.BodyHandlers.ofString()))
+ .matches(e -> e.reason() == BlockReason.BLOCKED_REDIRECT)
+ .withMessageContaining("Too many redirects");
+ }
+
+ @Test
+ void downgrades_a_303_to_get_and_drops_the_body() throws Exception {
+ var response = client(loopbackPolicy()).send(
+ HttpRequest.newBuilder(URI.create(base + "/see-other"))
+ .POST(HttpRequest.BodyPublishers.ofString("payload"))
+ .build(),
+ HttpResponse.BodyHandlers.ofString());
+
+ assertThat(response.statusCode()).isEqualTo(200);
+ assertThat(lastMethod.get()).isEqualTo("GET");
+ }
+
+ @Test
+ void keeps_credentials_on_a_same_origin_hop() throws Exception {
+ client(loopbackPolicy()).send(
+ HttpRequest.newBuilder(URI.create(base + "/hop"))
+ .header("Authorization", "Bearer sekrit")
+ .build(),
+ HttpResponse.BodyHandlers.ofString());
+
+ // Same scheme, host and port — nothing to protect against here, and
+ // stripping would break ordinary authenticated redirects.
+ assertThat(lastAuthHeader.get()).isEqualTo("Bearer sekrit");
+ }
+
+ @Test
+ void maxRedirects_zero_hands_the_3xx_back_untouched() {
+ var client = new SsrfGuardedHttpClient(
+ HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build(),
+ loopbackPolicy(), false, 0);
+
+ assertThatExceptionOfType(SsrfGuardException.class)
+ .isThrownBy(() -> client.send(
+ HttpRequest.newBuilder(URI.create(base + "/hop")).build(),
+ HttpResponse.BodyHandlers.ofString()))
+ .withMessageContaining("Too many redirects");
+ }
+}