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