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
21 changes: 21 additions & 0 deletions dev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,24 @@ 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 that terminates TLS. 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.
31 changes: 26 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,16 @@

package com.google.adk.web.config;

import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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 Down Expand Up @@ -50,10 +54,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 +76,23 @@ 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;
}

/** 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() {}
}
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