diff --git a/dev/README.md b/dev/README.md
index a1ac809db..3c8859578 100644
--- a/dev/README.md
+++ b/dev/README.md
@@ -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.
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 364586e5b..63926cb80 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
@@ -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;
@@ -48,6 +49,7 @@
*
{@link CorsFilter}: Applies the CORS configuration to incoming requests.
* {@link ForwardedHeaderFilter}: Applies reverse proxy headers to the URL of every request,
* so the same-origin checks see the URL the browser used.
+ * {@code OriginGuardFilter}: Rejects requests from pages rebound onto this machine by DNS.
*
*/
@Configuration
@@ -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
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..ea563917f
--- /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 allowedOrigins;
+ private final ImmutableSet allowedHosts;
+ private final boolean boundToLoopback;
+
+ OriginGuardFilter(List 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()));
+ }
+}
diff --git a/dev/src/test/java/com/google/adk/web/AdkWebServerCorsTest.java b/dev/src/test/java/com/google/adk/web/AdkWebServerCorsTest.java
index deb359803..2ad12a140 100644
--- a/dev/src/test/java/com/google/adk/web/AdkWebServerCorsTest.java
+++ b/dev/src/test/java/com/google/adk/web/AdkWebServerCorsTest.java
@@ -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}
@@ -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());
}
@@ -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());
}
@@ -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());
}
@@ -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());
}
@@ -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));
}
@@ -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();
}
@@ -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
@@ -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,
@@ -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");
}
}
@@ -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());
}
@@ -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");
}
}
@@ -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));
}
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..d279f0082
--- /dev/null
+++ b/dev/src/test/java/com/google/adk/web/AdkWebServerDnsRebindingTest.java
@@ -0,0 +1,187 @@
+/*
+ * 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_toLoopbackHost_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_toOtherHost_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_toLoopbackServerNameWithoutHostHeader_isAllowed(@Autowired MockMvc mockMvc)
+ throws Exception {
+ mockMvc.perform(get("http://localhost:8000/list-apps")).andExpect(status().isOk());
+ }
+
+ @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());
+ }
+
+ @Test
+ public void restApi_toOtherServerNameClaimingLoopbackHost_isRejected(@Autowired MockMvc mockMvc)
+ throws Exception {
+ // Spring takes the server name from X-Forwarded-Host, which a rebound page can send.
+ mockMvc
+ .perform(
+ get("http://" + REBOUND_HOST + "/list-apps")
+ .header("X-Forwarded-Host", "localhost:8000"))
+ .andExpect(status().isForbidden());
+ }
+ }
+
+ @Nested
+ @SpringBootTest(
+ properties = {"server.address=127.0.0.1", "adk.web.cors.origins=https://UI.example.com/"})
+ @AutoConfigureMockMvc
+ class LoopbackBindWithConfiguredOrigin {
+
+ @ParameterizedTest
+ @ValueSource(strings = {"ui.example.com", "ui.example.com."})
+ public void restApi_throughProxyForConfiguredOrigin_isAllowed(
+ String host, @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, host).header(ORIGIN, "https://ui.example.com"))
+ .andExpect(status().isOk());
+ }
+ }
+
+ @Nested
+ @SpringBootTest(properties = "server.address=0.0.0.0")
+ @AutoConfigureMockMvc
+ class WildcardBind {
+
+ @Test
+ public void restApi_fromWildcardOriginOverLoopback_isRejected(@Autowired MockMvc mockMvc)
+ throws Exception {
+ // A browser opening http://0.0.0.0 reaches loopback, but 0.0.0.0 is no loopback name.
+ mockMvc
+ .perform(
+ get("/list-apps").header(HOST, "0.0.0.0:8000").header(ORIGIN, "http://0.0.0.0:8000"))
+ .andExpect(status().isForbidden());
+ }
+ }
+
+ @Nested
+ @SpringBootTest(properties = "adk.web.cors.origins=https://ui.example.com")
+ @AutoConfigureMockMvc
+ class UndeclaredBindWithConfiguredOrigin {
+
+ @Test
+ public void restApi_reboundOriginOverLoopback_isRejected(@Autowired MockMvc mockMvc)
+ throws Exception {
+ // Unlike Python ADK, and like Go, listing origins keeps the Origin check on.
+ mockMvc
+ .perform(get("/list-apps").header(HOST, REBOUND_HOST).header(ORIGIN, REBOUND_ORIGIN))
+ .andExpect(status().isForbidden());
+ }
+ }
+
+ @Nested
+ @SpringBootTest(properties = {"server.address=127.0.0.1", "adk.web.cors.origins=*"})
+ @AutoConfigureMockMvc
+ class AllOriginsOptIn {
+
+ @Test
+ public void restApi_toReboundHost_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_toOtherHostWithoutOrigin_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());
+ }
+
+ @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/maven_plugin/README.md b/maven_plugin/README.md
index 448f297c3..34f0691b7 100644
--- a/maven_plugin/README.md
+++ b/maven_plugin/README.md
@@ -257,7 +257,7 @@ instruction: |
Once the server starts, open your browser to:
- Default: http://localhost:8000
- Custom port: http://localhost:{port}
-- External access: http://0.0.0.0:{port} (if using -Dhost=0.0.0.0)
+- With -Dhost=0.0.0.0: http://localhost:{port}, or the machine's address
The web UI provides:
- Interactive chat interface with your agents
diff --git a/maven_plugin/src/main/java/com/google/adk/maven/WebMojo.java b/maven_plugin/src/main/java/com/google/adk/maven/WebMojo.java
index 78935155a..99dc19381 100644
--- a/maven_plugin/src/main/java/com/google/adk/maven/WebMojo.java
+++ b/maven_plugin/src/main/java/com/google/adk/maven/WebMojo.java
@@ -32,6 +32,7 @@
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
+import java.util.Set;
import org.apache.maven.artifact.DependencyResolutionRequiredException;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
@@ -82,8 +83,8 @@
*
* Web Interface
*
- * Once started, ADK Web UI is available at {@code http://host:port} where users can interact
- * with available agents.
+ *
Once started, ADK Web UI is available at {@code http://host:port}, or at {@code
+ * http://localhost:port} for a loopback or wildcard host.
*
* @author Google ADK Team
*/
@@ -255,7 +256,7 @@ public void execute() throws MojoExecutionException, MojoFailureException {
applicationContext = app.run(new String[0]);
getLog().info("đ ADK Web Server started successfully!");
- getLog().info("đ Web UI available at: http://" + host + ":" + port);
+ getLog().info("đ Web UI available at: http://" + displayHost(host) + ":" + port);
getLog().info("âšī¸ Press Ctrl+C to stop the server...");
getLog().info("");
@@ -549,4 +550,12 @@ private void clearSystemProperties() {
getLog().warn("Error clearing system properties", e);
}
}
+
+ /**
+ * Returns localhost for loopback and wildcard hosts, like ADK Go: the server rejects Origin
+ * http://0.0.0.0 over loopback, and http://::1:8000 is not a valid URL.
+ */
+ private static String displayHost(String host) {
+ return Set.of("127.0.0.1", "::1", "0.0.0.0", "::").contains(host) ? "localhost" : host;
+ }
}