VYPR
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.

PackageAffected versionsPatched versions
org.springframework.ai:spring-ai-bedrock-converseMaven
>= 1.0.0-M5, < 1.0.51.0.5
org.springframework.ai:spring-ai-bedrock-converseMaven
>= 1.1.0-M1, < 1.1.41.1.4

Affected products

1
  • cpe:2.3:a:vmware:spring_ai:*:*:*:*:*:*:*:*
    Range: >=1.0.0,<1.0.5

Patches

1
a7d3223bc11a

Improve media fetching robustness in BedrockProxyChatModel

https://github.com/spring-projects/spring-aiChristian TzolovMar 20, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.