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
AdapterRe-checked
httpclient5scheme + DNS only — not port, userinfo or IP-literal
okhttphost allowlist + private IP (via the {@code Dns} layer) only
jdkhttpnothing — 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"); + } +}