diff --git a/dev/README.md b/dev/README.md
index 97c49ea36..71da1c0f6 100644
--- a/dev/README.md
+++ b/dev/README.md
@@ -39,3 +39,34 @@ and the bundled `backendUrl` value is preserved. If you instead enable
`server.forward-headers-strategy=framework`, make sure your reverse proxy strips
or overwrites incoming `Forwarded` and `X-Forwarded-*` headers from untrusted
clients.
+
+## Cross-origin requests
+
+By default, the REST API and the `/run_live` WebSocket accept only same-origin
+requests. The dev UI served by the server itself is same-origin and needs no
+configuration.
+
+To allow other origins, for example a UI served by a separate development
+server, set `adk.web.cors.origins` to a comma-separated list of origins:
+
+```properties
+adk.web.cors.origins=http://localhost:4200
+```
+
+Behind a reverse proxy, a request also counts as same-origin when its origin
+matches the URL that the proxy reports in the `Forwarded` header, or in
+`X-Forwarded-*` headers when `Forwarded` is absent. The dev UI therefore keeps
+working behind a proxy on another machine that terminates TLS, such as Cloud
+Run's. List the external origin in `adk.web.cors.origins` if the proxy changes
+the scheme, host or port without reporting it; a reported scheme or host without
+a port implies the scheme's default port. Avoid `*`: it lets any website open in
+your browser call the server.
+
+### DNS rebinding
+
+To block pages that resolve their own domain to this machine, the server also
+rejects a non-loopback `Host` when `server.address` is a loopback address (the
+default for `mvn google-adk:web`), and a non-loopback `Origin` on a request that
+arrives over loopback. Origins in `adk.web.cors.origins`, and their hosts, are
+exempt, so a same-machine proxy that serves the UI under another name needs its
+origin listed. `*` turns both checks off.
diff --git a/dev/src/main/java/com/google/adk/web/config/AdkWebCorsConfig.java b/dev/src/main/java/com/google/adk/web/config/AdkWebCorsConfig.java
index 5e54f54cb..fce3af903 100644
--- a/dev/src/main/java/com/google/adk/web/config/AdkWebCorsConfig.java
+++ b/dev/src/main/java/com/google/adk/web/config/AdkWebCorsConfig.java
@@ -16,12 +16,17 @@
package com.google.adk.web.config;
+import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+import org.springframework.http.server.ServerHttpRequest;
+import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
+import org.springframework.web.cors.DefaultCorsProcessor;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
@@ -39,6 +44,7 @@
*
{@link CorsConfigurationSource}: Configures CORS settings such as allowed origins, methods,
* headers, credentials, and max age.
* {@link CorsFilter}: Applies the CORS configuration to incoming requests.
+ * {@code OriginGuardFilter}: Rejects requests from pages rebound onto this machine by DNS.
*
*/
@Configuration
@@ -50,10 +56,10 @@ public class AdkWebCorsConfig {
public CorsConfigurationSource corsConfigurationSource(AdkWebCorsProperties corsProperties) {
if (corsProperties.origins().contains("*")) {
logger.warn(
- "CORS is configured to allow all origins (\"*\"), which is insecure and intended for"
- + " local development only. This also applies to the /run_live WebSocket endpoint."
- + " Set 'adk.web.cors.origins' to an explicit allowlist to restrict which origins"
- + " may call the server.");
+ "CORS is configured to allow all origins (\"*\"), so any website open in a browser can"
+ + " call this server, including the /run_live WebSocket endpoint. Set"
+ + " 'adk.web.cors.origins' to an explicit allowlist to restrict which origins may"
+ + " call the server.");
}
CorsConfiguration configuration = new CorsConfiguration();
@@ -72,6 +78,29 @@ public CorsConfigurationSource corsConfigurationSource(AdkWebCorsProperties cors
@Bean
public CorsFilter corsFilter(CorsConfigurationSource corsConfigurationSource) {
- return new CorsFilter(corsConfigurationSource);
+ CorsFilter filter = new CorsFilter(corsConfigurationSource);
+ filter.setCorsProcessor(new ForwardedSameOriginCorsProcessor());
+ return filter;
+ }
+
+ @Bean
+ OriginGuardFilter originGuardFilter(
+ AdkWebCorsProperties corsProperties, @Value("${server.address:}") String serverAddress) {
+ return new OriginGuardFilter(corsProperties.origins(), serverAddress);
+ }
+
+ /** Treats a request as same-origin when its origin matches the URL a reverse proxy reports. */
+ private static final class ForwardedSameOriginCorsProcessor extends DefaultCorsProcessor {
+
+ @Override
+ protected boolean handleInternal(
+ ServerHttpRequest request,
+ ServerHttpResponse response,
+ CorsConfiguration config,
+ boolean preFlightRequest)
+ throws IOException {
+ return ForwardedSameOrigin.matches(request)
+ || super.handleInternal(request, response, config, preFlightRequest);
+ }
}
}
diff --git a/dev/src/main/java/com/google/adk/web/config/AdkWebCorsProperties.java b/dev/src/main/java/com/google/adk/web/config/AdkWebCorsProperties.java
index c96dce91e..234b98f4b 100644
--- a/dev/src/main/java/com/google/adk/web/config/AdkWebCorsProperties.java
+++ b/dev/src/main/java/com/google/adk/web/config/AdkWebCorsProperties.java
@@ -21,7 +21,8 @@
/**
* Properties for configuring CORS in ADK Web. This class is used to load CORS settings from
- * application properties.
+ * application properties. {@code origins} defaults to an empty list, which allows only same-origin
+ * requests.
*/
@ConfigurationProperties(prefix = "adk.web.cors")
public record AdkWebCorsProperties(
@@ -34,7 +35,7 @@ public record AdkWebCorsProperties(
public AdkWebCorsProperties {
mapping = mapping != null ? mapping : "/**";
- origins = origins != null && !origins.isEmpty() ? origins : List.of("*");
+ origins = origins != null ? origins : List.of();
methods =
methods != null && !methods.isEmpty()
? methods
diff --git a/dev/src/main/java/com/google/adk/web/config/ForwardedSameOrigin.java b/dev/src/main/java/com/google/adk/web/config/ForwardedSameOrigin.java
new file mode 100644
index 000000000..9c57a5984
--- /dev/null
+++ b/dev/src/main/java/com/google/adk/web/config/ForwardedSameOrigin.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * 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
+ *
+ * http://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 com.google.adk.web.config;
+
+import java.net.URI;
+import org.springframework.http.HttpRequest;
+import org.springframework.http.client.support.HttpRequestWrapper;
+import org.springframework.web.util.ForwardedHeaderUtils;
+import org.springframework.web.util.UriComponentsBuilder;
+import org.springframework.web.util.WebUtils;
+
+/**
+ * Same-origin check against the URL that a reverse proxy reports in the {@code Forwarded} header,
+ * or in {@code X-Forwarded-*} headers when {@code Forwarded} is absent. Browsers let a cross-origin
+ * page send those headers only after a CORS preflight, which fails the origin check, so such a page
+ * cannot fake a match. Other clients can send any value, so this is not an authorization check.
+ */
+public final class ForwardedSameOrigin {
+
+ /**
+ * Returns whether the {@code Origin} header matches the request URL as the proxy reports it. A
+ * request without an {@code Origin} header matches; one with a header Spring cannot parse does
+ * not.
+ */
+ public static boolean matches(HttpRequest request) {
+ if (request.getHeaders().getOrigin() == null) {
+ return true;
+ }
+ try {
+ UriComponentsBuilder forwarded =
+ ForwardedHeaderUtils.adaptFromForwardedHeaders(request.getURI(), request.getHeaders());
+ // Some proxies report a WebSocket handshake as ws or wss; the browser's Origin uses http(s).
+ String scheme = forwarded.build().getScheme();
+ if ("ws".equalsIgnoreCase(scheme)) {
+ forwarded.scheme("http");
+ } else if ("wss".equalsIgnoreCase(scheme)) {
+ forwarded.scheme("https");
+ }
+ URI forwardedUri = forwarded.build().toUri();
+ return WebUtils.isSameOrigin(
+ new HttpRequestWrapper(request) {
+ @Override
+ public URI getURI() {
+ return forwardedUri;
+ }
+ });
+ } catch (IllegalArgumentException | IllegalStateException | IndexOutOfBoundsException e) {
+ // Spring's parsers throw these for a malformed Origin, forwarded header or request URL.
+ return false;
+ }
+ }
+
+ private ForwardedSameOrigin() {}
+}
diff --git a/dev/src/main/java/com/google/adk/web/config/OriginGuardFilter.java b/dev/src/main/java/com/google/adk/web/config/OriginGuardFilter.java
new file mode 100644
index 000000000..e11406551
--- /dev/null
+++ b/dev/src/main/java/com/google/adk/web/config/OriginGuardFilter.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * 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
+ *
+ * http://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 com.google.adk.web.config;
+
+import com.google.common.base.Ascii;
+import com.google.common.base.CharMatcher;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.net.HostAndPort;
+import com.google.common.net.InetAddresses;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletRequestWrapper;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.List;
+import org.jspecify.annotations.Nullable;
+import org.springframework.http.HttpHeaders;
+import org.springframework.web.filter.OncePerRequestFilter;
+import org.springframework.web.util.UriComponentsBuilder;
+
+/**
+ * Rejects requests from a page that resolved its own domain to this machine (DNS rebinding): a
+ * foreign {@code Host} on a server bound to loopback, and a foreign {@code Origin} on a request
+ * that arrived over loopback. Listed origins and their hosts pass, and {@code *} turns both checks
+ * off.
+ */
+final class OriginGuardFilter extends OncePerRequestFilter {
+
+ private final boolean allowAll;
+ private final ImmutableSet origins;
+ private final ImmutableSet hosts;
+ private final boolean boundToLoopback;
+
+ OriginGuardFilter(List allowedOrigins, String bindAddress) {
+ this.allowAll = allowedOrigins.contains("*");
+ this.origins =
+ allowedOrigins.stream()
+ .map(OriginGuardFilter::normalizeOrigin)
+ .collect(ImmutableSet.toImmutableSet());
+ this.hosts =
+ origins.stream()
+ .map(OriginGuardFilter::originHost)
+ .filter(host -> !host.isEmpty())
+ .collect(ImmutableSet.toImmutableSet());
+ this.boundToLoopback = isLoopback(bindAddress);
+ }
+
+ @Override
+ protected void doFilterInternal(
+ HttpServletRequest request, HttpServletResponse response, FilterChain chain)
+ throws ServletException, IOException {
+ if (allowAll) {
+ chain.doFilter(request, response);
+ return;
+ }
+ // ForwardedHeaderFilter's wrapper takes the local address from headers a page can set.
+ HttpServletRequest connection = unwrap(request);
+ if (boundToLoopback && isForeignHost(connection)) {
+ reject(response, "host not allowed");
+ return;
+ }
+ String origin = request.getHeader(HttpHeaders.ORIGIN);
+ if (origin != null
+ && !origins.contains(normalizeOrigin(origin))
+ && isLoopback(connection.getLocalAddr())
+ && !isLoopback(originHost(origin))) {
+ // A rebound page's own domain fills Host and Origin alike, so same-origin checks pass it.
+ reject(response, "origin not allowed");
+ return;
+ }
+ chain.doFilter(request, response);
+ }
+
+ /** Whether the real Host, which a page cannot set, names neither loopback nor a listed host. */
+ private boolean isForeignHost(HttpServletRequest connection) {
+ // HTTP/2 sends the authority as the server name rather than a Host header.
+ String host = connection.getHeader(HttpHeaders.HOST);
+ if (host == null) {
+ host = connection.getServerName();
+ }
+ return !isLoopback(host) && !hosts.contains(bareHost(host));
+ }
+
+ private static HttpServletRequest unwrap(HttpServletRequest request) {
+ ServletRequest current = request;
+ while (current instanceof ServletRequestWrapper wrapper) {
+ current = wrapper.getRequest();
+ }
+ return current instanceof HttpServletRequest connection ? connection : request;
+ }
+
+ private static void reject(HttpServletResponse response, String reason) throws IOException {
+ response.setStatus(HttpServletResponse.SC_FORBIDDEN);
+ response.setContentType("text/plain");
+ response.getWriter().write("Forbidden: " + reason);
+ }
+
+ /** Whether host, with or without a port, is localhost or a loopback IP; never queries DNS. */
+ private static boolean isLoopback(@Nullable String host) {
+ String bare = bareHost(host);
+ return bare.equals("localhost")
+ || (InetAddresses.isInetAddress(bare) && InetAddresses.forString(bare).isLoopbackAddress());
+ }
+
+ /** Lowercases host and strips its port, brackets and trailing dot; empty if unparsable. */
+ private static String bareHost(@Nullable String host) {
+ if (host == null) {
+ return "";
+ }
+ try {
+ String bare = HostAndPort.fromString(host.trim()).getHost();
+ return Ascii.toLowerCase(CharMatcher.is('.').trimTrailingFrom(bare));
+ } catch (IllegalArgumentException e) {
+ return "";
+ }
+ }
+
+ private static String originHost(String origin) {
+ try {
+ return bareHost(UriComponentsBuilder.fromUriString(origin.trim()).build().getHost());
+ } catch (IllegalArgumentException e) {
+ return "";
+ }
+ }
+
+ private static String normalizeOrigin(String origin) {
+ return Ascii.toLowerCase(CharMatcher.is('/').trimTrailingFrom(origin.trim()));
+ }
+}
diff --git a/dev/src/main/java/com/google/adk/web/websocket/WebSocketConfig.java b/dev/src/main/java/com/google/adk/web/websocket/WebSocketConfig.java
index a17d72aff..7c69f1b9e 100644
--- a/dev/src/main/java/com/google/adk/web/websocket/WebSocketConfig.java
+++ b/dev/src/main/java/com/google/adk/web/websocket/WebSocketConfig.java
@@ -17,11 +17,18 @@
package com.google.adk.web.websocket;
import com.google.adk.web.config.AdkWebCorsProperties;
+import com.google.adk.web.config.ForwardedSameOrigin;
+import java.util.List;
+import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
+import org.springframework.http.server.ServerHttpRequest;
+import org.springframework.http.server.ServerHttpResponse;
+import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
+import org.springframework.web.socket.server.support.OriginHandshakeInterceptor;
/** Configuration class for WebSocket handling. */
@Configuration
@@ -42,6 +49,27 @@ public WebSocketConfig(
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry
.addHandler(liveWebSocketHandler, "/run_live")
- .setAllowedOrigins(corsProperties.origins().toArray(new String[0]));
+ .addInterceptors(new ForwardedAwareOriginInterceptor(corsProperties.origins()))
+ // The interceptor checks origins; the registry's own check ignores forwarded headers.
+ .setAllowedOrigins("*");
+ }
+
+ /** Origin check for the handshake that also accepts the URL a reverse proxy reports. */
+ private static final class ForwardedAwareOriginInterceptor extends OriginHandshakeInterceptor {
+
+ ForwardedAwareOriginInterceptor(List allowedOrigins) {
+ super(allowedOrigins);
+ }
+
+ @Override
+ public boolean beforeHandshake(
+ ServerHttpRequest request,
+ ServerHttpResponse response,
+ WebSocketHandler wsHandler,
+ Map attributes)
+ throws Exception {
+ return ForwardedSameOrigin.matches(request)
+ || super.beforeHandshake(request, response, wsHandler, attributes);
+ }
}
}
diff --git a/dev/src/test/java/com/google/adk/web/AdkWebServerCorsTest.java b/dev/src/test/java/com/google/adk/web/AdkWebServerCorsTest.java
new file mode 100644
index 000000000..087f87359
--- /dev/null
+++ b/dev/src/test/java/com/google/adk/web/AdkWebServerCorsTest.java
@@ -0,0 +1,274 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * 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
+ *
+ * http://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 com.google.adk.web;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.springframework.http.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
+import static org.springframework.http.HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD;
+import static org.springframework.http.HttpHeaders.ORIGIN;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.WebSocket;
+import java.net.http.WebSocketHandshakeException;
+import java.util.concurrent.CompletionException;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.RequestPostProcessor;
+
+/**
+ * Tests for the browser origins allowed to call the REST API and open the {@code /run_live}
+ * WebSocket.
+ */
+final class AdkWebServerCorsTest {
+
+ private static final String REMOTE_ORIGIN = "https://evil.example";
+ private static final HttpClient httpClient = HttpClient.newHttpClient();
+
+ @Nested
+ @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
+ @AutoConfigureMockMvc
+ class DefaultOrigins {
+
+ @ParameterizedTest
+ @ValueSource(strings = {REMOTE_ORIGIN, "http://localhost:4200"})
+ public void restApi_fromOtherOrigin_isRejected(String origin, @Autowired MockMvc mockMvc)
+ throws Exception {
+ // The DNS-rebinding guard does not apply to requests over the network, so CORS decides.
+ mockMvc
+ .perform(get("/list-apps").header(ORIGIN, origin).with(overNetwork()))
+ .andExpect(status().isForbidden())
+ .andExpect(header().doesNotExist(ACCESS_CONTROL_ALLOW_ORIGIN));
+ }
+
+ @Test
+ public void restApi_withMalformedOrigin_isRejected(@Autowired MockMvc mockMvc)
+ throws Exception {
+ mockMvc
+ .perform(get("/list-apps").header(ORIGIN, "http://a b").with(overNetwork()))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ public void restApi_fromSameOrigin_isAllowed(@Autowired MockMvc mockMvc) throws Exception {
+ // Same-origin requests skip the origin list, so any host name on the network works.
+ mockMvc
+ .perform(
+ get("http://devbox:8000/list-apps")
+ .header(ORIGIN, "http://devbox:8000")
+ .with(overNetwork()))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ public void restApi_behindTlsProxy_isAllowed(@Autowired MockMvc mockMvc) throws Exception {
+ // The proxy forwards plain HTTP; its X-Forwarded-Proto restores the browser's https origin.
+ mockMvc
+ .perform(
+ get("http://app.example.com/list-apps")
+ .header("X-Forwarded-Proto", "https")
+ .header(ORIGIN, "https://app.example.com")
+ .with(overNetwork()))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ public void restApi_behindTlsProxy_fromRemoteOrigin_isRejected(@Autowired MockMvc mockMvc)
+ throws Exception {
+ mockMvc
+ .perform(
+ get("http://app.example.com/list-apps")
+ .header("X-Forwarded-Proto", "https")
+ .header(ORIGIN, REMOTE_ORIGIN)
+ .with(overNetwork()))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ public void preflight_fromRemoteOrigin_isRejected(@Autowired MockMvc mockMvc) throws Exception {
+ mockMvc
+ .perform(
+ options("/apps/test-app/users/test-user/sessions/test-session")
+ .header(ORIGIN, REMOTE_ORIGIN)
+ .header(ACCESS_CONTROL_REQUEST_METHOD, "DELETE")
+ .with(overNetwork()))
+ .andExpect(status().isForbidden())
+ .andExpect(header().doesNotExist(ACCESS_CONTROL_ALLOW_ORIGIN));
+ }
+
+ @Test
+ public void runLive_fromSameOrigin_completesHandshake(@LocalServerPort int port) {
+ openRunLive(port, "http://localhost:" + port).abort();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {REMOTE_ORIGIN, "http://localhost:4200"})
+ public void runLive_fromOtherOrigin_isRejected(String origin, @LocalServerPort int port) {
+ assertHandshakeForbidden(port, origin);
+ }
+
+ @Test
+ public void runLive_behindTlsProxy_completesHandshake(@LocalServerPort int port) {
+ // A same-machine TLS proxy at https://127.0.0.1; only forwarded headers make this match.
+ WebSocket.Builder handshake =
+ httpClient
+ .newWebSocketBuilder()
+ .header(ORIGIN, "https://127.0.0.1")
+ .header("X-Forwarded-Proto", "https")
+ .header("X-Forwarded-Host", "127.0.0.1");
+
+ connect(port, handshake).abort();
+ }
+
+ @Test
+ public void runLive_reboundOriginBehindHostRewritingProxy_isRejected(
+ @LocalServerPort int port) {
+ // A rebound page reaching a same-machine proxy that forwards the page's own host.
+ WebSocket.Builder handshake =
+ httpClient
+ .newWebSocketBuilder()
+ .header(ORIGIN, REMOTE_ORIGIN)
+ .header("X-Forwarded-Proto", "https")
+ .header("X-Forwarded-Host", "evil.example");
+
+ assertHandshakeForbidden(port, handshake);
+ }
+ }
+
+ /** With the CORS filter mapped away from /run_live, the handshake's own origin check decides. */
+ @Nested
+ @SpringBootTest(
+ webEnvironment = WebEnvironment.RANDOM_PORT,
+ properties = "adk.web.cors.mapping=/apps/**")
+ class CorsFilterNotOnRunLive {
+
+ @Test
+ public void runLive_fromSameOrigin_completesHandshake(@LocalServerPort int port) {
+ openRunLive(port, "http://localhost:" + port).abort();
+ }
+
+ @Test
+ public void runLive_fromOtherLocalPort_isRejected(@LocalServerPort int port) {
+ // A loopback origin passes the DNS-rebinding guard, so only the handshake check refuses it.
+ assertHandshakeForbidden(port, "http://localhost:4200");
+ }
+ }
+
+ @Nested
+ @SpringBootTest(
+ webEnvironment = WebEnvironment.RANDOM_PORT,
+ properties = "adk.web.cors.origins=https://ui.example.com")
+ @AutoConfigureMockMvc
+ class ConfiguredOrigins {
+
+ @Test
+ public void restApi_fromConfiguredOrigin_isAllowed(@Autowired MockMvc mockMvc)
+ throws Exception {
+ mockMvc
+ .perform(get("/list-apps").header(ORIGIN, "https://ui.example.com"))
+ .andExpect(status().isOk())
+ .andExpect(header().string(ACCESS_CONTROL_ALLOW_ORIGIN, "https://ui.example.com"));
+ }
+
+ @Test
+ public void restApi_behindTlsProxy_isAllowed(@Autowired MockMvc mockMvc) throws Exception {
+ mockMvc
+ .perform(
+ get("http://app.example.com/list-apps")
+ .header("X-Forwarded-Proto", "https")
+ .header(ORIGIN, "https://app.example.com")
+ .with(overNetwork()))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ public void runLive_fromConfiguredOrigin_completesHandshake(@LocalServerPort int port) {
+ openRunLive(port, "https://ui.example.com").abort();
+ }
+
+ @Test
+ public void runLive_fromUnlistedOrigin_isRejected(@LocalServerPort int port) {
+ // A loopback origin passes the DNS-rebinding guard, so the configured list must refuse it.
+ assertHandshakeForbidden(port, "http://localhost:4200");
+ }
+ }
+
+ @Nested
+ @SpringBootTest(
+ webEnvironment = WebEnvironment.RANDOM_PORT,
+ properties = "adk.web.cors.origins=*")
+ @AutoConfigureMockMvc
+ class AllOriginsOptIn {
+
+ @Test
+ public void restApi_fromRemoteOrigin_isAllowed(@Autowired MockMvc mockMvc) throws Exception {
+ mockMvc
+ .perform(get("/list-apps").header(ORIGIN, REMOTE_ORIGIN))
+ .andExpect(status().isOk())
+ .andExpect(header().string(ACCESS_CONTROL_ALLOW_ORIGIN, "*"));
+ }
+
+ @Test
+ public void runLive_fromRemoteOrigin_completesHandshake(@LocalServerPort int port) {
+ openRunLive(port, REMOTE_ORIGIN).abort();
+ }
+ }
+
+ /** Delivers a MockMvc request on a routable address, as a LAN host or remote proxy does. */
+ private static RequestPostProcessor overNetwork() {
+ return request -> {
+ request.setLocalAddr("10.0.0.5");
+ return request;
+ };
+ }
+
+ private static WebSocket openRunLive(int port, String origin) {
+ return connect(port, httpClient.newWebSocketBuilder().header(ORIGIN, origin));
+ }
+
+ private static WebSocket connect(int port, WebSocket.Builder handshake) {
+ return handshake
+ .buildAsync(URI.create("ws://localhost:" + port + "/run_live"), new WebSocket.Listener() {})
+ .join();
+ }
+
+ private static void assertHandshakeForbidden(int port, String origin) {
+ assertHandshakeForbidden(port, httpClient.newWebSocketBuilder().header(ORIGIN, origin));
+ }
+
+ private static void assertHandshakeForbidden(int port, WebSocket.Builder handshake) {
+ CompletionException thrown =
+ assertThrows(CompletionException.class, () -> connect(port, handshake));
+
+ assertThat(thrown).hasCauseThat().isInstanceOf(WebSocketHandshakeException.class);
+ WebSocketHandshakeException cause = (WebSocketHandshakeException) thrown.getCause();
+ assertThat(cause.getResponse().statusCode()).isEqualTo(403);
+ }
+}
diff --git a/dev/src/test/java/com/google/adk/web/AdkWebServerDnsRebindingTest.java b/dev/src/test/java/com/google/adk/web/AdkWebServerDnsRebindingTest.java
new file mode 100644
index 000000000..1b03cebd6
--- /dev/null
+++ b/dev/src/test/java/com/google/adk/web/AdkWebServerDnsRebindingTest.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * 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
+ *
+ * http://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 com.google.adk.web;
+
+import static org.springframework.http.HttpHeaders.HOST;
+import static org.springframework.http.HttpHeaders.ORIGIN;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.test.web.servlet.MockMvc;
+
+/**
+ * Tests for the Host and Origin checks against DNS rebinding. MockMvc requests arrive on 127.0.0.1,
+ * as a rebound page's requests do.
+ */
+final class AdkWebServerDnsRebindingTest {
+
+ private static final String REBOUND_HOST = "evil.example:8000";
+ private static final String REBOUND_ORIGIN = "http://evil.example:8000";
+
+ @Nested
+ @SpringBootTest(properties = "server.address=127.0.0.1")
+ @AutoConfigureMockMvc
+ class LoopbackBind {
+
+ @ParameterizedTest
+ @ValueSource(strings = {"localhost:8000", "127.0.0.1:8000", "[::1]:8000"})
+ public void restApi_fromLoopbackHost_isAllowed(String host, @Autowired MockMvc mockMvc)
+ throws Exception {
+ mockMvc.perform(get("/list-apps").header(HOST, host)).andExpect(status().isOk());
+ }
+
+ @ParameterizedTest
+ @ValueSource(
+ strings = {
+ REBOUND_HOST,
+ "127.0.0.1.evil.example",
+ "127.0.0.1:8080.evil.example",
+ "localhost:8000, evil.example"
+ })
+ public void restApi_fromOtherHost_isRejected(String host, @Autowired MockMvc mockMvc)
+ throws Exception {
+ // A rebound page's same-origin GET carries no Origin, so only its Host shows the rebinding.
+ mockMvc.perform(get("/list-apps").header(HOST, host)).andExpect(status().isForbidden());
+ }
+
+ @Test
+ public void restApi_toOtherServerNameWithoutHostHeader_isRejected(@Autowired MockMvc mockMvc)
+ throws Exception {
+ // HTTP/2 carries the authority as the server name rather than a Host header.
+ mockMvc
+ .perform(get("http://" + REBOUND_HOST + "/list-apps"))
+ .andExpect(status().isForbidden());
+ }
+ }
+
+ @Nested
+ @SpringBootTest(
+ properties = {"server.address=127.0.0.1", "adk.web.cors.origins=https://ui.example.com"})
+ @AutoConfigureMockMvc
+ class LoopbackBindWithConfiguredOrigin {
+
+ @Test
+ public void restApi_throughProxyForConfiguredOrigin_isAllowed(@Autowired MockMvc mockMvc)
+ throws Exception {
+ // A listed origin's host is allowed as Host, which a same-machine proxy sends.
+ mockMvc
+ .perform(
+ get("/list-apps")
+ .header(HOST, "ui.example.com")
+ .header(ORIGIN, "https://ui.example.com"))
+ .andExpect(status().isOk());
+ }
+ }
+
+ @Nested
+ @SpringBootTest(properties = {"server.address=127.0.0.1", "adk.web.cors.origins=*"})
+ @AutoConfigureMockMvc
+ class AllOriginsOptIn {
+
+ @Test
+ public void restApi_fromReboundHost_isAllowed(@Autowired MockMvc mockMvc) throws Exception {
+ mockMvc.perform(get("/list-apps").header(HOST, REBOUND_HOST)).andExpect(status().isOk());
+ }
+ }
+
+ @Nested
+ @SpringBootTest
+ @AutoConfigureMockMvc
+ class UndeclaredBind {
+
+ @Test
+ public void restApi_fromOtherHostWithoutOrigin_isAllowed(@Autowired MockMvc mockMvc)
+ throws Exception {
+ // Without a loopback bind, the server may be reachable under any name.
+ mockMvc.perform(get("/list-apps").header(HOST, REBOUND_HOST)).andExpect(status().isOk());
+ }
+
+ @Test
+ public void restApi_reboundOriginOverLoopback_isRejected(@Autowired MockMvc mockMvc)
+ throws Exception {
+ mockMvc
+ .perform(get("/list-apps").header(HOST, REBOUND_HOST).header(ORIGIN, REBOUND_ORIGIN))
+ .andExpect(status().isForbidden());
+ }
+ }
+
+ @Nested
+ @SpringBootTest(properties = "server.forward-headers-strategy=framework")
+ @AutoConfigureMockMvc
+ class ForwardedHeaderFilterEnabled {
+
+ @Test
+ public void restApi_reboundOriginClaimingNetworkAddress_isRejected(@Autowired MockMvc mockMvc)
+ throws Exception {
+ // Spring turns Forwarded: by= into the local address, and a same-origin page can send it.
+ mockMvc
+ .perform(
+ get("/list-apps")
+ .header(HOST, REBOUND_HOST)
+ .header(ORIGIN, REBOUND_ORIGIN)
+ .header("Forwarded", "by=10.0.0.5"))
+ .andExpect(status().isForbidden());
+ }
+ }
+}
diff --git a/dev/src/test/java/com/google/adk/web/config/ForwardedSameOriginTest.java b/dev/src/test/java/com/google/adk/web/config/ForwardedSameOriginTest.java
new file mode 100644
index 000000000..5d6e5b5df
--- /dev/null
+++ b/dev/src/test/java/com/google/adk/web/config/ForwardedSameOriginTest.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * 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
+ *
+ * http://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 com.google.adk.web.config;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.springframework.http.server.ServletServerHttpRequest;
+import org.springframework.mock.web.MockHttpServletRequest;
+
+final class ForwardedSameOriginTest {
+
+ @Test
+ public void matches_originOfDirectRequest_returnsTrue() {
+ MockHttpServletRequest request = requestTo("app.example.com");
+ request.addHeader("Origin", "http://app.example.com");
+
+ assertThat(ForwardedSameOrigin.matches(new ServletServerHttpRequest(request))).isTrue();
+ }
+
+ @Test
+ public void matches_originFromXForwardedHeaders_returnsTrue() {
+ MockHttpServletRequest request = requestTo("localhost");
+ request.addHeader("Origin", "https://app.example.com");
+ request.addHeader("X-Forwarded-Proto", "https");
+ request.addHeader("X-Forwarded-Host", "app.example.com");
+
+ assertThat(ForwardedSameOrigin.matches(new ServletServerHttpRequest(request))).isTrue();
+ }
+
+ @Test
+ public void matches_originFromForwardedHeader_returnsTrue() {
+ MockHttpServletRequest request = requestTo("localhost");
+ request.addHeader("Origin", "https://app.example.com");
+ request.addHeader("Forwarded", "proto=https;host=app.example.com");
+
+ assertThat(ForwardedSameOrigin.matches(new ServletServerHttpRequest(request))).isTrue();
+ }
+
+ @ParameterizedTest
+ @CsvSource({"ws, http://app.example.com", "wss, https://app.example.com"})
+ public void matches_webSocketSchemeFromProxy_returnsTrue(String scheme, String origin) {
+ MockHttpServletRequest request = requestTo("localhost");
+ request.addHeader("Origin", origin);
+ request.addHeader("X-Forwarded-Proto", scheme);
+ request.addHeader("X-Forwarded-Host", "app.example.com");
+
+ assertThat(ForwardedSameOrigin.matches(new ServletServerHttpRequest(request))).isTrue();
+ }
+
+ @Test
+ public void matches_forwardedWithoutHost_ignoresXForwardedHost() {
+ MockHttpServletRequest request = requestTo("localhost");
+ request.addHeader("Origin", "https://app.example.com");
+ request.addHeader("Forwarded", "proto=https");
+ request.addHeader("X-Forwarded-Host", "app.example.com");
+
+ assertThat(ForwardedSameOrigin.matches(new ServletServerHttpRequest(request))).isFalse();
+ }
+
+ @Test
+ public void matches_forwardedSchemeWithoutPort_assumesDefaultPort() {
+ MockHttpServletRequest request = requestTo("app.example.com");
+ request.setServerPort(8443);
+ request.addHeader("Origin", "https://app.example.com:8443");
+ request.addHeader("X-Forwarded-Proto", "https");
+
+ assertThat(ForwardedSameOrigin.matches(new ServletServerHttpRequest(request))).isFalse();
+ }
+
+ @Test
+ public void matches_otherOriginBehindProxy_returnsFalse() {
+ MockHttpServletRequest request = requestTo("localhost");
+ request.addHeader("Origin", "https://evil.example");
+ request.addHeader("X-Forwarded-Proto", "https");
+ request.addHeader("X-Forwarded-Host", "app.example.com");
+
+ assertThat(ForwardedSameOrigin.matches(new ServletServerHttpRequest(request))).isFalse();
+ }
+
+ @Test
+ public void matches_noOriginHeader_returnsTrue() {
+ MockHttpServletRequest request = requestTo("localhost");
+ // Without an Origin there is nothing to compare, so even a malformed forwarded header passes.
+ request.addHeader("X-Forwarded-Port", "not-a-port");
+
+ assertThat(ForwardedSameOrigin.matches(new ServletServerHttpRequest(request))).isTrue();
+ }
+
+ @Test
+ public void matches_malformedOrigin_returnsFalse() {
+ MockHttpServletRequest request = requestTo("localhost");
+ request.addHeader("Origin", "http://a b");
+
+ assertThat(ForwardedSameOrigin.matches(new ServletServerHttpRequest(request))).isFalse();
+ }
+
+ @Test
+ public void matches_emptyForwardedProtoList_returnsFalse() {
+ MockHttpServletRequest request = requestTo("localhost");
+ request.addHeader("Origin", "http://localhost");
+ request.addHeader("X-Forwarded-Proto", ",");
+
+ assertThat(ForwardedSameOrigin.matches(new ServletServerHttpRequest(request))).isFalse();
+ }
+
+ @Test
+ public void matches_malformedForwardedHost_returnsFalse() {
+ MockHttpServletRequest request = requestTo("localhost");
+ request.addHeader("Origin", "https://app.example.com");
+ request.addHeader("X-Forwarded-Host", "a b");
+
+ assertThat(ForwardedSameOrigin.matches(new ServletServerHttpRequest(request))).isFalse();
+ }
+
+ @Test
+ public void matches_malformedForwardedPort_returnsFalse() {
+ MockHttpServletRequest request = requestTo("localhost");
+ request.addHeader("Origin", "https://app.example.com");
+ request.addHeader("X-Forwarded-Host", "app.example.com");
+ request.addHeader("X-Forwarded-Port", "not-a-port");
+
+ assertThat(ForwardedSameOrigin.matches(new ServletServerHttpRequest(request))).isFalse();
+ }
+
+ private static MockHttpServletRequest requestTo(String host) {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/list-apps");
+ request.setServerName(host);
+ return request;
+ }
+}