High severity8.6NVD Advisory· Published Mar 27, 2026· Updated May 10, 2026
CVE-2026-22742
CVE-2026-22742
Description
Spring AI's spring-ai-bedrock-converse contains a Server-Side Request Forgery (SSRF) vulnerability in BedrockProxyChatModel when processing multimodal messages that include user-supplied media URLs. Insufficient validation of those URLs allows an attacker to induce the server to issue HTTP requests to unintended internal or external destinations. This issue affects Spring AI: from 1.0.0 before 1.0.5, from 1.1.0 before 1.1.4.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
org.springframework.ai:spring-ai-bedrock-converseMaven | >= 1.0.0-M5, < 1.0.5 | 1.0.5 |
org.springframework.ai:spring-ai-bedrock-converseMaven | >= 1.1.0-M1, < 1.1.4 | 1.1.4 |
Affected products
1Patches
1a7d3223bc11aImprove media fetching robustness in BedrockProxyChatModel
12 files changed · +870 −62
auto-configurations/models/spring-ai-autoconfigure-model-bedrock-ai/src/test/java/org/springframework/ai/model/bedrock/converse/autoconfigure/BedrockConverseProxyChatAutoConfigurationIT.java+1 −1 modified@@ -44,7 +44,7 @@ public class BedrockConverseProxyChatAutoConfigurationIT { private final ApplicationContextRunner contextRunner = BedrockTestUtils.getContextRunner() .withPropertyValues( - "spring.ai.bedrock.converse.chat.options.model=" + "anthropic.claude-3-5-sonnet-20240620-v1:0", + "spring.ai.bedrock.converse.chat.options.model=" + "us.anthropic.claude-sonnet-4-5-20250929-v1:0", "spring.ai.bedrock.converse.chat.options.temperature=0.5") .withConfiguration(SpringAiTestAutoConfigurations.of(BedrockConverseProxyChatAutoConfiguration.class));
auto-configurations/models/spring-ai-autoconfigure-model-bedrock-ai/src/test/java/org/springframework/ai/model/bedrock/converse/autoconfigure/tool/FunctionCallWithFunctionBeanIT.java+2 −2 modified@@ -55,7 +55,7 @@ void functionCallTest() { this.contextRunner .withPropertyValues( - "spring.ai.bedrock.converse.chat.options.model=" + "anthropic.claude-3-5-sonnet-20240620-v1:0") + "spring.ai.bedrock.converse.chat.options.model=" + "us.anthropic.claude-haiku-4-5-20251001-v1:0") .run(context -> { BedrockProxyChatModel chatModel = context.getBean(BedrockProxyChatModel.class); @@ -84,7 +84,7 @@ void functionStreamTest() { this.contextRunner .withPropertyValues( - "spring.ai.bedrock.converse.chat.options.model=" + "anthropic.claude-3-5-sonnet-20240620-v1:0") + "spring.ai.bedrock.converse.chat.options.model=" + "us.anthropic.claude-haiku-4-5-20251001-v1:0") .run(context -> { BedrockProxyChatModel chatModel = context.getBean(BedrockProxyChatModel.class);
auto-configurations/models/spring-ai-autoconfigure-model-bedrock-ai/src/test/java/org/springframework/ai/model/bedrock/converse/autoconfigure/tool/FunctionCallWithPromptFunctionIT.java+1 −1 modified@@ -48,7 +48,7 @@ public class FunctionCallWithPromptFunctionIT { void functionCallTest() { this.contextRunner .withPropertyValues( - "spring.ai.bedrock.converse.chat.options.model=" + "anthropic.claude-3-5-sonnet-20240620-v1:0") + "spring.ai.bedrock.converse.chat.options.model=" + "us.anthropic.claude-haiku-4-5-20251001-v1:0") .run(context -> { BedrockProxyChatModel chatModel = context.getBean(BedrockProxyChatModel.class);
models/spring-ai-bedrock-converse/pom.xml+5 −0 modified@@ -82,6 +82,11 @@ <version>${bedrockruntime.version}</version> </dependency> + <dependency> + <groupId>org.apache.httpcomponents.client5</groupId> + <artifactId>httpclient5</artifactId> + </dependency> + <!-- test dependencies --> <dependency> <groupId>org.springframework.ai</groupId>
models/spring-ai-bedrock-converse/src/main/java/org/springframework/ai/bedrock/converse/api/MediaFetcher.java+324 −0 added@@ -0,0 +1,324 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.bedrock.converse.api; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.URI; +import java.net.UnknownHostException; +import java.util.Set; + +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.client5.http.socket.ConnectionSocketFactory; +import org.apache.hc.client5.http.socket.LayeredConnectionSocketFactory; +import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.config.Registry; +import org.apache.hc.core5.http.config.RegistryBuilder; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.util.TimeValue; +import org.apache.hc.core5.util.Timeout; + +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +/** + * Fetches media content from HTTP/HTTPS URLs with SSRF and resource-exhaustion + * protections. + * + * <p> + * Protection measures: + * <ul> + * <li>Socket-level blocking via {@link SsrfBlockingPlainSocketFactory} and + * {@link SsrfBlockingSSLSocketFactory}: the resolved {@link java.net.InetAddress} is + * checked at {@code connectSocket()} time — after DNS resolution — so raw IP literals + * (e.g. {@code 127.0.0.1}, {@code 169.254.169.254}) are blocked even when no DNS lookup + * occurs.</li> + * <li>DNS-level blocking via {@link SsrfSafeDnsResolver}: hostnames that resolve to + * internal addresses are rejected early, before a connection attempt is made. This + * provides a fast-fail path for hostname-based requests and limits DNS rebinding + * exposure.</li> + * <li>HTTP redirects are disabled to prevent redirect chains that lead to internal + * addresses.</li> + * <li>Connect and socket timeouts prevent slow-server resource exhaustion.</li> + * <li>Response bodies are capped at {@value #DEFAULT_MAX_FETCH_SIZE_BYTES} bytes to + * prevent memory exhaustion.</li> + * </ul> + * + * @author Christian Tzolov + * @since 1.0.0 + */ +public final class MediaFetcher { + + /** + * Maximum number of bytes fetched from a media URL. Protects against memory + * exhaustion when a user-supplied URL points to arbitrarily large content (40 MB). + */ + public static final int DEFAULT_MAX_FETCH_SIZE_BYTES = 40 * 1024 * 1024; + + /** Connect timeout for opening a connection to the media URL. */ + private static final int DEFAULT_CONNECT_TIMEOUT_SECONDS = 15; + + /** Socket timeout for reading from the media URL connection. */ + private static final int DEFAULT_SOCKET_TIMEOUT_SECONDS = 30; + + private final RestClient restClient; + + /** + * Optional set of allowed hostnames. When non-empty, only hosts in this set (or + * matching a {@code *.suffix} wildcard entry) are permitted. An empty set means no + * allowlist is enforced and only the SSRF blocklist applies. + */ + private final Set<String> allowedHosts; + + /** + * Creates a {@code MediaFetcher} with no host allowlist (blocklist-only protection). + */ + public MediaFetcher() { + this(Set.of()); + } + + /** + * Creates a {@code MediaFetcher} with an optional host allowlist. + * + * <p> + * When {@code allowedHosts} is non-empty, every fetch is checked against this set + * before the SSRF blocklist. A host is allowed when it either equals an entry exactly + * (case-insensitive) or matches a wildcard entry of the form {@code *.example.com}. + * @param allowedHosts set of permitted hostnames or wildcard patterns; an empty set + * disables allowlist enforcement + */ + public MediaFetcher(Set<String> allowedHosts) { + this.allowedHosts = Set.copyOf(allowedHosts); + this.restClient = createSsrfSafeRestClient(); + } + + /** + * Package-private constructor for testing — allows injecting a custom + * {@link RestClient} (e.g. one backed by {@code MockRestServiceServer}). + */ + MediaFetcher(Set<String> allowedHosts, RestClient restClient) { + this.allowedHosts = Set.copyOf(allowedHosts); + this.restClient = restClient; + } + + /** + * Fetches the content at {@code uri} and returns it as a byte array. + * + * <p> + * The caller is responsible for validating the URI (protocol, host) before invoking + * this method. This method enforces size limits and socket-level SSRF protection. + * @param uri the URI to fetch + * @return the response body as a byte array + * @throws SecurityException if the response exceeds + * {@link #DEFAULT_MAX_FETCH_SIZE_BYTES} or the host resolves to a blocked internal + * address + * @throws org.springframework.web.client.RestClientException on HTTP or I/O errors + */ + public byte[] fetch(URI uri) { + if (!this.allowedHosts.isEmpty()) { + String host = uri.getHost(); + if (!isHostAllowed(host)) { + throw new SecurityException("Host '" + host + + "' is not in the allowed hosts list. Configure MediaFetcher with the appropriate allowed hosts."); + } + } + return this.restClient.get().uri(uri).exchange((request, response) -> { + long contentLength = response.getHeaders().getContentLength(); + if (contentLength > DEFAULT_MAX_FETCH_SIZE_BYTES) { + throw new SecurityException("Media URL response exceeds maximum allowed size of " + + DEFAULT_MAX_FETCH_SIZE_BYTES + " bytes: " + uri); + } + try (InputStream body = response.getBody()) { + return readWithSizeLimit(body, DEFAULT_MAX_FETCH_SIZE_BYTES); + } + }, true); + } + + /** + * Returns {@code true} if {@code host} is permitted by the allowlist. An entry that + * starts with {@code *.} is treated as a suffix wildcard matching any subdomain (e.g. + * {@code *.example.com} matches {@code img.example.com} but not {@code example.com} + * itself). + */ + private boolean isHostAllowed(String host) { + if (host == null) { + return false; + } + String normalizedHost = host.toLowerCase(); + for (String allowed : this.allowedHosts) { + String normalizedAllowed = allowed.toLowerCase(); + if (normalizedAllowed.startsWith("*.")) { + // wildcard: *.example.com → matches img.example.com + String suffix = normalizedAllowed.substring(1); // ".example.com" + if (normalizedHost.endsWith(suffix)) { + return true; + } + } + else if (normalizedHost.equals(normalizedAllowed)) { + return true; + } + } + return false; + } + + private static byte[] readWithSizeLimit(InputStream inputStream, int maxBytes) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int totalRead = 0; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + totalRead += bytesRead; + if (totalRead > maxBytes) { + throw new SecurityException( + "Media URL response exceeds maximum allowed size of " + maxBytes + " bytes"); + } + output.write(buffer, 0, bytesRead); + } + return output.toByteArray(); + } + + private static RestClient createSsrfSafeRestClient() { + Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder.<ConnectionSocketFactory>create() + .register("http", new SsrfBlockingPlainSocketFactory()) + .register("https", new SsrfBlockingSSLSocketFactory(SSLConnectionSocketFactory.getSocketFactory())) + .build(); + + PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager( + socketFactoryRegistry, null, null, null, null, new SsrfSafeDnsResolver(), null); + connectionManager.setDefaultConnectionConfig(ConnectionConfig.custom() + .setConnectTimeout(Timeout.ofSeconds(DEFAULT_CONNECT_TIMEOUT_SECONDS)) + .setSocketTimeout(Timeout.ofSeconds(DEFAULT_SOCKET_TIMEOUT_SECONDS)) + .build()); + + CloseableHttpClient httpClient = HttpClients.custom() + .setConnectionManager(connectionManager) + .disableRedirectHandling() + .build(); + return RestClient.builder().requestFactory(new HttpComponentsClientHttpRequestFactory(httpClient)).build(); + } + + /** + * Checks the resolved {@link InetAddress} in {@code remoteAddress} and throws + * {@link SecurityException} if it is a blocked internal address. Called by both + * socket factories at connect time — after DNS resolution — so it catches raw IP + * literals that bypass the {@link SsrfSafeDnsResolver}. Thrown as an unchecked + * {@link RuntimeException} so it propagates through Spring RestClient without being + * wrapped in {@link org.springframework.web.client.ResourceAccessException}. + */ + private static void assertNotBlockedAddress(InetSocketAddress remoteAddress, HttpHost host) { + InetAddress address = remoteAddress.getAddress(); + if (address != null && URLValidator.isBlockedAddress(address)) { + throw new SecurityException("Connection to blocked internal address " + address.getHostAddress() + + " rejected for host '" + host.getHostName() + "'"); + } + } + + /** + * Plain-HTTP socket factory that blocks connections to internal addresses at connect + * time. Extends {@link PlainConnectionSocketFactory} and delegates to it after the + * address check, preserving all default socket behaviour. + */ + private static final class SsrfBlockingPlainSocketFactory extends PlainConnectionSocketFactory { + + @Override + public Socket connectSocket(TimeValue connectTimeout, Socket socket, HttpHost host, + InetSocketAddress remoteAddress, InetSocketAddress localAddress, HttpContext context) + throws IOException { + assertNotBlockedAddress(remoteAddress, host); + return super.connectSocket(connectTimeout, socket, host, remoteAddress, localAddress, context); + } + + } + + /** + * TLS socket factory that blocks connections to internal addresses at connect time. + * Wraps an {@link SSLConnectionSocketFactory} delegate and performs the address check + * before handing off to it, preserving all TLS configuration (cipher suites, hostname + * verification, etc.). + */ + private static final class SsrfBlockingSSLSocketFactory implements LayeredConnectionSocketFactory { + + private final SSLConnectionSocketFactory delegate; + + SsrfBlockingSSLSocketFactory(SSLConnectionSocketFactory delegate) { + this.delegate = delegate; + } + + @Override + public Socket createSocket(HttpContext context) throws IOException { + return this.delegate.createSocket(context); + } + + @Override + public Socket connectSocket(TimeValue connectTimeout, Socket socket, HttpHost host, + InetSocketAddress remoteAddress, InetSocketAddress localAddress, HttpContext context) + throws IOException { + assertNotBlockedAddress(remoteAddress, host); + return this.delegate.connectSocket(connectTimeout, socket, host, remoteAddress, localAddress, context); + } + + @Override + public Socket createLayeredSocket(Socket socket, String target, int port, HttpContext context) + throws IOException { + return this.delegate.createLayeredSocket(socket, target, port, context); + } + + } + + /** + * DNS resolver that rejects hostnames resolving to internal addresses. Acts as an + * early-rejection layer for hostname-based requests, complementing the socket-level + * check in {@link SsrfBlockingPlainSocketFactory} and + * {@link SsrfBlockingSSLSocketFactory} which covers raw IP literals that skip DNS + * resolution entirely. + */ + private static final class SsrfSafeDnsResolver implements DnsResolver { + + @Override + public InetAddress[] resolve(String host) throws UnknownHostException { + InetAddress[] addresses = SystemDefaultDnsResolver.INSTANCE.resolve(host); + for (InetAddress address : addresses) { + if (URLValidator.isBlockedAddress(address)) { + // Throw SecurityException (RuntimeException) rather than + // UnknownHostException so it propagates through Spring RestClient + // without being wrapped in ResourceAccessException. + throw new SecurityException( + "Host '" + host + "' resolves to a blocked internal address: " + address.getHostAddress()); + } + } + return addresses; + } + + @Override + public String resolveCanonicalHostname(String host) throws UnknownHostException { + return SystemDefaultDnsResolver.INSTANCE.resolveCanonicalHostname(host); + } + + } + +}
models/spring-ai-bedrock-converse/src/main/java/org/springframework/ai/bedrock/converse/api/URLValidator.java+57 −5 modified@@ -16,9 +16,11 @@ package org.springframework.ai.bedrock.converse.api; +import java.net.InetAddress; import java.net.MalformedURLException; import java.net.URISyntaxException; import java.net.URL; +import java.net.UnknownHostException; import java.util.regex.Pattern; /** @@ -47,9 +49,14 @@ private URLValidator() { } /** - * Quick validation using regex pattern Good for basic checks but may not catch all - * edge cases + * Check if the string looks like a URL using a simple regex pattern to disstinct it + * from base64 or other text. This is a quick check to avoid unnecessary URL parsing + * for clearly non-URL strings. + * @deprecated This method is not sufficient for security-sensitive URL validation and + * should not be relied upon for security-critical checks. Use + * {@link #isValidURLStrict(String)} instead for robust validation. */ + @Deprecated public static boolean isValidURLBasic(String urlString) { if (urlString == null || urlString.trim().isEmpty()) { return false; @@ -77,13 +84,23 @@ public static boolean isValidURLStrict(String urlString) { return false; } - // Validate host (not empty and contains at least one dot, unless it's - // localhost) + // Validate host (not empty) + // IPv6 hosts contain ':' instead of '.', so skip the dot check for them String host = url.getHost(); if (host == null || host.isEmpty()) { return false; } - if (!host.equals("localhost") && !host.contains(".")) { + boolean isIPv6 = host.contains(":"); + if (!isIPv6 && !host.contains(".")) { + return false; + } + + // Block internal/private addresses (loopback, link-local, site-local) + // including raw IP literals that bypass the dot-based localhost check + try { + assertNoInternalAddress(host); + } + catch (SecurityException e) { return false; } @@ -100,6 +117,41 @@ public static boolean isValidURLStrict(String urlString) { } } + /** + * Resolves all IP addresses for the given hostname and throws + * {@link SecurityException} if any resolve to a loopback, link-local, site-local, or + * wildcard address. Protects against SSRF via internal network access (including IPv6 + * equivalents) and limits exposure from DNS rebinding by checking all returned + * addresses. + * @param host the hostname to check + * @throws SecurityException if the host resolves to a blocked internal address or + * cannot be resolved + */ + public static void assertNoInternalAddress(String host) { + try { + for (InetAddress address : InetAddress.getAllByName(host)) { + if (isBlockedAddress(address)) { + throw new SecurityException("URL host '" + host + "' resolves to a blocked internal address: " + + address.getHostAddress()); + } + } + } + catch (UnknownHostException e) { + throw new SecurityException("Failed to resolve host: " + host, e); + } + } + + /** + * Returns {@code true} if the given address is a loopback, link-local, site-local, or + * wildcard address. Covers both IPv4 and IPv6 private/internal ranges. + * @param address the address to test + * @return {@code true} if the address should be blocked + */ + public static boolean isBlockedAddress(InetAddress address) { + return address.isLoopbackAddress() || address.isLinkLocalAddress() || address.isSiteLocalAddress() + || address.isAnyLocalAddress(); + } + /** * Attempts to fix common URL issues Adds protocol if missing, removes extra spaces */
models/spring-ai-bedrock-converse/src/main/java/org/springframework/ai/bedrock/converse/BedrockProxyChatModel.java+39 −51 modified@@ -16,11 +16,9 @@ package org.springframework.ai.bedrock.converse; -import java.io.IOException; -import java.io.InputStream; +import java.net.URI; import java.net.URISyntaxException; import java.net.URL; -import java.net.URLConnection; import java.time.Duration; import java.util.ArrayList; import java.util.Base64; @@ -79,6 +77,7 @@ import org.springframework.ai.bedrock.converse.api.BedrockMediaFormat; import org.springframework.ai.bedrock.converse.api.ConverseApiUtils; import org.springframework.ai.bedrock.converse.api.ConverseChatResponseStream; +import org.springframework.ai.bedrock.converse.api.MediaFetcher; import org.springframework.ai.bedrock.converse.api.URLValidator; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.MessageType; @@ -110,8 +109,8 @@ import org.springframework.ai.tool.definition.ToolDefinition; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; -import org.springframework.util.StreamUtils; import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClientException; /** * A {@link ChatModel} implementation that uses the Amazon Bedrock Converse API to @@ -173,6 +172,8 @@ public class BedrockProxyChatModel implements ChatModel { */ private ChatModelObservationConvention observationConvention; + private final MediaFetcher mediaFetcher; + public BedrockProxyChatModel(BedrockRuntimeClient bedrockRuntimeClient, BedrockRuntimeAsyncClient bedrockRuntimeAsyncClient, BedrockChatOptions defaultOptions, ObservationRegistry observationRegistry, ToolCallingManager toolCallingManager) { @@ -184,18 +185,28 @@ public BedrockProxyChatModel(BedrockRuntimeClient bedrockRuntimeClient, BedrockRuntimeAsyncClient bedrockRuntimeAsyncClient, BedrockChatOptions defaultOptions, ObservationRegistry observationRegistry, ToolCallingManager toolCallingManager, ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate) { + this(bedrockRuntimeClient, bedrockRuntimeAsyncClient, defaultOptions, observationRegistry, toolCallingManager, + toolExecutionEligibilityPredicate, new MediaFetcher()); + } + + public BedrockProxyChatModel(BedrockRuntimeClient bedrockRuntimeClient, + BedrockRuntimeAsyncClient bedrockRuntimeAsyncClient, BedrockChatOptions defaultOptions, + ObservationRegistry observationRegistry, ToolCallingManager toolCallingManager, + ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate, MediaFetcher mediaFetcher) { Assert.notNull(bedrockRuntimeClient, "bedrockRuntimeClient must not be null"); Assert.notNull(bedrockRuntimeAsyncClient, "bedrockRuntimeAsyncClient must not be null"); Assert.notNull(toolCallingManager, "toolCallingManager must not be null"); Assert.notNull(toolExecutionEligibilityPredicate, "toolExecutionEligibilityPredicate must not be null"); + Assert.notNull(mediaFetcher, "mediaFetcher must not be null"); this.bedrockRuntimeClient = bedrockRuntimeClient; this.bedrockRuntimeAsyncClient = bedrockRuntimeAsyncClient; this.defaultOptions = defaultOptions; this.observationRegistry = observationRegistry; this.toolCallingManager = toolCallingManager; this.toolExecutionEligibilityPredicate = toolExecutionEligibilityPredicate; + this.mediaFetcher = mediaFetcher; } private static BedrockChatOptions from(ChatOptions options) { @@ -538,7 +549,7 @@ else if (message.getMessageType() == MessageType.TOOL) { .build(); } - private ContentBlock mapMediaToContentBlock(Media media) { + ContentBlock mapMediaToContentBlock(Media media) { var mimeType = media.getMimeType(); @@ -549,9 +560,7 @@ private ContentBlock mapMediaToContentBlock(Media media) { videoSource = VideoSource.builder().bytes(SdkBytes.fromByteArrayUnsafe(bytes)).build(); } else if (media.getData() instanceof String uriText) { - // if (URLValidator.isValidURLBasic(uriText)) { videoSource = VideoSource.builder().s3Location(S3Location.builder().uri(uriText).build()).build(); - // } } else if (media.getData() instanceof URL url) { try { @@ -576,29 +585,40 @@ else if (BedrockMediaFormat.isSupportedImageFormat(mimeType)) { // Image } else if (media.getData() instanceof String text) { - if (URLValidator.isValidURLBasic(text)) { - try { - URL url = new URL(text); - URLConnection connection = url.openConnection(); - try (InputStream is = connection.getInputStream()) { - sourceBuilder.bytes(SdkBytes.fromByteArrayUnsafe(StreamUtils.copyToByteArray(is))).build(); + if (text.startsWith("s3://")) { + sourceBuilder.s3Location(S3Location.builder().uri(text).build()).build(); + } + else if (text.startsWith("http://") || text.startsWith("https://")) { + // Not base64 + if (URLValidator.isValidURLStrict(text)) { + try { + byte[] bytes = this.mediaFetcher.fetch(URI.create(text)); + sourceBuilder.bytes(SdkBytes.fromByteArrayUnsafe(bytes)).build(); + } + catch (SecurityException | RestClientException e) { + throw new RuntimeException("Failed to read media data from URL: " + text, e); } } - catch (IOException e) { - throw new RuntimeException("Failed to read media data from URL: " + text, e); + else { + throw new SecurityException("URL is not valid under strict validation rules: " + text); } } else { + // Assume it's base64-encoded image data sourceBuilder.bytes(SdkBytes.fromByteArray(Base64.getDecoder().decode(text))); } } else if (media.getData() instanceof URL url) { - try (InputStream is = url.openConnection().getInputStream()) { - byte[] imageBytes = StreamUtils.copyToByteArray(is); - sourceBuilder.bytes(SdkBytes.fromByteArrayUnsafe(imageBytes)).build(); + try { + String protocol = url.getProtocol(); + if (!"http".equalsIgnoreCase(protocol) && !"https".equalsIgnoreCase(protocol)) { + throw new SecurityException("Unsupported URL protocol: " + protocol); + } + byte[] bytes = this.mediaFetcher.fetch(url.toURI()); + sourceBuilder.bytes(SdkBytes.fromByteArrayUnsafe(bytes)).build(); } - catch (IOException e) { + catch (SecurityException | RestClientException | URISyntaxException e) { throw new IllegalArgumentException("Failed to read media data from URL: " + url, e); } } @@ -637,38 +657,6 @@ static String sanitizeDocumentName(String name) { return name.replaceAll("[^a-zA-Z0-9\\s\\-()\\[\\]]", "-"); } - private static byte[] getContentMediaData(Object mediaData) { - if (mediaData instanceof byte[] bytes) { - return bytes; - } - else if (mediaData instanceof String text) { - if (URLValidator.isValidURLBasic(text)) { - try { - URL url = new URL(text); - URLConnection connection = url.openConnection(); - try (InputStream is = connection.getInputStream()) { - return StreamUtils.copyToByteArray(is); - } - } - catch (IOException e) { - throw new RuntimeException("Failed to read media data from URL: " + text, e); - } - } - return text.getBytes(); - } - else if (mediaData instanceof URL url) { - try (InputStream is = url.openConnection().getInputStream()) { - return StreamUtils.copyToByteArray(is); - } - catch (IOException e) { - throw new RuntimeException("Failed to read media data from URL: " + url, e); - } - } - else { - throw new IllegalArgumentException("Unsupported media data type: " + mediaData.getClass().getSimpleName()); - } - } - /** * Convert {@link ConverseResponse} to {@link ChatResponse} includes model output, * stopReason, usage, metrics etc.
models/spring-ai-bedrock-converse/src/test/java/org/springframework/ai/bedrock/converse/api/MediaFetcherTest.java+182 −0 added@@ -0,0 +1,182 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.bedrock.converse.api; + +import java.net.URI; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +/** + * Tests for {@link MediaFetcher} covering the allowlist, SSRF blocklist, and size-limit + * protections. + */ +class MediaFetcherTest { + + private RestClient.Builder restClientBuilder; + + private MockRestServiceServer mockServer; + + @BeforeEach + void setUp() { + this.restClientBuilder = RestClient.builder(); + this.mockServer = MockRestServiceServer.bindTo(this.restClientBuilder).build(); + } + + // ------------------------------------------------------------------------- + // Allowlist rejection — no network call needed + // ------------------------------------------------------------------------- + + @Test + void fetchHostNotInAllowlistThrowsSecurityException() { + MediaFetcher fetcher = new MediaFetcher(Set.of("trusted.com")); + assertThatThrownBy(() -> fetcher.fetch(URI.create("http://evil.com/image.png"))) + .isInstanceOf(SecurityException.class) + .hasMessageContaining("evil.com"); + } + + @Test + void fetchWildcardDoesNotMatchApexDomainThrowsSecurityException() { + // *.example.com must NOT match example.com itself + MediaFetcher fetcher = new MediaFetcher(Set.of("*.example.com")); + assertThatThrownBy(() -> fetcher.fetch(URI.create("http://example.com/image.png"))) + .isInstanceOf(SecurityException.class); + } + + @Test + void fetchWildcardDoesNotMatchUnrelatedDomainThrowsSecurityException() { + MediaFetcher fetcher = new MediaFetcher(Set.of("*.example.com")); + assertThatThrownBy(() -> fetcher.fetch(URI.create("http://evil.notexample.com/image.png"))) + .isInstanceOf(SecurityException.class); + } + + // ------------------------------------------------------------------------- + // Allowlist pass-through — via MockRestServiceServer + // ------------------------------------------------------------------------- + + @Test + void fetchExactHostInAllowlistFetchSucceeds() { + MediaFetcher fetcher = new MediaFetcher(Set.of("example.com"), this.restClientBuilder.build()); + this.mockServer.expect(requestTo("http://example.com/image.png")) + .andRespond(withSuccess("imagedata", MediaType.IMAGE_PNG)); + + byte[] result = fetcher.fetch(URI.create("http://example.com/image.png")); + + assertThat(result).isEqualTo("imagedata".getBytes()); + this.mockServer.verify(); + } + + @Test + void fetchExactHostCaseInsensitiveFetchSucceeds() { + // Allowlist entry is uppercase; URI host is lowercase + MediaFetcher fetcher = new MediaFetcher(Set.of("EXAMPLE.COM"), this.restClientBuilder.build()); + this.mockServer.expect(requestTo("http://example.com/image.png")) + .andRespond(withSuccess("imagedata", MediaType.IMAGE_PNG)); + + byte[] result = fetcher.fetch(URI.create("http://example.com/image.png")); + + assertThat(result).isNotEmpty(); + this.mockServer.verify(); + } + + @Test + void fetchWildcardMatchesSubdomainFetchSucceeds() { + MediaFetcher fetcher = new MediaFetcher(Set.of("*.example.com"), this.restClientBuilder.build()); + this.mockServer.expect(requestTo("http://cdn.example.com/image.png")) + .andRespond(withSuccess("imagedata", MediaType.IMAGE_PNG)); + + byte[] result = fetcher.fetch(URI.create("http://cdn.example.com/image.png")); + + assertThat(result).isNotEmpty(); + this.mockServer.verify(); + } + + @Test + void fetchEmptyAllowlistNoAllowlistEnforced() { + // Empty allowlist → no allowlist check; only the SSRF blocklist applies + MediaFetcher fetcher = new MediaFetcher(Set.of(), this.restClientBuilder.build()); + this.mockServer.expect(requestTo("http://any-host.com/image.png")) + .andRespond(withSuccess("imagedata", MediaType.IMAGE_PNG)); + + byte[] result = fetcher.fetch(URI.create("http://any-host.com/image.png")); + + assertThat(result).isNotEmpty(); + this.mockServer.verify(); + } + + // ------------------------------------------------------------------------- + // SSRF blocking — connect-time defence (real MediaFetcher, no mock) + // + // Numeric IPs are resolved by the JDK without a real DNS round-trip, so + // these tests run offline. Both SsrfSafeDnsResolver and the socket-level + // factories throw SecurityException (RuntimeException), which propagates + // through Spring RestClient without being wrapped in RestClientException. + // ------------------------------------------------------------------------- + + @Test + void fetchLoopbackAddressBlockedAtConnectTime() { + MediaFetcher fetcher = new MediaFetcher(); + assertThatThrownBy(() -> fetcher.fetch(URI.create("http://127.0.0.1/image.png"))) + .isInstanceOf(SecurityException.class); + } + + @Test + void fetchAwsImdsAddressBlockedAtConnectTime() { + // 169.254.169.254 must never be reached + MediaFetcher fetcher = new MediaFetcher(); + assertThatThrownBy(() -> fetcher.fetch(URI.create("http://169.254.169.254/latest/meta-data/iam/"))) + .isInstanceOf(SecurityException.class); + } + + @Test + void fetchSiteLocalAddressBlockedAtConnectTime() { + MediaFetcher fetcher = new MediaFetcher(); + assertThatThrownBy(() -> fetcher.fetch(URI.create("http://10.0.0.1/image.png"))) + .isInstanceOf(SecurityException.class); + } + + // ------------------------------------------------------------------------- + // Size-limit protection + // ------------------------------------------------------------------------- + + @Test + void fetchContentLengthExceedsLimitThrowsSecurityException() { + MediaFetcher fetcher = new MediaFetcher(Set.of(), this.restClientBuilder.build()); + HttpHeaders headers = new HttpHeaders(); + headers.setContentLength((long) MediaFetcher.DEFAULT_MAX_FETCH_SIZE_BYTES + 1); + this.mockServer.expect(requestTo("http://cdn.example.com/big.png")) + .andRespond(withStatus(HttpStatus.OK).contentType(MediaType.IMAGE_PNG).headers(headers)); + + assertThatThrownBy(() -> fetcher.fetch(URI.create("http://cdn.example.com/big.png"))) + .isInstanceOf(SecurityException.class) + .hasMessageContaining("exceeds maximum allowed size"); + } + +}
models/spring-ai-bedrock-converse/src/test/java/org/springframework/ai/bedrock/converse/api/URLValidatorTest.java+120 −0 added@@ -0,0 +1,120 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.bedrock.converse.api; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for {@link URLValidator#assertNoInternalAddress} — the pre-flight SSRF guard. + * Numeric IPs are resolved by the JDK without a network round-trip, so these tests run + * offline and are reliable in CI. + */ +class URLValidatorTest { + + // ------------------------------------------------------------------------- + // Loopback: localhost explicitly allowed by old regex + // ------------------------------------------------------------------------- + + @ParameterizedTest(name = "assertNoInternalAddress blocks loopback: {0}") + @ValueSource(strings = { "127.0.0.1", "127.0.0.2", "::1" }) + void loopbackThrowsSecurityException(String host) { + assertThatThrownBy(() -> URLValidator.assertNoInternalAddress(host)).isInstanceOf(SecurityException.class) + .hasMessageContaining(host); + } + + @Test + void localhostThrowsSecurityException() { + // "localhost" resolves to 127.0.0.1 — the old regex explicitly allowed it + assertThatThrownBy(() -> URLValidator.assertNoInternalAddress("localhost")) + .isInstanceOf(SecurityException.class); + } + + // ------------------------------------------------------------------------- + // Link-local: AWS IMDS (169.254.169.254) + // ------------------------------------------------------------------------- + + @ParameterizedTest(name = "assertNoInternalAddress blocks link-local: {0}") + @ValueSource(strings = { "169.254.169.254", "169.254.0.1" }) + void awsImdsThrowsSecurityException(String host) { + // Primary scenario: AWS IMDS credential theft + assertThatThrownBy(() -> URLValidator.assertNoInternalAddress(host)).isInstanceOf(SecurityException.class) + .hasMessageContaining(host); + } + + // ------------------------------------------------------------------------- + // Site-local — private network ranges + // ------------------------------------------------------------------------- + + @ParameterizedTest(name = "assertNoInternalAddress blocks site-local: {0}") + @ValueSource(strings = { "10.0.0.1", "10.255.255.255", "172.16.0.1", "172.31.255.255", "192.168.0.1", + "192.168.255.255" }) + void privateRangesThrowsSecurityException(String host) { + assertThatThrownBy(() -> URLValidator.assertNoInternalAddress(host)).isInstanceOf(SecurityException.class) + .hasMessageContaining(host); + } + + // ------------------------------------------------------------------------- + // Wildcard / any-local + // ------------------------------------------------------------------------- + + @Test + void anyLocalThrowsSecurityException() { + assertThatThrownBy(() -> URLValidator.assertNoInternalAddress("0.0.0.0")).isInstanceOf(SecurityException.class); + } + + // ------------------------------------------------------------------------- + // Unresolvable host — fail-closed + // ------------------------------------------------------------------------- + + @Test + void unknownHostThrowsSecurityException() { + assertThatThrownBy(() -> URLValidator.assertNoInternalAddress("this-host-does-not-exist.invalid")) + .isInstanceOf(SecurityException.class) + .hasMessageContaining("Failed to resolve host"); + } + + // ------------------------------------------------------------------------- + // Internal domain names — metadata.google.internal + // Not tested by DNS resolution because the domain is not guaranteed to resolve + // in CI. The SsrfSafeDnsResolver in MediaFetcher provides the connect-time + // defence for such domains (see MediaFetcherTest). + // ------------------------------------------------------------------------- + + // ------------------------------------------------------------------------- + // isBlockedAddress — unit-level coverage of each flag + // ------------------------------------------------------------------------- + + @Test + void isBlockedAddressPublicIpv4ReturnsFalse() throws Exception { + // 8.8.8.8 is a well-known public IP; numeric resolution needs no DNS lookup + java.net.InetAddress google = java.net.InetAddress.getByName("8.8.8.8"); + assertThat(URLValidator.isBlockedAddress(google)).isFalse(); + } + + @Test + void doesNotThrowForPublicNumericIp() { + // 8.8.8.8 parsed without DNS; must not be blocked + assertThatCode(() -> URLValidator.assertNoInternalAddress("8.8.8.8")).doesNotThrowAnyException(); + } + +}
models/spring-ai-bedrock-converse/src/test/java/org/springframework/ai/bedrock/converse/BedrockProxyChatModelIT.java+1 −1 modified@@ -340,7 +340,7 @@ void streamFunctionCallTest() { } @ParameterizedTest(name = "{displayName} - {0} ") - @ValueSource(ints = { 50, 200 }) + @ValueSource(ints = { 50, 60 }) void streamFunctionCallTestWithMaxTokens(int maxTokens) { UserMessage userMessage = new UserMessage(
models/spring-ai-bedrock-converse/src/test/java/org/springframework/ai/bedrock/converse/BedrockProxyChatModelTest.java+137 −0 modified@@ -16,6 +16,9 @@ package org.springframework.ai.bedrock.converse; +import java.net.URL; + +import io.micrometer.observation.ObservationRegistry; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Answers; @@ -24,8 +27,17 @@ import org.mockito.junit.jupiter.MockitoExtension; import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.regions.providers.DefaultAwsRegionProviderChain; +import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeAsyncClient; +import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; + +import org.springframework.ai.bedrock.converse.api.MediaFetcher; +import org.springframework.ai.content.Media; +import org.springframework.ai.model.tool.DefaultToolExecutionEligibilityPredicate; +import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.util.MimeType; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; @@ -35,6 +47,18 @@ class BedrockProxyChatModelTest { @Mock(answer = Answers.RETURNS_DEEP_STUBS) private DefaultAwsRegionProviderChain.Builder awsRegionProviderBuilder; + @Mock + private BedrockRuntimeClient syncClient; + + @Mock + private BedrockRuntimeAsyncClient asyncClient; + + private BedrockProxyChatModel newModel() { + return new BedrockProxyChatModel(this.syncClient, this.asyncClient, BedrockChatOptions.builder().build(), + ObservationRegistry.NOOP, ToolCallingManager.builder().build(), + new DefaultToolExecutionEligibilityPredicate()); + } + @Test void shouldIgnoreExceptionAndUseDefault() { try (MockedStatic<DefaultAwsRegionProviderChain> mocked = mockStatic(DefaultAwsRegionProviderChain.class)) { @@ -64,4 +88,117 @@ void sanitizeDocumentNameShouldPreserveAllowedSpecialCharacters() { assertThat(BedrockProxyChatModel.sanitizeDocumentName(name)).isEqualTo(name); } + // ------------------------------------------------------------------------- + // Protocol rejection for URL-object media + // ------------------------------------------------------------------------- + + @Test + void fileProtocolUrlMediaThrowsIllegalArgumentException() throws Exception { + BedrockProxyChatModel model = newModel(); + Media media = Media.builder() + .mimeType(MimeType.valueOf("image/png")) + .data(new URL("file:///etc/passwd")) + .build(); + + assertThatThrownBy(() -> model.mapMediaToContentBlock(media)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Failed to read media data from URL") + .cause() + .isInstanceOf(SecurityException.class) + .hasMessageContaining("Unsupported URL protocol: file"); + } + + @Test + void ftpProtocolUrlMediaThrowsIllegalArgumentException() throws Exception { + BedrockProxyChatModel model = newModel(); + Media media = Media.builder() + .mimeType(MimeType.valueOf("image/png")) + .data(new URL("ftp://internal-server/data.png")) + .build(); + + assertThatThrownBy(() -> model.mapMediaToContentBlock(media)).isInstanceOf(IllegalArgumentException.class) + .cause() + .isInstanceOf(SecurityException.class) + .hasMessageContaining("Unsupported URL protocol: ftp"); + } + + // ------------------------------------------------------------------------- + // Pre-flight SSRF block for URL-object media + // ------------------------------------------------------------------------- + + @Test + void loopbackHttpUrlMediaThrowsIllegalArgumentException() throws Exception { + BedrockProxyChatModel model = newModel(); + Media media = Media.builder() + .mimeType(MimeType.valueOf("image/png")) + .data(new URL("http://127.0.0.1/image.png")) + .build(); + + assertThatThrownBy(() -> model.mapMediaToContentBlock(media)).isInstanceOf(IllegalArgumentException.class) + .cause() + .isInstanceOf(SecurityException.class); + } + + @Test + void awsImdsHttpUrlMediaThrowsIllegalArgumentException() throws Exception { + // Primary scenario: AWS IMDS credential theft via URL object + BedrockProxyChatModel model = newModel(); + Media media = Media.builder() + .mimeType(MimeType.valueOf("image/png")) + .data(new URL("http://169.254.169.254/latest/meta-data/iam/security-credentials/")) + .build(); + + assertThatThrownBy(() -> model.mapMediaToContentBlock(media)).isInstanceOf(IllegalArgumentException.class) + .cause() + .isInstanceOf(SecurityException.class); + } + + // ------------------------------------------------------------------------- + // Pre-flight SSRF block for String URL media + // ------------------------------------------------------------------------- + + @Test + void loopbackStringUrlMediaThrowsRuntimeException() { + BedrockProxyChatModel model = newModel(); + // 127.0.0.1 passes isValidURLStrict (has dots) but is blocked by + // assertNoInternalAddress + Media media = Media.builder() + .mimeType(MimeType.valueOf("image/png")) + .data("http://127.0.0.1/image.png") + .build(); + + assertThatThrownBy(() -> model.mapMediaToContentBlock(media)).isInstanceOf(RuntimeException.class) + .hasMessageContaining("URL is not valid under strict validation rules") + .isInstanceOf(SecurityException.class); + } + + @Test + void awsImdsStringUrlMediaThrowsRuntimeException() { + // Primary scenario: AWS IMDS credential theft via String URL + BedrockProxyChatModel model = newModel(); + Media media = Media.builder() + .mimeType(MimeType.valueOf("image/png")) + .data("http://169.254.169.254/latest/meta-data/iam/security-credentials/") + .build(); + + assertThatThrownBy(() -> model.mapMediaToContentBlock(media)).isInstanceOf(RuntimeException.class) + .isInstanceOf(SecurityException.class); + } + + // ------------------------------------------------------------------------- + // MediaFetcher injection allows restricting media sources (allowlist) + // ------------------------------------------------------------------------- + + @Test + void allowlistRejectsUnlistedStringUrlMediaThrowsRuntimeException() { + BedrockProxyChatModel model = new BedrockProxyChatModel(this.syncClient, this.asyncClient, + BedrockChatOptions.builder().build(), ObservationRegistry.NOOP, ToolCallingManager.builder().build(), + new DefaultToolExecutionEligibilityPredicate(), new MediaFetcher(java.util.Set.of("trusted-cdn.com"))); + Media media = Media.builder().mimeType(MimeType.valueOf("image/png")).data("http://evil.com/image.png").build(); + + assertThatThrownBy(() -> model.mapMediaToContentBlock(media)).isInstanceOf(RuntimeException.class) + .cause() + .isInstanceOf(SecurityException.class) + .hasMessageContaining("evil.com"); + } + }
spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock-converse.adoc+1 −1 modified@@ -120,7 +120,7 @@ At run-time you can override the default options by adding new, request specific [source,java] ---- var options = BedrockChatOptions.builder() - .model("anthropic.claude-3-5-sonnet-20240620-v1:0") + .model("us.anthropic.claude-haiku-4-5-20251001-v1:0") .temperature(0.6) .maxTokens(300) .toolCallbacks(List.of(FunctionToolCallback.builder("getCurrentWeather", new WeatherService())
Vulnerability mechanics
Synthesis attempt was rejected by the grounding validator. Re-run pending.
References
6- github.com/advisories/GHSA-mhrg-94vw-45c5ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-22742ghsaADVISORY
- spring.io/security/cve-2026-22742nvdVendor AdvisoryWEB
- github.com/spring-projects/spring-ai/commit/a7d3223bc11a010b93fbc40a17fa9b68c52a8118ghsaWEB
- github.com/spring-projects/spring-ai/releases/tag/v1.0.5ghsaWEB
- github.com/spring-projects/spring-ai/releases/tag/v1.1.4ghsaWEB
News mentions
0No linked articles in our index yet.