Skip to content

Commit 3e337af

Browse files
Mateusz Krawieccopybara-github
authored andcommitted
fix(dev): reject DNS-rebinding requests that reach the ADK Web server over loopback
- Rejects a non-loopback `Host` when `server.address` is a loopback address, and a non-loopback `Origin` on a request that arrives over loopback, unless the origin is listed in `adk.web.cors.origins`. A same-machine proxy that serves the UI under another name needs its origin listed. PiperOrigin-RevId: 1000840345
1 parent 97f22a2 commit 3e337af

9 files changed

Lines changed: 878 additions & 8 deletions

File tree

‎dev/README.md‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,34 @@ and the bundled `backendUrl` value is preserved. If you instead enable
3939
`server.forward-headers-strategy=framework`, make sure your reverse proxy strips
4040
or overwrites incoming `Forwarded` and `X-Forwarded-*` headers from untrusted
4141
clients.
42+
43+
## Cross-origin requests
44+
45+
By default, the REST API and the `/run_live` WebSocket accept only same-origin
46+
requests. The dev UI served by the server itself is same-origin and needs no
47+
configuration.
48+
49+
To allow other origins, for example a UI served by a separate development
50+
server, set `adk.web.cors.origins` to a comma-separated list of origins:
51+
52+
```properties
53+
adk.web.cors.origins=http://localhost:4200
54+
```
55+
56+
Behind a reverse proxy, a request also counts as same-origin when its origin
57+
matches the URL that the proxy reports in the `Forwarded` header, or in
58+
`X-Forwarded-*` headers when `Forwarded` is absent. The dev UI therefore keeps
59+
working behind a proxy on another machine that terminates TLS, such as Cloud
60+
Run's. List the external origin in `adk.web.cors.origins` if the proxy changes
61+
the scheme, host or port without reporting it; a reported scheme or host without
62+
a port implies the scheme's default port. Avoid `*`: it lets any website open in
63+
your browser call the server.
64+
65+
### DNS rebinding
66+
67+
To block pages that resolve their own domain to this machine, the server also
68+
rejects a non-loopback `Host` when `server.address` is a loopback address (the
69+
default for `mvn google-adk:web`), and a non-loopback `Origin` on a request that
70+
arrives over loopback. Origins in `adk.web.cors.origins`, and their hosts, are
71+
exempt, so a same-machine proxy that serves the UI under another name needs its
72+
origin listed. `*` turns both checks off.

‎dev/src/main/java/com/google/adk/web/config/AdkWebCorsConfig.java‎

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,17 @@
1616

1717
package com.google.adk.web.config;
1818

19+
import java.io.IOException;
1920
import org.slf4j.Logger;
2021
import org.slf4j.LoggerFactory;
22+
import org.springframework.beans.factory.annotation.Value;
2123
import org.springframework.context.annotation.Bean;
2224
import org.springframework.context.annotation.Configuration;
25+
import org.springframework.http.server.ServerHttpRequest;
26+
import org.springframework.http.server.ServerHttpResponse;
2327
import org.springframework.web.cors.CorsConfiguration;
2428
import org.springframework.web.cors.CorsConfigurationSource;
29+
import org.springframework.web.cors.DefaultCorsProcessor;
2530
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
2631
import org.springframework.web.filter.CorsFilter;
2732

