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; + } +}