Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions dev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,21 @@ adk.web.cors.origins=http://localhost:4200
```

Behind a reverse proxy, the same-origin check uses the URL that the proxy
reports in forwarded headers, so the dev UI keeps working behind a proxy that
terminates TLS. A proxy on a non-default port must include the port in its
forwarded headers. List the external origin in `adk.web.cors.origins` if the
proxy does not report the external URL, or if it reports `ws` or `wss` as the
scheme of WebSocket requests (as Traefik does). Avoid `*`: it lets any website
open in your browser call the server.
reports in forwarded headers, so the dev UI keeps working behind a proxy on
another machine that terminates TLS. A proxy on a non-default port must include
the port in its forwarded headers. List the external origin in
`adk.web.cors.origins` if the proxy does not report the external URL, or if it
reports `ws` or `wss` as the scheme of WebSocket requests (as Traefik does).
Avoid `*`: it lets any website open in your browser call the server.

### DNS rebinding

The server also blocks pages that resolve their own domain to this machine. When
`server.address` is a loopback address (the default for `mvn google-adk:web`),
it rejects a non-loopback `Host`. On a request that arrives over loopback, it
rejects a non-loopback `Origin`. Origins in `adk.web.cors.origins`, and their
hosts, are exempt. A UI reached over loopback under another name, through a
same-machine proxy or a hosts-file alias, therefore needs its origin listed. `*`
turns both checks off. `AdkWebServer.start()` and `main()` do not set
`server.address`, so set `server.address=127.0.0.1` there to turn on the `Host`
check.
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.web.server.autoconfigure.servlet.ForwardedHeaderFilterCustomizer;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
Expand Down Expand Up @@ -48,6 +49,7 @@
* <li>{@link CorsFilter}: Applies the CORS configuration to incoming requests.
* <li>{@link ForwardedHeaderFilter}: Applies reverse proxy headers to the URL of every request,
* so the same-origin checks see the URL the browser used.
* <li>{@code OriginGuardFilter}: Rejects requests from pages rebound onto this machine by DNS.
* </ul>
*/
@Configuration
Expand Down Expand Up @@ -84,6 +86,12 @@ public CorsFilter corsFilter(CorsConfigurationSource corsConfigurationSource) {
return new CorsFilter(corsConfigurationSource);
}

@Bean
OriginGuardFilter originGuardFilter(
AdkWebCorsProperties corsProperties, @Value("${server.address:}") String serverAddress) {
return new OriginGuardFilter(corsProperties.origins(), serverAddress);
}

/**
* Makes {@code server.forward-headers-strategy=framework} the default, so same-origin checks see
* the URL the browser used behind a TLS-terminating proxy. Registered like Spring Boot's own
Expand Down
145 changes: 145 additions & 0 deletions dev/src/main/java/com/google/adk/web/config/OriginGuardFilter.java
Original file line number Diff line number Diff line change
@@ -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<String> allowedOrigins;
private final ImmutableSet<String> allowedHosts;
private final boolean boundToLoopback;

OriginGuardFilter(List<String> origins, String bindAddress) {
this.allowAll = origins.contains("*");
this.allowedOrigins =
origins.stream()
.map(OriginGuardFilter::normalizeOrigin)
.collect(ImmutableSet.toImmutableSet());
this.allowedHosts =
allowedOrigins.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 server name and local address from page headers.
HttpServletRequest containerRequest = unwrap(request);
if (boundToLoopback && isForeignHost(containerRequest)) {
reject(response, "host not allowed");
return;
}
String origin = request.getHeader(HttpHeaders.ORIGIN);
if (origin != null
&& !allowedOrigins.contains(normalizeOrigin(origin))
&& isLoopback(containerRequest.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 containerRequest) {
// HTTP/2 sends the authority as the server name rather than a Host header.
String host = containerRequest.getHeader(HttpHeaders.HOST);
if (host == null) {
host = containerRequest.getServerName();
}
return !isLoopback(host) && !allowedHosts.contains(bareHost(host));
}

private static HttpServletRequest unwrap(HttpServletRequest request) {
ServletRequest current = request;
while (current instanceof ServletRequestWrapper wrapper) {
current = wrapper.getRequest();
}
return (HttpServletRequest) current;
}

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()));
}
}
66 changes: 51 additions & 15 deletions dev/src/test/java/com/google/adk/web/AdkWebServerCorsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
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}
Expand All @@ -61,17 +62,21 @@ class DefaultOrigins {
@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))
.perform(get("/list-apps").header(ORIGIN, origin).with(overNetwork()))
.andExpect(status().isForbidden())
.andExpect(header().doesNotExist(ACCESS_CONTROL_ALLOW_ORIGIN));
}

@Test
public void restApi_fromSameOrigin_isAllowed(@Autowired MockMvc mockMvc) throws Exception {
// Same-origin requests skip the origin list, so the bundled UI works from any host name.
// 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"))
.perform(
get("http://devbox:8000/list-apps")
.header(ORIGIN, "http://devbox:8000")
.with(overNetwork()))
.andExpect(status().isOk());
}

Expand All @@ -82,7 +87,8 @@ public void restApi_behindTlsProxy_isAllowed(@Autowired MockMvc mockMvc) throws
.perform(
get("http://app.example.com/list-apps")
.header("X-Forwarded-Proto", "https")
.header(ORIGIN, "https://app.example.com"))
.header(ORIGIN, "https://app.example.com")
.with(overNetwork()))
.andExpect(status().isOk());
}

Expand All @@ -93,7 +99,8 @@ public void restApi_behindTlsProxy_fromRemoteOrigin_isRejected(@Autowired MockMv
.perform(
get("http://app.example.com/list-apps")
.header("X-Forwarded-Proto", "https")
.header(ORIGIN, REMOTE_ORIGIN))
.header(ORIGIN, REMOTE_ORIGIN)
.with(overNetwork()))
.andExpect(status().isForbidden());
}

Expand All @@ -105,7 +112,8 @@ public void restApi_behindTlsProxy_fromPlainHttpOrigin_isRejected(@Autowired Moc
.perform(
get("http://app.example.com/list-apps")
.header("X-Forwarded-Proto", "https")
.header(ORIGIN, "http://app.example.com"))
.header(ORIGIN, "http://app.example.com")
.with(overNetwork()))
.andExpect(status().isForbidden());
}

Expand All @@ -115,7 +123,8 @@ public void preflight_fromRemoteOrigin_isRejected(@Autowired MockMvc mockMvc) th
.perform(
options("/apps/test-app/users/test-user/sessions/test-session")
.header(ORIGIN, REMOTE_ORIGIN)
.header(ACCESS_CONTROL_REQUEST_METHOD, "DELETE"))
.header(ACCESS_CONTROL_REQUEST_METHOD, "DELETE")
.with(overNetwork()))
.andExpect(status().isForbidden())
.andExpect(header().doesNotExist(ACCESS_CONTROL_ALLOW_ORIGIN));
}
Expand All @@ -133,12 +142,13 @@ public void runLive_fromOtherOrigin_isRejected(String origin, @LocalServerPort i

@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://app.example.com")
.header(ORIGIN, "https://127.0.0.1")
.header("X-Forwarded-Proto", "https")
.header("X-Forwarded-Host", "app.example.com");
.header("X-Forwarded-Host", "127.0.0.1");

connect(port, handshake).abort();
}
Expand All @@ -153,6 +163,20 @@ public void runLive_behindTlsProxy_fromPlainHttpOrigin_isRejected(@LocalServerPo

assertHandshakeForbidden(port, handshake);
}

@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, "https://evil.example")
.header("X-Forwarded-Proto", "https")
.header("X-Forwarded-Host", "evil.example");

assertHandshakeForbidden(port, handshake);
}
}

@Nested
Expand All @@ -166,12 +190,13 @@ public void restApi_behindTlsProxy_isRejected(@Autowired MockMvc mockMvc) throws
.perform(
get("http://app.example.com/list-apps")
.header("X-Forwarded-Proto", "https")
.header(ORIGIN, "https://app.example.com"))
.header(ORIGIN, "https://app.example.com")
.with(overNetwork()))
.andExpect(status().isForbidden());
}
}

/** With the CORS filter mapped away from /run_live, only the handshake's origin check runs. */
/** With the CORS filter mapped away from /run_live, the handshake's own origin check decides. */
@Nested
@SpringBootTest(
webEnvironment = WebEnvironment.RANDOM_PORT,
Expand All @@ -184,8 +209,9 @@ public void runLive_fromSameOrigin_completesHandshake(@LocalServerPort int port)
}

@Test
public void runLive_fromRemoteOrigin_isRejected(@LocalServerPort int port) {
assertHandshakeForbidden(port, REMOTE_ORIGIN);
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");
}
}

Expand All @@ -211,7 +237,8 @@ public void restApi_behindTlsProxy_isAllowed(@Autowired MockMvc mockMvc) throws
.perform(
get("http://app.example.com/list-apps")
.header("X-Forwarded-Proto", "https")
.header(ORIGIN, "https://app.example.com"))
.header(ORIGIN, "https://app.example.com")
.with(overNetwork()))
.andExpect(status().isOk());
}

Expand All @@ -222,7 +249,8 @@ public void runLive_fromConfiguredOrigin_completesHandshake(@LocalServerPort int

@Test
public void runLive_fromUnlistedOrigin_isRejected(@LocalServerPort int port) {
assertHandshakeForbidden(port, REMOTE_ORIGIN);
// A loopback origin passes the DNS-rebinding guard, so the configured list must refuse it.
assertHandshakeForbidden(port, "http://localhost:4200");
}
}

Expand All @@ -247,6 +275,14 @@ public void runLive_fromRemoteOrigin_completesHandshake(@LocalServerPort int por
}
}

/** 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));
}
Expand Down
Loading
Loading