변경 이력¶
ssrf-guard의 주요 변경 사항을 기록합니다.
형식은 Keep a Changelog를 따르며, 본 프로젝트는 Semantic Versioning을 준수합니다.
[Unreleased]¶
[3.3.0] — 리다이렉트 홉 정합성과 스캐너 스킴 커버리지¶
보안¶
-
리다이렉트 홉이 첫 요청과 동일한 검사를 받습니다. 이전에는 어댑터마다 홉에서 무엇을 재검증할지 즉흥적으로 정했고, 서로 달랐습니다:
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)가 체인을 묶습니다.SafeRedirectStrategy(httpclient5)는UrlPolicy를 받아 같은 이음매를 호출합니다. 3인자 생성자는 deprecated이며 3.3.0 이전의 스킴 전용 동작을 유지해 기존 코드가 그대로 컴파일됩니다.첫 JVM ↔ JS 정합성 감사에서 발견됐습니다. OkHttp는 이번 변경 범위 밖입니다 —
Dns계층이 홉마다 호스트 허용 목록과 사설 IP는 재검사하지만 스킴·포트·userinfo·IP-리터럴은 재적용되지 않습니다. network interceptor가 그 이음매처럼 보였지만 아니었습니다: OkHttp는 연결이 맺어진 뒤에 호출하므로 요청이 이미 내부 호스트에 닿습니다. 제대로 닫으려면 jdkhttp와 같은 루프 처리가 필요하고 별건으로 추적합니다. -
LLM 툴 입력 스캐너가 비-
http(s)스킴과 프로토콜 상대 URL을 수집 단계에서 버리지 않습니다. 툴 입력의file://·ftp://·gopher://등 모든scheme://URL이 정책에 닿기도 전에 걸러져서,allowedSchemes에 의해 거부되는 대신 조용히 가드를 통과했습니다. 페치 시점에 호출자의 스킴을 물려받는 — 따라서 실제 페치 대상인 — 프로토콜 상대 참조(//evil.example/x)는 아예 수집되지 않았습니다.이제 수집은 관대하게 하고 판단은 정책이 합니다. JS 쪽
@devslab/ssrf-guard-js0.2.0에서 한 것과 같은 교정입니다.file://류는blocked_scheme이 되고,//host는 https로 해석된 것처럼 호스트 허용 목록으로 검증됩니다. authority가 없는 스킴(mailto:·urn:·data:)은 계속 무시합니다 — 검사할 호스트가 없고 페치 표면도 아닙니다.이것은 3.1.1에서 고친 대문자 스킴 우회와 같은 형태입니다 — 수집 단계의 필터가 URL을 검증에서 통째로 빠져나가게 하는 것.
ssrf-guard-springai,ssrf-guard-langchain4j영향.
Migration¶
파괴적 변경 1건, ssrf-guard-jdkhttp. SsrfGuardedHttpClient가 이제 HttpClient.Redirect.NEVER로 만든 delegate를 요구하고 아니면 IllegalArgumentException을 던집니다. 리다이렉트를 직접 따라가며 검증하기 때문입니다. 내부에서 따라가는 delegate는 모든 홉에서 정책을 우회시키므로, 조용히 아무것도 안 하는 가드가 되느니 시끄럽게 거부합니다.
HttpClient.newHttpClient()와 HttpClient.newBuilder().build()는 이미 NEVER라 대부분의 코드는 영향이 없습니다. Redirect.NORMAL이나 ALWAYS를 넘기고 있었다면 빼세요 — 지금까지 리다이렉트 검증을 전혀 못 받고 있었고, 이제 래퍼가 해줍니다.
나머지는 그대로 교체됩니다 — SafeRedirectStrategy의 옛 생성자는 deprecated 상태로 계속 컴파일되고, 스캐너 변경은 정책이 봤더라면 애초에 거부했을 URL만 거부합니다.
[3.2.0] — scanEmbedded: LLM 툴 입력 가드의 문장 중간 URL 탐지¶
Added¶
scanEmbedded— LLM 툴 입력 가드의 문장 중간 URL 탐지.JsonToolInputGuard에 opt-in 세 번째 생성자 인자 추가 (어댑터에는 대응 프로퍼티ssrf.guard.llm.scan-embedded, 기본false) — 툴 입력 문자열 안에 문장 중간으로 묻힌http(s)://URL("summarize http://169.254.169.254/ please")을 추출·검증. 프롬프트 인젝션 지시문이 취하는 전형적인 형태입니다. 기존 whole-string 스캐너에 순수 추가되며, 꼬리에 붙은 문장부호는 제거, 경로의 균형 잡힌 괄호(/wiki/Foo_(bar))는 보존, 앞 텍스트에 붙은 URL(seehttp://evil.com)도 탐지. 기본 동작 변경 없음. JS 자매 라이브러리@devslab/ssrf-guard-js0.5.0에서 포팅 — AskLinq 통합 피드백에서 나온 옵션입니다.
Security¶
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¶
v3.2.0으로 그대로 교체 — 소비자 코드 변경 없음. scanEmbedded는 기본 off라 ssrf.guard.llm.scan-embedded=true(자동 wrap) 또는 새 3-인자 JsonToolInputGuard 생성자(수동 배선)로 opt-in하기 전까지 기존 동작 그대로입니다. 파싱 시점 검증 누락 수정 2건은 무조건 적용 — URL이 파싱 불가라는 이유만으로 가드를 통과하던 툴 입력이 있었다면 이제 검증됩니다.
[3.1.1] — LLM 툴 입력 가드의 대문자 스킴 우회 수정¶
Security¶
- LLM 툴 입력 가드의 대문자 스킴 우회 수정.
JsonToolInputGuard가 후보 URL을 대소문자 구분(http:///https://접두사)으로 수집해서, 대문자 스킴으로 쓴 툴 입력 URL(HTTP://169.254.169.254/…,HtTpS://evil.com/)이 수집조차 되지 않고 정책 검증을 통째로 건너뛰었습니다 — 호스트 allowlist와 IP-literal 검사를 우회. URI 스킴은 대소문자를 구분하지 않으며(RFC 3986 §3.1) 바로 뒤의 스킴 검사는 이미equalsIgnoreCase를 사용하므로, 수집 단계만 영향받은 불일치였습니다. 이제 대소문자를 구분하지 않고 감지합니다. 툴 입력을 이 가드로 거르는ssrf-guard-springai,ssrf-guard-langchain4j모두 영향.
Migration¶
v3.1.1로 그대로 교체 — 소비자 코드 변경 없음. URL을 받는 LLM 툴을 노출하고 ssrf-guard-springai / ssrf-guard-langchain4j로 툴 입력을 거른다면, 이 수정을 반영하도록 업그레이드하세요.
[3.1.0] — LLM 코어 추출, LangChain4j, WebClient DNS 공백 메움, GraalVM hints¶
Added¶
ssrf-guard-llm— 신규. 모든 LLM 툴 어댑터가 공유하는 JSON 트리 walk + URL 추출 + 정책 검증을 담는 framework-agnostic 코어 모듈.ToolInputGuard(인터페이스)와JsonToolInputGuard(기본 구현) 노출. 이전에SsrfGuardedToolCallback내부에 있던 ~200줄 로직을 이쪽으로 통합.ssrf-guard-langchain4j— 신규. LangChain4j의ToolExecutor를 wrap하는 어댑터 모듈.ssrf-guard-springai와 동일한 LLM 에이전트 SSRF 표면을 LangChain4j 생태계 (Java LLM 양대 프레임워크 중 다른 하나) 대응. Spring Boot 사용자는BeanPostProcessor로 자동 wrap; 비-Spring/프로그래매틱 사용자용SsrfGuardedToolExecutors.wrap(...)헬퍼 제공.- GraalVM native-image 친화성.
ssrf-guard-llm이RuntimeHintsRegistrar를 등록 (META-INF/spring/aot.factories통해) — Spring Boot AOT 프로세서가 런타임 reflection surface (매 차단마다 Jackson이 직렬화하는SsrfBlockPayloadrecord, Jackson에 의해label이 읽히는BlockReasonenum)를 인식. 어댑터 모듈 (-springai,-langchain4j, 모든 Spring 자동설정)은 Spring Boot 3 AOT가 무료로 처리 —@Bean팩토리 메서드와BeanPostProcessor만 호스팅하므로.
Changed¶
ssrf-guard-springai가 thin adapter (~30줄)로 리팩토링. 새-llm모듈의JsonToolInputGuard에 위임. Public API 변경 없음 —SsrfGuardedToolCallback의 모든 생성자/메서드 시그니처 유지. v3.0.x 소비자는 API 변경 못 느낌 — 단지ssrf-guard-llm을 transitive로 받게 됨.- 에러 payload 형태 안정화. SSRF 차단 시 LLM이 보는 JSON 객체가 이제 typed
SsrfBlockPayloadrecord ({error, reason, url, message, guidance}) 기반. v3.0.x와 wire 호환 — 동일 필드명, 동일 값. 이전Map.of(...)형태의 JDK-내부ImmutableCollections.MapN백킹 타입이 AOT introspect 안 되던 문제 해결. wire shape에 의존한 스크립트 변경 불필요; 기존 substring 테스트도 그대로 통과.
Fixed¶
- WebClient DNS-time 방어 공백 메움. v3.0.x의
ssrf-guard-webclient는 URL-time 필터만 실행 — 화이트리스트를 통과한 호스트가 DNS 시점에 사설 IP로 resolve될 수 있었음 (전형적인 DNS 리바인딩 → 메타데이터 공격). 새SsrfGuardReactorAddressResolverGroup이 reactor-netty의AddressResolverGroup에 후킹해서 RestClient 모듈이 Apache HttpClientDnsResolver단계에서 적용하는 것과 동일한 사설 IP 필터를 적용. WebFlux 앱도 이제 차단형 RestClient 앱이 갖던 2단계 방어 (URL + DNS) 동등하게 받음. reactor-netty 클래스패스 의존 — 비-Netty WebFlux 백엔드 (Jetty Reactive, Helidon)는 URL-time 필터만 작동하고 connector 교체는 스킵.
Migration¶
v3.1.0로 그냥 올리기 — 소비자 코드 변경 없음. 메타 kr.devslab:ssrf-guard:3.1.0도 v3.0.x처럼 -core, -httpclient5, -restclient를 transitive로 끌어옴. 새 모듈 (-llm, -langchain4j)은 opt-in — 좌표 추가 안 하면 따라오지 않음.
기존 SecurityException catch 코드는 그대로 동작 (SsrfGuardException이 여전히 SecurityException 서브클래스). 구조화 e.reason() 접근하려면 SsrfGuardException을 catch.
Native-image 소비자: kr.devslab:ssrf-guard-llm:3.1.0 (또는 transitive로 끌어오는 모듈) 추가하면 AOT 프로세서가 등록된 hints 자동 인식.
[3.0.1] — 메트릭 빈 classpath 게이트 수정¶
Fixed¶
ClassNotFoundException: io.micrometer.core.instrument.MeterRegistry—micrometer-core가 classpath에 없는 소비자가 부팅 실패.-restclient,-resttemplate(via-restclient),-webclient,-feign,-springai전부 영향. 메트릭 빈 팩토리 메서드가ObjectProvider<MeterRegistry>를 파라미터로 선언했고,ObjectProvider는 빈이 없을 때 우아하게 처리하지만 파라미터 타입 자체는 클래스 로드 시점에 JVM이 resolve하기 때문.- Micrometer 기반 메트릭 빈을 static inner
@Configuration으로 격리하고@ConditionalOnClass(name = "io.micrometer.core.instrument.MeterRegistry")(string form — Spring ASM 조건 평가기가 JVM 로드 없이 어노테이션 검사)으로 게이트. 외부 자동설정은@ConditionalOnMissingBean(SsrfGuardMetrics.class)조건으로NoOpSsrfGuardMetricsfallback@Bean등록.
Migration¶
v3.0.1로 그냥 올리기 — 소비자 코드 변경 없음. v3.0.0의 이 버그를 우회하려고 io.micrometer:micrometer-core를 빌드에 추가했었다면(메트릭 실제로 안 쓰면서) 이제 제거 가능.
[3.0.0] — 멀티모듈 + LLM 에이전트 SSRF 방어¶
v2.0.0 스타터는 단일 jar로 Spring RestClient만 지원했지만, v3.0.0은 HTTP 클라이언트 경계로 코드를 분할하고 모든 JVM HTTP 스택에 대한 모듈을 추가했으며, 지난 2년간 LLM 에이전트가 만들어온 SSRF 표면을 막는 Spring AI Tool 래퍼를 출시합니다.
Added — 새 모듈 (opt-in)¶
| 모듈 | 용도 |
|---|---|
ssrf-guard-core |
정책 / NetUtil / Micrometer 메트릭 인터페이스 — Spring 의존성 없음 |
ssrf-guard-httpclient5 |
Apache HttpClient 5 DnsResolver + RedirectStrategy (TOCTOU 차단) |
ssrf-guard-restclient |
Spring 6.1+ RestClient 자동설정 (v2.0.0의 surface가 별도 모듈로) |
ssrf-guard-resttemplate |
NEW — 엔터프라이즈/레거시용 Spring RestTemplate 자동설정 |
ssrf-guard-webclient |
NEW — Spring WebFlux WebClient ExchangeFilterFunction + 자동설정 |
ssrf-guard-feign |
NEW — Spring Cloud OpenFeign RequestInterceptor + 자동설정 |
ssrf-guard-springai |
NEW — Spring AI ToolCallback 래퍼. LLM이 실행하기 전 URL 형식 인자 검증. 2025+ 가장 핫한 SSRF 표면 |
ssrf-guard-jdkhttp |
NEW — java.net.http.HttpClient 래퍼 (Spring 없음, JDK 11+) |
ssrf-guard-okhttp |
NEW — OkHttp Interceptor + Dns (Spring 없음) |
ssrf-guard |
메타 아티팩트 — -core + -httpclient5 + -restclient 묶음으로 v2.0.0 호환 |
Added — 방어 강화¶
- IP 리터럴 호스트 거부 (
ssrf.guard.reject-ip-literal-hosts=true기본). 호스트가 어떤 형식의 IP 리터럴이든 — dotted decimal (127.0.0.1), bare decimal (2130706433), hex (0x7f000001), octal (0177.0.0.1), 부분 (127.1), IPv6 ([::1]) — URL 단계에서 DNS 전에 거부. 난독화된 IP 우회 클래스를 통째로 차단. - Userinfo 거부 (
ssrf.guard.reject-user-info=true기본).https://user:pass@host/...형식 거부 — 알려진 SSRF 우회 벡터이자 credential 누출 리스크. - IPv4-mapped IPv6 + 6to4 unmapping.
::ffff:10.0.0.5와2002:0a00::(10.0.0.0/8을 wrap한 6to4)이 이제 사설로 정확히 분류됨 — Java의isLoopbackAddress()가 놓치던 우회.
Added — 관찰성¶
- Micrometer 메트릭. 클래스패스에
MeterRegistry빈이 있으면 자동: - 구조화된 WARN 로그. 차단마다:
ssrf-guard: <message> (reason=blocked_private_ip, scheme=http, host=169.254.169.254).
Changed — BREAKING¶
- 패키지 변경. catch-all
kr.devslab.ssrfguard.security패키지의 타입들이 각 모듈의 패키지로 이동:
| v2.0.0 | v3.0.0 |
|---|---|
kr.devslab.ssrfguard.autoconfigure.SsrfGuardAutoConfiguration |
kr.devslab.ssrfguard.restclient.SsrfGuardRestClientAutoConfiguration |
kr.devslab.ssrfguard.autoconfigure.SsrfGuardProperties |
kr.devslab.ssrfguard.core.SsrfGuardProperties |
kr.devslab.ssrfguard.security.SsrfGuardInterceptor |
kr.devslab.ssrfguard.restclient.SsrfGuardClientHttpRequestInterceptor |
kr.devslab.ssrfguard.security.SafeDnsResolver |
kr.devslab.ssrfguard.httpclient5.SafeDnsResolver |
kr.devslab.ssrfguard.security.SafeRedirectStrategy |
kr.devslab.ssrfguard.httpclient5.SafeRedirectStrategy |
kr.devslab.ssrfguard.security.NetUtil |
kr.devslab.ssrfguard.core.NetUtil |
- SecurityException → SsrfGuardException. 모든 거부 경로가 SsrfGuardException을 던짐 (여전히 SecurityException 서브클래스 — v2.0.0 catch 블록은 계속 동작). BlockReason enum 태그를 노출. |
|
- 새 properties. ssrf.guard.reject-ip-literal-hosts, ssrf.guard.reject-user-info 기본 true — 끄면 v2.0.0 동작으로 복원. |
Migration¶
대부분 버전 올리고 재빌드하면 끝:
<dependency>
<groupId>kr.devslab</groupId>
<artifactId>ssrf-guard</artifactId>
<version>3.0.0</version>
</dependency>
ssrf-guard 메타 아티팩트가 -core, -httpclient5, -restclient를 transitive로 끌어와 v2.0.0 전체 surface 제공.
다른 HTTP 클라이언트를 쓰면 해당 모듈 선택:
<!-- RestTemplate -->
<dependency>
<groupId>kr.devslab</groupId>
<artifactId>ssrf-guard-resttemplate</artifactId>
<version>3.0.0</version>
</dependency>
<!-- WebClient -->
<dependency>
<groupId>kr.devslab</groupId>
<artifactId>ssrf-guard-webclient</artifactId>
<version>3.0.0</version>
</dependency>
<!-- Spring AI 툴 콜 -->
<dependency>
<groupId>kr.devslab</groupId>
<artifactId>ssrf-guard-springai</artifactId>
<version>3.0.0</version>
</dependency>
외부 호출에서 SecurityException catch 하던 코드는 그대로 동작 — SsrfGuardException extends SecurityException. 구조화 태그가 필요하면 SsrfGuardException catch 후 e.reason() 검사.
[2.0.0] — kr.devslab:ssrf-guard로 리브랜딩¶
Changed¶
- BREAKING — 좌표 변경.
com.devs.lab:ssrf-guard-spring-boot-starter→kr.devslab:ssrf-guard. 레거시 아티팩트는 Maven Central에 발행된 적 없으므로 v2.0.0이 첫 Central 공식 릴리즈. - BREAKING — 패키지 이름 변경.
devs.lab.ssrf.*→kr.devslab.ssrfguard.*:
| Old | New |
|---|---|
devs.lab.ssrf.config.SsrfGuardAutoConfiguration |
kr.devslab.ssrfguard.autoconfigure.SsrfGuardAutoConfiguration |
devs.lab.ssrf.security.SsrfGuardProperties |
kr.devslab.ssrfguard.autoconfigure.SsrfGuardProperties |
devs.lab.ssrf.security.SsrfGuardInterceptor |
kr.devslab.ssrfguard.security.SsrfGuardInterceptor |
devs.lab.ssrf.security.SafeDnsResolver |
kr.devslab.ssrfguard.security.SafeDnsResolver |
devs.lab.ssrf.security.SafeRedirectStrategy |
kr.devslab.ssrfguard.security.SafeRedirectStrategy |
devs.lab.ssrf.security.NetUtil |
kr.devslab.ssrfguard.security.NetUtil |
- BREAKING —
SsrfGuardApplication제거. 원래 Spring Initializr 템플릿에서 남은 빈@SpringBootApplication; 라이브러리 starter가 main class를 가질 이유 없음. - 빌드 시스템: Maven → Gradle 8.10 + Vanniktech maven-publish 0.30.0. easy-paging-spring-boot-starter, api-log와 같은 컨벤션.
- 릴리즈 흐름: semantic-release → tag-triggered Gradle publish.
v[0-9]+.[0-9]+.[0-9]+매치 git tag가 release workflow 실행, 한 단계에 build + sign + Sonatype Central Portal 업로드 + GitHub Release 생성.
Added¶
- CI 워크플로 (
.github/workflows/ci.yml) —mainpush, PR마다./gradlew build jacocoTestReport실행, Codecov에 커버리지 업로드. - 문서 사이트 https://ssrf-guard.devslab.kr/ — 설치, 빠른 시작, 보안 모델, 설정 레퍼런스. mkdocs-material + i18n (영문 + 한국어).
- 이중 언어 README (
README.md/README.ko.md). - 모든 방어 동작에 대한 풀 테스트 커버리지:
NetUtilTest— whitelist 매칭 (exact + suffix), IDN 정규화, IPv4 사설 IP 분류 (loopback, RFC-1918, link-local 포함 AWS 메타데이터, CGNAT, benchmark, broadcast) + IPv6 (ULA, link-local).SafeDnsResolverTest— whitelist 게이트 + 사설 IP 필터, "필터 후 남는 게 없음" 경로 포함.SsrfGuardInterceptorTest— 스킴/호스트/포트 accept/reject 매트릭스, suffix 라벨 경계 lookalike (전형적badexample.com우회).SsrfGuardAutoConfigurationTest— 활성화 시 모든 public 빈 등록,ssrf.guard.enabled=false일 때 등록 안 됨.SsrfGuardIntegrationTest—MockWebServer를 통한 실제 HTTP, 4-레이어 방어 end-to-end.
Migration¶
의존성 좌표 및 직접 import 업데이트:
<!-- v1.x (Maven Central에 없음) -->
<dependency>
<groupId>com.devs.lab</groupId>
<artifactId>ssrf-guard-spring-boot-starter</artifactId>
<version>1.1.0</version>
</dependency>
<!-- v2.0.0 -->
<dependency>
<groupId>kr.devslab</groupId>
<artifactId>ssrf-guard</artifactId>
<version>2.0.0</version>
</dependency>
application.yml 키는 변경 없음 — ssrf.guard.* 그대로 동작.
보안 타입 직접 import 사용 중? devs.lab.ssrf를 kr.devslab.ssrfguard로 바꾸고 kr.devslab.ssrfguard.autoconfigure (properties + auto-config) 와 kr.devslab.ssrfguard.security (interceptor + resolver + redirect + NetUtil) 로 split.
[1.1.0] — 2025-09-23¶
pre-v2 작업의 semantic-release rollup. tag만 있고 Maven Central에 발행 안 됨.
- README + releaser 템플릿 정리
[1.0.0] — 2025-09-23¶
첫 공개 릴리즈. tag만 있고 Maven Central에 발행 안 됨.
com.devs.lab:ssrf-guard-spring-boot-starter첫 cut