Skip to content
Open
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
31 changes: 31 additions & 0 deletions dev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
39 changes: 34 additions & 5 deletions dev/src/main/java/com/google/adk/web/config/AdkWebCorsConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -39,6 +44,7 @@
* <li>{@link CorsConfigurationSource}: Configures CORS settings such as allowed origins, methods,
* headers, credentials, and max age.
* <li>{@link CorsFilter}: Applies the CORS configuration to incoming requests.
* <li>{@code OriginGuardFilter}: Rejects requests from pages rebound onto this machine by DNS.
* </ul>
*/
@Configuration
Expand All @@ -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();
Expand All @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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() {}
}
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> origins;
private final ImmutableSet<String> hosts;
private final boolean boundToLoopback;

OriginGuardFilter(List<String> 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()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String> allowedOrigins) {
super(allowedOrigins);
}

@Override
public boolean beforeHandshake(
ServerHttpRequest request,
ServerHttpResponse response,
WebSocketHandler wsHandler,
Map<String, Object> attributes)
throws Exception {
return ForwardedSameOrigin.matches(request)
|| super.beforeHandshake(request, response, wsHandler, attributes);
}
}
}
Loading
Loading