Skip to content

feat(core): one definition of what a redirect hop must pass (RedirectGuard) - #21

Merged
jlc488 merged 2 commits into
mainfrom
feat/core-redirect-guard
Aug 8, 2026
Merged

feat(core): one definition of what a redirect hop must pass (RedirectGuard)#21
jlc488 merged 2 commits into
mainfrom
feat/core-redirect-guard

Conversation

@jlc488

@jlc488 jlc488 commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

말씀하신 "어댑터로 분리하지 말고 코어로 옮기면 되지 않냐"에 대한 답입니다. 대체로 맞고, 나뉘는 지점을 짚을 값어치가 있습니다.

무엇이 옮겨지고 무엇이 못 옮겨지나

  • 검사 자체는 이미 코어에 있었습니다 (UrlPolicy.validate). 그게 문제가 아니었습니다.
  • 못 옮기는 건 가로채는 지점입니다. JS는 safeFetch가 리다이렉트 루프를 소유해서 "홉에서 뭘 검사하나"를 한 번만 씁니다. JVM은 어댑터가 남의 클라이언트를 감싸고 루프는 그 클라이언트 것입니다.
  • 옮길 수 있고 옮겨야 하는 건 결정입니다. 어댑터마다 즉흥적이었으니까요:
어댑터 홉에서 재검증하던 것
httpclient5 스킴 + DNS — 포트·userinfo·IP-리터럴 없음
okhttp 호스트 + 사설 IP (Dns 계층) — 그 외 없음
jdkhttp 아무것도 안 함

허용된 호스트에서 튕겨 나가는 리다이렉트가 SSRF의 실제 형태입니다. 첫 요청보다 약하게 검사되는 홉은 단계만 늘어난 구멍입니다.

변경

coreRedirectGuard가 단일 정의입니다. 정책 전체를 적용하고 blocked_redirect로 재던짐. origin 비교·fetch 스펙 메서드 강등 규칙·자격증명 헤더 목록도 함께 둬서 어댑터가 그것들까지 재유도하지 않게 했습니다.

jdkhttp — 말씀하신 1번. SsrfGuardedHttpClient가 루프를 직접 돕니다. JS와 같은 시맨틱(303·POST301/302GET 강등 + 본문 폐기, origin 넘으면 자격증명 제거, maxRedirects 기본 5).

Redirect.NEVER delegate를 요구하고 아니면 던집니다. 내부에서 리다이렉트를 따라가는 delegate 위에서는 이 클래스가 자기 계약을 지킬 수 없습니다 — 조용히 아무것도 안 하는 가드보다 시끄럽게 실패하는 게 낫습니다. 실제 루프백 서버 대상 테스트 8개로 검증했습니다.

httpclient5SafeRedirectStrategyUrlPolicy를 받아 같은 이음매를 호출. 3인자 생성자는 deprecated로 남겨 기존 코드가 컴파일됩니다.

OkHttp는 일부러 뺐습니다 — 시도했다가 틀린 걸 확인했습니다

network interceptor를 만들었는데 테스트가 SocketException: Network is unreachable로 실패했습니다. OkHttp는 연결이 맺어진 뒤에 network interceptor를 호출합니다. 요청이 이미 메타데이터 주소에 닿은 뒤였습니다.

이음매처럼 보였지만 아니었습니다. SSRF에서는 연결 자체가 공격입니다. 제대로 닫으려면 jdkhttp와 같은 루프 처리가 필요하고 그건 그 어댑터의 계약을 바꾸는 일이라, 반쯤 해두지 않고 별건으로 뺐습니다. 만들었던 클래스는 지웠습니다.

검증

./gradlew build → BUILD SUCCESSFUL
전 모듈 237 tests (기존 231), failures=0 errors=0

CHANGELOG 3표면 갱신. 미출시 3.2.0에 들어갑니다.

PR #20(스캐너 수정)과 독립입니다 — 둘 다 main 기준이고 순서 무관하게 머지 가능합니다.

jlc488 added 2 commits August 9, 2026 01:24
…Guard)

Answers a question the owner asked while reviewing the parity audit: why
fix redirect re-validation per adapter instead of moving it into core?
Mostly right — and the split is worth naming.

The CHECK already lived in core (UrlPolicy.validate). What could not move
is the interception point: the JS sibling owns its redirect loop, so it
writes "what do we check on a hop" once, while on the JVM each adapter
wraps someone else's client and the client owns the loop. What can and
must move is the DECISION, because each adapter was improvising it:

  httpclient5   scheme + DNS — not port, userinfo, IP-literal
  okhttp        host + private IP (Dns layer) — nothing else
  jdkhttp       NOTHING; the JDK client follows redirects internally

A redirect off an allowlisted host is the shape SSRF actually takes. A
hop checked more weakly than the first request is a hole with extra
steps.

core: RedirectGuard is now the single definition — the full policy,
re-thrown as blocked_redirect — plus the origin comparison, the
fetch-spec method-downgrade rule and the credential-header list, so
adapters stop re-deriving those too.

jdkhttp: SsrfGuardedHttpClient drives the loop itself, with the same
semantics as the JS sibling (303 and POST 301/302 downgrade to GET and
drop the body, credentials stripped cross-origin, maxRedirects bounds the
chain). It REQUIRES a delegate built with Redirect.NEVER and throws
otherwise: a delegate that follows redirects internally would bypass the
policy on every hop, and this class cannot honour its contract over one.
Failing loudly beats a guard that quietly does nothing. Eight tests drive
it against a real loopback server.

httpclient5: SafeRedirectStrategy takes the UrlPolicy and calls the seam.
The three-arg constructor is deprecated and keeps the old scheme-only
behaviour so existing code compiles.

OkHttp is deliberately NOT included. I wrote a network interceptor for it
and the tests failed with "Network is unreachable" — because OkHttp
invokes network interceptors AFTER the connection is established, so the
request had already reached the metadata address. It looked like the
seam and is not one; for SSRF the connection IS the attack. Closing it
needs the same loop treatment as jdkhttp, which changes that adapter's
contract, so it is tracked separately rather than half-done here.

Verified: ./gradlew build BUILD SUCCESSFUL, 237 tests across all modules
(was 231), failures=0 errors=0. Changelog updated in all three surfaces.
# Conflicts:
#	CHANGELOG.md
#	docs/changelog.ko.md
#	docs/changelog.md
@jlc488
jlc488 merged commit 74b4bf8 into main Aug 8, 2026
1 check passed
@jlc488
jlc488 deleted the feat/core-redirect-guard branch August 8, 2026 16:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant