From 1d455723a6e7732d6ad22835ac74c1c75526f9fb Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Mon, 6 Jul 2026 00:15:18 +0900 Subject: [PATCH] Fix uppercase-scheme bypass in LLM tool-input guard JsonToolInputGuard.looksLikeUrl matched http:// and https:// prefixes case-sensitively, so a tool-input URL with an uppercase scheme (HTTP://169.254.169.254/, HtTpS://evil.com/) was never collected as a candidate and skipped policy validation entirely, bypassing the host allowlist and IP-literal checks. URI schemes are case-insensitive (RFC 3986 3.1) and the downstream scheme check already used equalsIgnoreCase, so the case-sensitive match in the collection step was an unintended inconsistency. Lower-case before the prefix test. Adds regression tests for uppercase/mixed-case blocked URLs and confirms an uppercase whitelisted URL still passes. Affects ssrf-guard-springai and ssrf-guard-langchain4j, which both route tool input through this guard. Same class of bug fixed in the JS sibling ssrf-guard-js. --- CHANGELOG.md | 4 ++++ docs/changelog.md | 4 ++++ .../ssrfguard/llm/JsonToolInputGuard.java | 7 +++++- .../ssrfguard/llm/JsonToolInputGuardTest.java | 22 +++++++++++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9440d7b..edee395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ The source of truth for the entries below is [docs/changelog.md](docs/changelog. ## [Unreleased] +### Security + +- **Uppercase-scheme bypass in the LLM tool-input guard fixed.** `JsonToolInputGuard` collected candidate URLs with a case-sensitive `http://` / `https://` prefix test, so an uppercase-scheme tool-input URL (`HTTP://169.254.169.254/…`) was never collected and skipped policy validation entirely — bypassing the host allowlist and IP-literal checks. Detection is now case-insensitive. Affects `ssrf-guard-springai` and `ssrf-guard-langchain4j`. + ## [3.1.0] — LLM core extraction, LangChain4j, WebClient DNS gap, GraalVM hints ### Added diff --git a/docs/changelog.md b/docs/changelog.md index 6311303..0e8c47a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -6,6 +6,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ## [Unreleased] +### Security + +- **Uppercase-scheme bypass in the LLM tool-input guard fixed.** `JsonToolInputGuard` collected candidate URLs with a case-sensitive `http://` / `https://` prefix test, so a tool-input URL written with an uppercase scheme (`HTTP://169.254.169.254/…`, `HtTpS://evil.com/`) was never collected and skipped policy validation entirely — bypassing the host allowlist and IP-literal checks. URI schemes are case-insensitive (RFC 3986 §3.1) and the downstream scheme check already used `equalsIgnoreCase`, so this only affected the collection step. Detection is now case-insensitive. Affects `ssrf-guard-springai` and `ssrf-guard-langchain4j`, which both route tool input through this guard. + ## [3.1.0] — LLM core extraction, LangChain4j, WebClient DNS gap, GraalVM hints ### Added diff --git a/ssrf-guard-llm/src/main/java/kr/devslab/ssrfguard/llm/JsonToolInputGuard.java b/ssrf-guard-llm/src/main/java/kr/devslab/ssrfguard/llm/JsonToolInputGuard.java index ec912e0..27b3417 100644 --- a/ssrf-guard-llm/src/main/java/kr/devslab/ssrfguard/llm/JsonToolInputGuard.java +++ b/ssrf-guard-llm/src/main/java/kr/devslab/ssrfguard/llm/JsonToolInputGuard.java @@ -146,7 +146,12 @@ private static void collectUrlLikeStrings(JsonNode node, List out) { private static boolean looksLikeUrl(String s) { if (s == null) return false; - String trimmed = s.trim(); + // Lower-case before the prefix test: URI schemes are case-insensitive + // (RFC 3986 §3.1), so HTTP:// / HtTpS:// must be detected too. The + // downstream scheme check already uses equalsIgnoreCase; a + // case-sensitive match here would let an uppercase-scheme URL skip + // collection entirely and bypass the policy. + String trimmed = s.trim().toLowerCase(); return trimmed.startsWith("http://") || trimmed.startsWith("https://"); } diff --git a/ssrf-guard-llm/src/test/java/kr/devslab/ssrfguard/llm/JsonToolInputGuardTest.java b/ssrf-guard-llm/src/test/java/kr/devslab/ssrfguard/llm/JsonToolInputGuardTest.java index 856a80e..fd0ac89 100644 --- a/ssrf-guard-llm/src/test/java/kr/devslab/ssrfguard/llm/JsonToolInputGuardTest.java +++ b/ssrf-guard-llm/src/test/java/kr/devslab/ssrfguard/llm/JsonToolInputGuardTest.java @@ -93,6 +93,28 @@ void throws_when_throw_on_violation_is_true() { .matches(e -> e.reason() == BlockReason.BLOCKED_IP_LITERAL); } + @Test + void blocks_uppercase_and_mixed_case_scheme_urls() { + // URI schemes are case-insensitive; an uppercase scheme must not let a + // URL skip collection and bypass the policy. + var guard = new JsonToolInputGuard(policy(List.of("api.example.com"))); + + String upper = guard.checkOrFormatError("{\"url\":\"HTTP://169.254.169.254/latest/meta-data/\"}"); + assertThat(upper) + .isNotNull() + .contains("\"error\":\"ssrf_blocked\"") + .contains("\"reason\":\"blocked_ip_literal\""); + + String mixed = guard.checkOrFormatError("{\"request\":{\"target\":\"HtTpS://evil.com/\"}}"); + assertThat(mixed).contains("\"error\":\"ssrf_blocked\""); + } + + @Test + void still_allows_whitelisted_url_with_uppercase_scheme() { + var guard = new JsonToolInputGuard(policy(List.of("api.example.com"))); + assertThat(guard.checkOrFormatError("{\"url\":\"HTTPS://api.example.com/v1\"}")).isNull(); + } + @Test void ignores_non_http_schemes() { // mailto:, urn:uuid:, file:// — should not trip the URL detector