Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions docs/changelog.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <h2>Why this lives in core</h2>
* 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 <i>decision</i> can, and must: before this
* class existed each adapter improvised, and they disagreed.
*
* <table>
* <caption>What each adapter re-checked per hop before this class</caption>
* <tr><th>Adapter</th><th>Re-checked</th></tr>
* <tr><td>httpclient5</td><td>scheme + DNS only — not port, userinfo or IP-literal</td></tr>
* <tr><td>okhttp</td><td>host allowlist + private IP (via the {@code Dns} layer) only</td></tr>
* <tr><td>jdkhttp</td><td>nothing — the JDK client followed redirects internally</td></tr>
* </table>
*
* 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.
*
* <h2>What a hop must pass</h2>
* 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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -36,11 +39,31 @@ public final class SafeRedirectStrategy implements RedirectStrategy {
private final SafeDnsResolver dnsResolver;
private final Iterable<String> allowedSchemes;
private final SsrfGuardMetrics metrics;
private final UrlPolicy policy;

public SafeRedirectStrategy(SafeDnsResolver dnsResolver, Iterable<String> 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<String> 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<String> allowedSchemes, SsrfGuardMetrics metrics) {
this(dnsResolver, allowedSchemes, metrics, null);
}

@Override
Expand All @@ -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");
Expand Down
Loading