@@ -39,6 +44,7 @@
3944
* <li>{@link CorsConfigurationSource}: Configures CORS settings such as allowed origins, methods,
4045
* headers, credentials, and max age.
4146
* <li>{@link CorsFilter}: Applies the CORS configuration to incoming requests.
47+
* <li>{@code OriginGuardFilter}: Rejects requests from pages rebound onto this machine by DNS.
4248
* </ul>
4349
*/
4450
@Configuration
@@ -50,10 +56,10 @@ public class AdkWebCorsConfig {
5056
public CorsConfigurationSource corsConfigurationSource(AdkWebCorsProperties corsProperties) {
5157
if (corsProperties.origins().contains("*")) {
5258
logger.warn(
53-
"CORS is configured to allow all origins (\"*\"), which is insecure and intended for"
54-
+ " local development only. This also applies to the /run_live WebSocket endpoint."
55-
+ " Set 'adk.web.cors.origins' to an explicit allowlist to restrict which origins"
56-
+ " may call the server.");
59+
"CORS is configured to allow all origins (\"*\"), so any website open in a browser can"
60+
+ " call this server, including the /run_live WebSocket endpoint. Set"
61+
+ " 'adk.web.cors.origins' to an explicit allowlist to restrict which origins may"
62+
+ " call the server.");
5763
}
5864

5965
CorsConfiguration configuration = new CorsConfiguration();
@@ -72,6 +78,29 @@ public CorsConfigurationSource corsConfigurationSource(AdkWebCorsProperties cors
7278

7379
@Bean
7480
public CorsFilter corsFilter(CorsConfigurationSource corsConfigurationSource) {
75-
return new CorsFilter(corsConfigurationSource);
81+
CorsFilter filter = new CorsFilter(corsConfigurationSource);
82+
filter.setCorsProcessor(new ForwardedSameOriginCorsProcessor());
83+
return filter;
84+
}
85+
86+
@Bean
87+
OriginGuardFilter originGuardFilter(
88+
AdkWebCorsProperties corsProperties, @Value("${server.address:}") String serverAddress) {
89+
return new OriginGuardFilter(corsProperties.origins(), serverAddress);
90+
}
91+
92+
/** Treats a request as same-origin when its origin matches the URL a reverse proxy reports. */
93+
private static final class ForwardedSameOriginCorsProcessor extends DefaultCorsProcessor {
94+
95+
@Override
96+
protected boolean handleInternal(
97+
ServerHttpRequest request,
98+
ServerHttpResponse response,
99+
CorsConfiguration config,
100+
boolean preFlightRequest)
101+
throws IOException {
102+
return ForwardedSameOrigin.matches(request)
103+
|| super.handleInternal(request, response, config, preFlightRequest);
104+
}
76105
}
77106
}

‎dev/src/main/java/com/google/adk/web/config/AdkWebCorsProperties.java‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121

2222
/**
2323
* Properties for configuring CORS in ADK Web. This class is used to load CORS settings from
24-
* application properties.
24+
* application properties. {@code origins} defaults to an empty list, which allows only same-origin
25+
* requests.
2526
*/
2627
@ConfigurationProperties(prefix = "adk.web.cors")
2728
public record AdkWebCorsProperties(
@@ -34,7 +35,7 @@ public record AdkWebCorsProperties(
3435

3536
public AdkWebCorsProperties {
3637
mapping = mapping != null ? mapping : "/**";
37-
origins = origins != null && !origins.isEmpty() ? origins : List.of("*");
38+
origins = origins != null ? origins : List.of();
3839
methods =
3940
methods != null && !methods.isEmpty()
4041
? methods
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.adk.web.config;
18+
19+
import java.net.URI;
20+
import org.springframework.http.HttpRequest;
21+
import org.springframework.http.client.support.HttpRequestWrapper;
22+
import org.springframework.web.util.ForwardedHeaderUtils;
23+
import org.springframework.web.util.UriComponentsBuilder;
24+
import org.springframework.web.util.WebUtils;
25+
26+
/**
27+
* Same-origin check against the URL that a reverse proxy reports in the {@code Forwarded} header,
28+
* or in {@code X-Forwarded-*} headers when {@code Forwarded} is absent. Browsers let a cross-origin
29+
* page send those headers only after a CORS preflight, which fails the origin check, so such a page
30+
* cannot fake a match. Other clients can send any value, so this is not an authorization check.
31+
*/
32+
public final class ForwardedSameOrigin {
33+
34+
/**
35+
* Returns whether the {@code Origin} header matches the request URL as the proxy reports it. A
36+
* request without an {@code Origin} header matches; one with a header Spring cannot parse does
37+
* not.
38+
*/
39+
public static boolean matches(HttpRequest request) {
40+
if (request.getHeaders().getOrigin() == null) {
41+
return true;
42+
}
43+
try {
44+
UriComponentsBuilder forwarded =
45+
ForwardedHeaderUtils.adaptFromForwardedHeaders(request.getURI(), request.getHeaders());
46+
// Some proxies report a WebSocket handshake as ws or wss; the browser's Origin uses http(s).
47+
String scheme = forwarded.build().getScheme();
48+
if ("ws".equalsIgnoreCase(scheme)) {
49+
forwarded.scheme("http");
50+
} else if ("wss".equalsIgnoreCase(scheme)) {
51+
forwarded.scheme("https");
52+
}
53+
URI forwardedUri = forwarded.build().toUri();
54+
return WebUtils.isSameOrigin(
55+
new HttpRequestWrapper(request) {
56+
@Override
57+
public URI getURI() {
58+
return forwardedUri;
59+
}
60+
});
61+
} catch (IllegalArgumentException | IllegalStateException | IndexOutOfBoundsException e) {
62+
// Spring's parsers throw these for a malformed Origin, forwarded header or request URL.
63+
return false;
64+
}
65+
}
66+
67+
private ForwardedSameOrigin() {}
68+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.adk.web.config;
18+
19+
import com.google.common.base.Ascii;
20+
import com.google.common.base.CharMatcher;
21+
import com.google.common.collect.ImmutableSet;
22+
import com.google.common.net.HostAndPort;
23+
import com.google.common.net.InetAddresses;
24+
import jakarta.servlet.FilterChain;
25+
import jakarta.servlet.ServletException;
26+
import jakarta.servlet.ServletRequest;
27+
import jakarta.servlet.ServletRequestWrapper;
28+
import jakarta.servlet.http.HttpServletRequest;
29+
import jakarta.servlet.http.HttpServletResponse;
30+
import java.io.IOException;
31+
import java.util.List;
32+
import org.jspecify.annotations.Nullable;
33+
import org.springframework.http.HttpHeaders;
34+
import org.springframework.web.filter.OncePerRequestFilter;
35+
import org.springframework.web.util.UriComponentsBuilder;
36+
37+
/**
38+
* Rejects requests from a page that resolved its own domain to this machine (DNS rebinding): a
39+
* foreign {@code Host} on a server bound to loopback, and a foreign {@code Origin} on a request
40+
* that arrived over loopback. Listed origins and their hosts pass, and {@code *} turns both checks
41+
* off.
42+
*/
43+
final class OriginGuardFilter extends OncePerRequestFilter {
44+
45+
private final boolean allowAll;
46+
private final ImmutableSet<String> origins;
47+
private final ImmutableSet<String> hosts;
48+
private final boolean boundToLoopback;
49+
50+
OriginGuardFilter(List<String> allowedOrigins, String bindAddress) {
51+
this.allowAll = allowedOrigins.contains("*");
52+
this.origins =
53+
allowedOrigins.stream()
54+
.map(OriginGuardFilter::normalizeOrigin)
55+
.collect(ImmutableSet.toImmutableSet());
56+
this.hosts =
57+
origins.stream()
58+
.map(OriginGuardFilter::originHost)
59+
.filter(host -> !host.isEmpty())
60+
.collect(ImmutableSet.toImmutableSet());
61+
this.boundToLoopback = isLoopback(bindAddress);
62+
}
63+
64+
@Override
65+
protected void doFilterInternal(
66+
HttpServletRequest request, HttpServletResponse response, FilterChain chain)
67+
throws ServletException, IOException {
68+
if (allowAll) {
69+
chain.doFilter(request, response);
70+
return;
71+
}
72+
// ForwardedHeaderFilter's wrapper takes the local address from headers a page can set.
73+
HttpServletRequest connection = unwrap(request);
74+
if (boundToLoopback && isForeignHost(connection)) {
75+
reject(response, "host not allowed");
76+
return;
77+
}
78+
String origin = request.getHeader(HttpHeaders.ORIGIN);
79+
if (origin != null
80+
&& !origins.contains(normalizeOrigin(origin))
81+
&& isLoopback(connection.getLocalAddr())
82+
&& !isLoopback(originHost(origin))) {
83+
// A rebound page's own domain fills Host and Origin alike, so same-origin checks pass it.
84+
reject(response, "origin not allowed");
85+
return;
86+
}
87+
chain.doFilter(request, response);
88+
}
89+
90+
/** Whether the real Host, which a page cannot set, names neither loopback nor a listed host. */
91+
private boolean isForeignHost(HttpServletRequest connection) {
92+
// HTTP/2 sends the authority as the server name rather than a Host header.
93+
String host = connection.getHeader(HttpHeaders.HOST);
94+
if (host == null) {
95+
host = connection.getServerName();
96+
}
97+
return !isLoopback(host) && !hosts.contains(bareHost(host));
98+
}
99+
100+
private static HttpServletRequest unwrap(HttpServletRequest request) {
101+
ServletRequest current = request;
102+
while (current instanceof ServletRequestWrapper wrapper) {
103+
current = wrapper.getRequest();
104+
}
105+
return current instanceof HttpServletRequest connection ? connection : request;
106+
}
107+
108+
private static void reject(HttpServletResponse response, String reason) throws IOException {
109+
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
110+
response.setContentType("text/plain");
111+
response.getWriter().write("Forbidden: " + reason);
112+
}
113+
114+
/** Whether host, with or without a port, is localhost or a loopback IP; never queries DNS. */
115+
private static boolean isLoopback(@Nullable String host) {
116+
String bare = bareHost(host);
117+
return bare.equals("localhost")
118+
|| (InetAddresses.isInetAddress(bare) && InetAddresses.forString(bare).isLoopbackAddress());
119+
}
120+
121+
/** Lowercases host and strips its port, brackets and trailing dot; empty if unparsable. */
122+
private static String bareHost(@Nullable String host) {
123+
if (host == null) {
124+
return "";
125+
}
126+
try {
127+
String bare = HostAndPort.fromString(host.trim()).getHost();
128+
return Ascii.toLowerCase(CharMatcher.is('.').trimTrailingFrom(bare));
129+
} catch (IllegalArgumentException e) {
130+
return "";
131+
}
132+
}
133+
134+
private static String originHost(String origin) {
135+
try {
136+
return bareHost(UriComponentsBuilder.fromUriString(origin.trim()).build().getHost());
137+
} catch (IllegalArgumentException e) {
138+
return "";
139+
}
140+
}
141+
142+
private static String normalizeOrigin(String origin) {
143+
return Ascii.toLowerCase(CharMatcher.is('/').trimTrailingFrom(origin.trim()));
144+
}
145+
}

‎dev/src/main/java/com/google/adk/web/websocket/WebSocketConfig.java‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,18 @@
1717
package com.google.adk.web.websocket;
1818

1919
import com.google.adk.web.config.AdkWebCorsProperties;
20+
import com.google.adk.web.config.ForwardedSameOrigin;
21+
import java.util.List;
22+
import java.util.Map;
2023
import org.springframework.beans.factory.annotation.Autowired;
2124
import org.springframework.context.annotation.Configuration;
25+
import org.springframework.http.server.ServerHttpRequest;
26+
import org.springframework.http.server.ServerHttpResponse;
27+
import org.springframework.web.socket.WebSocketHandler;
2228
import org.springframework.web.socket.config.annotation.EnableWebSocket;
2329
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
2430
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
31+
import org.springframework.web.socket.server.support.OriginHandshakeInterceptor;
2532

2633
/** Configuration class for WebSocket handling. */
2734
@Configuration
@@ -42,6 +49,27 @@ public WebSocketConfig(
4249
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
4350
registry
4451
.addHandler(liveWebSocketHandler, "/run_live")
45-
.setAllowedOrigins(corsProperties.origins().toArray(new String[0]));
52+
.addInterceptors(new ForwardedAwareOriginInterceptor(corsProperties.origins()))
53+
// The interceptor checks origins; the registry's own check ignores forwarded headers.
54+
.setAllowedOrigins("*");
55+
}
56+
57+
/** Origin check for the handshake that also accepts the URL a reverse proxy reports. */
58+
private static final class ForwardedAwareOriginInterceptor extends OriginHandshakeInterceptor {
59+
60+
ForwardedAwareOriginInterceptor(List<String> allowedOrigins) {
61+
super(allowedOrigins);
62+
}
63+
64+
@Override
65+
public boolean beforeHandshake(
66+
ServerHttpRequest request,
67+
ServerHttpResponse response,
68+
WebSocketHandler wsHandler,
69+
Map<String, Object> attributes)
70+
throws Exception {
71+
return ForwardedSameOrigin.matches(request)
72+
|| super.beforeHandshake(request, response, wsHandler, attributes);
73+
}
4674
}
4775
}

0 commit comments

Comments
 (0)