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
11 changes: 11 additions & 0 deletions docs/changelog.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ ssrf-guard의 주요 변경 사항을 기록합니다.

## [Unreleased]

v3.1.0 작업 추적. 릴리즈된 항목은 태그 시 별도 섹션으로 이동.

### 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(...)` 헬퍼 제공.

### Changed

- **`ssrf-guard-springai`가 thin adapter (~30줄)로 리팩토링.** 새 `-llm` 모듈의 `JsonToolInputGuard`에 위임. Public API 변경 없음 — `SsrfGuardedToolCallback`의 모든 생성자/메서드 시그니처 유지. v3.0.x 소비자는 API 변경 못 느낌 — 단지 `ssrf-guard-llm`을 transitive로 받게 됨.

## [3.0.1] — 메트릭 빈 classpath 게이트 수정

### Fixed
Expand Down
11 changes: 11 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and

## [Unreleased]

Tracking v3.1.0 work. Released items will move into their own section below when the tag lands.

### Added

- **`ssrf-guard-llm`** — new framework-agnostic core module holding the JSON walking + URL extraction + policy validation that every LLM tool adapter shares. Exposes `ToolInputGuard` (interface) and `JsonToolInputGuard` (default impl). Replaces ~200 lines of logic that previously lived inside `SsrfGuardedToolCallback`.
- **`ssrf-guard-langchain4j`** — new adapter module wrapping LangChain4j's `ToolExecutor`. Closes the same LLM-agent SSRF surface as `ssrf-guard-springai` but for the LangChain4j ecosystem (the other major Java LLM framework). Auto-wrap via `BeanPostProcessor` for Spring Boot users; `SsrfGuardedToolExecutors.wrap(...)` helpers for non-Spring / programmatic users.

### Changed

- **`ssrf-guard-springai` refactored to a thin adapter (~30 lines).** Delegates to `JsonToolInputGuard` from the new `-llm` module. Public API unchanged — every constructor and method on `SsrfGuardedToolCallback` keeps the same shape. v3.0.x consumers see no API change; they just pick up `ssrf-guard-llm` transitively.

## [3.0.1] — Fix metrics bean classpath gate

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Project coordinates
GROUP=kr.devslab
VERSION=3.0.1-SNAPSHOT
VERSION=3.1.0-SNAPSHOT

# Project metadata for Maven Central POM
POM_NAME=SSRF Guard
Expand Down
6 changes: 5 additions & 1 deletion settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ rootProject.name = "ssrf-guard"
// ssrf-guard-resttemplate — Spring RestTemplate interceptor + auto-config
// ssrf-guard-webclient — Spring WebFlux ExchangeFilterFunction + auto-config
// ssrf-guard-feign — Spring Cloud OpenFeign interceptor + auto-config
// ssrf-guard-springai — Spring AI tool URL validation
// ssrf-guard-llm — framework-agnostic JSON tool input validation (v3.1+)
// ssrf-guard-springai — Spring AI tool URL validation (thin adapter over -llm)
// ssrf-guard-langchain4j — LangChain4j tool URL validation (thin adapter over -llm, v3.1+)
// ssrf-guard-jdkhttp — java.net.http.HttpClient wrapper (no Spring)
// ssrf-guard-okhttp — OkHttp interceptor + DNS
// ssrf-guard — v2.0.0-compatible meta artifact (restclient + httpclient5)
Expand All @@ -37,7 +39,9 @@ include(
"ssrf-guard-resttemplate",
"ssrf-guard-webclient",
"ssrf-guard-feign",
"ssrf-guard-llm",
"ssrf-guard-springai",
"ssrf-guard-langchain4j",
"ssrf-guard-jdkhttp",
"ssrf-guard-okhttp",
"ssrf-guard",
Expand Down
80 changes: 80 additions & 0 deletions ssrf-guard-langchain4j/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// ssrf-guard-langchain4j — thin adapter wiring LangChain4j's ToolExecutor
// to ssrf-guard-llm. Same security model as ssrf-guard-springai; different
// framework abstraction.
//
// Closes the same SSRF surface (LLM agent fetch_url-style tools) for the
// LangChain4j community — the other major Java LLM framework.

base {
archivesName.set("ssrf-guard-langchain4j")
}

dependencies {
// The framework-agnostic core. Jackson follows transitively.
api(project(":ssrf-guard-llm"))
api("org.springframework.boot:spring-boot-autoconfigure")

// LangChain4j 1.15.0 — first stable line. ToolExecutor lives in the
// main `langchain4j` artifact (the AiServices implementation),
// ToolExecutionRequest in `langchain4j-core` follows transitively.
compileOnly("dev.langchain4j:langchain4j:1.15.0")

// Micrometer — compileOnly. The MetricsConfiguration inner class is
// gated by @ConditionalOnClass(MeterRegistry.class), so this dep is
// only ever needed at compile time (the class reference inside the
// gated inner config) — never required at runtime by consumers.
compileOnly("io.micrometer:micrometer-core")

compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
compileOnly("com.google.code.findbugs:jsr305:3.0.2")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("dev.langchain4j:langchain4j:1.15.0")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

mavenPublishing {
coordinates(
providers.gradleProperty("GROUP").get(),
"ssrf-guard-langchain4j",
providers.gradleProperty("VERSION").get()
)
pom {
name.set("SSRF Guard — LangChain4j")
description.set("SSRF Guard adapter for LangChain4j tool execution. Validates URL-shaped arguments in ToolExecutionRequest before the underlying ToolExecutor runs. Thin wrapper over ssrf-guard-llm.")
url.set(providers.gradleProperty("POM_URL"))
inceptionYear.set(providers.gradleProperty("POM_INCEPTION_YEAR"))
licenses {
license {
name.set(providers.gradleProperty("POM_LICENSE_NAME"))
url.set(providers.gradleProperty("POM_LICENSE_URL"))
distribution.set(providers.gradleProperty("POM_LICENSE_DIST"))
}
}
developers {
developer {
id.set(providers.gradleProperty("POM_DEVELOPER_ID"))
name.set(providers.gradleProperty("POM_DEVELOPER_NAME"))
url.set(providers.gradleProperty("POM_DEVELOPER_URL"))
email.set(providers.gradleProperty("POM_DEVELOPER_EMAIL"))
organization.set(providers.gradleProperty("POM_ORGANIZATION_NAME"))
organizationUrl.set(providers.gradleProperty("POM_ORGANIZATION_URL"))
}
}
organization {
name.set(providers.gradleProperty("POM_ORGANIZATION_NAME"))
url.set(providers.gradleProperty("POM_ORGANIZATION_URL"))
}
scm {
url.set(providers.gradleProperty("POM_SCM_URL"))
connection.set(providers.gradleProperty("POM_SCM_CONNECTION"))
developerConnection.set(providers.gradleProperty("POM_SCM_DEV_CONNECTION"))
}
issueManagement {
system.set(providers.gradleProperty("POM_ISSUE_SYSTEM"))
url.set(providers.gradleProperty("POM_ISSUE_URL"))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package kr.devslab.ssrfguard.langchain4j;

import dev.langchain4j.service.tool.ToolExecutor;
import kr.devslab.ssrfguard.core.HostPolicy;
import kr.devslab.ssrfguard.core.NoOpSsrfGuardMetrics;
import kr.devslab.ssrfguard.core.SsrfGuardMetrics;
import kr.devslab.ssrfguard.core.SsrfGuardProperties;
import kr.devslab.ssrfguard.core.UrlPolicy;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* Auto-configuration that wires the SSRF defenses into LangChain4j tool
* execution.
*
* <p>Activated when:
* <ul>
* <li>{@link ToolExecutor} is on the classpath (i.e. consumer pulls in
* LangChain4j 1.x).</li>
* <li>{@code ssrf.guard.enabled=true} (default).</li>
* <li>{@code ssrf.guard.langchain4j.wrap-tool-executors=true} (default
* true) — set false to opt out of automatic wrapping while still
* allowing manual use of {@link SsrfGuardedToolExecutors}.</li>
* </ul>
*
* <p>The {@link BeanPostProcessor} below wraps every {@link ToolExecutor}
* bean Spring registers. A consumer's hand-rolled {@code @Bean ToolExecutor}
* (or a {@code @Tool}-annotated class registered as a bean) gets the URL
* policy applied without any wiring code.
*
* <p>Mirrors {@code SsrfGuardSpringAiAutoConfiguration} — same model,
* different framework. Both share the same {@link UrlPolicy} bean shape
* so a Spring AI app and a LangChain4j app configured against the same
* `ssrf.guard.*` properties block identical attacks.
*/
@AutoConfiguration
@ConditionalOnClass(ToolExecutor.class)
@EnableConfigurationProperties(SsrfGuardProperties.class)
@ConditionalOnProperty(prefix = "ssrf.guard", name = "enabled", havingValue = "true", matchIfMissing = true)
public class SsrfGuardLangchain4jAutoConfiguration {

@Bean
@ConditionalOnMissingBean(SsrfGuardMetrics.class)
SsrfGuardMetrics ssrfGuardMetricsLangchain4j() {
return NoOpSsrfGuardMetrics.INSTANCE;
}

@Bean
@ConditionalOnMissingBean
UrlPolicy ssrfUrlPolicyLangchain4j(SsrfGuardProperties props,
ObjectProvider<HostPolicy> hostPolicy,
SsrfGuardMetrics metrics) {
HostPolicy hp = hostPolicy.getIfAvailable(
() -> new HostPolicy(props.getExactHosts(), props.getSuffixes()));
return new UrlPolicy(
props.getAllowedSchemes(),
props.getAllowedPorts(),
hp,
props.isRejectIpLiteralHosts(),
props.isRejectUserInfo(),
metrics
);
}

/**
* Wraps every {@link ToolExecutor} bean with {@link SsrfGuardedToolExecutor}.
* Idempotent — already-wrapped executors pass through.
*/
@Bean
@ConditionalOnProperty(prefix = "ssrf.guard.langchain4j", name = "wrap-tool-executors",
havingValue = "true", matchIfMissing = true)
BeanPostProcessor ssrfGuardToolExecutorBeanPostProcessor(UrlPolicy policy) {
return new BeanPostProcessor() {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (bean instanceof ToolExecutor exec && !(bean instanceof SsrfGuardedToolExecutor)) {
return new SsrfGuardedToolExecutor(exec, policy);
}
return bean;
}
};
}

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(name = "io.micrometer.core.instrument.MeterRegistry")
static class MetricsConfiguration {

@Bean
@ConditionalOnMissingBean(SsrfGuardMetrics.class)
SsrfGuardMetrics micrometerSsrfGuardMetricsLangchain4j(
ObjectProvider<io.micrometer.core.instrument.MeterRegistry> meterRegistry) {
var reg = meterRegistry.getIfAvailable();
return reg == null
? NoOpSsrfGuardMetrics.INSTANCE
: new kr.devslab.ssrfguard.core.MicrometerSsrfGuardMetrics(reg);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package kr.devslab.ssrfguard.langchain4j;

import dev.langchain4j.agent.tool.ToolExecutionRequest;
import dev.langchain4j.service.tool.ToolExecutor;
import kr.devslab.ssrfguard.core.SsrfGuardException;
import kr.devslab.ssrfguard.core.UrlPolicy;
import kr.devslab.ssrfguard.llm.JsonToolInputGuard;
import kr.devslab.ssrfguard.llm.ToolInputGuard;

/**
* Wraps a LangChain4j {@link ToolExecutor} so URL-shaped arguments in the
* incoming {@link ToolExecutionRequest} are validated against an
* {@link UrlPolicy} before the underlying executor runs.
*
* <p>Sibling to {@code SsrfGuardedToolCallback} in {@code ssrf-guard-springai}.
* Same model, same core ({@link JsonToolInputGuard}) — only the framework
* abstraction differs.
*
* <h2>The threat — recap</h2>
* LangChain4j {@code @Tool} methods and programmatic {@code ToolExecutor}s
* both end up receiving a JSON {@code arguments} string from the LLM. A
* tool like:
* <pre>{@code
* @Tool("Fetch a URL and return its body")
* String fetchUrl(String url) {
* return restClient.get().uri(url).retrieve().body(String.class);
* }
* }</pre>
* is one prompt away from SSRF if {@code url} is attacker-controlled
* (directly via user message, or indirectly via RAG-injected instructions).
*
* <h2>What the wrap does</h2>
* <ol>
* <li>Reads {@code request.arguments()} — the JSON the LLM emitted.</li>
* <li>Delegates to {@link JsonToolInputGuard} — walks the whole tree, finds
* URL-shaped strings, validates each through {@link UrlPolicy}.</li>
* <li>On rejection, returns the structured JSON error directly (the LLM
* reads it on its next turn and recovers). On approval, delegates to
* the wrapped executor.</li>
* </ol>
*
* <h2>Usage</h2>
* Manual wrap (works without Spring):
* <pre>{@code
* UrlPolicy policy = ...;
* ToolExecutor raw = (request, memoryId) -> myFetchUrl(request.arguments());
* ToolExecutor safe = new SsrfGuardedToolExecutor(raw, policy);
*
* AiServices.builder(MyAssistant.class)
* .chatModel(model)
* .tools(Map.of(toolSpec, safe))
* .build();
* }</pre>
*
* Auto-wrap (Spring): drop {@code ssrf-guard-langchain4j} on the classpath —
* {@link SsrfGuardLangchain4jAutoConfiguration} registers a
* {@code BeanPostProcessor} that wraps every {@link ToolExecutor} bean in
* the context.
*/
public final class SsrfGuardedToolExecutor implements ToolExecutor {

private final ToolExecutor delegate;
private final ToolInputGuard guard;

public SsrfGuardedToolExecutor(ToolExecutor delegate, UrlPolicy policy) {
this(delegate, policy, false);
}

public SsrfGuardedToolExecutor(ToolExecutor delegate, UrlPolicy policy, boolean throwOnViolation) {
this.delegate = delegate;
this.guard = new JsonToolInputGuard(policy, throwOnViolation);
}

/**
* Advanced constructor — supply a custom {@link ToolInputGuard}. Useful
* for non-JSON tool argument shapes, custom error payloads, or
* fixed-decision guards in tests.
*/
public SsrfGuardedToolExecutor(ToolExecutor delegate, ToolInputGuard guard) {
this.delegate = delegate;
this.guard = guard;
}

@Override
public String execute(ToolExecutionRequest request, Object memoryId) {
String violation = guard.checkOrFormatError(request.arguments());
return violation != null ? violation : delegate.execute(request, memoryId);
}

public ToolExecutor delegate() {
return delegate;
}

public ToolInputGuard guard() {
return guard;
}
}
Loading