Skip to content

Commit fa027f3

Browse files
Mateusz Krawieccopybara-github
authored andcommitted
fix(dev): reject DNS-rebinding requests that reach the server over loopback
- A UI reached over loopback under another name, such as through a same-machine proxy or a hosts-file alias, needs its origin in `adk.web.cors.origins`. - With `-Dhost=0.0.0.0`, open the UI at `http://localhost:{port}`: the server now rejects the `http://0.0.0.0` origin. PiperOrigin-RevId: 1000840345
1 parent 5667d38 commit fa027f3

7 files changed

Lines changed: 422 additions & 25 deletions

File tree

‎dev/README.md‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,21 @@ adk.web.cors.origins=http://localhost:4200
5959
```
6060

6161
Behind a reverse proxy, the same-origin check uses the URL that the proxy
62-
reports in forwarded headers, so the dev UI keeps working behind a proxy that
63-
terminates TLS. A proxy on a non-default port must include the port in its
64-
forwarded headers. List the external origin in `adk.web.cors.origins` if the
65-
proxy does not report the external URL, or if it reports `ws` or `wss` as the
66-
scheme of WebSocket requests (as Traefik does). Avoid `*`: it lets any website
67-
open in your browser call the server.
62+
reports in forwarded headers, so the dev UI keeps working behind a proxy on
63+
another machine that terminates TLS. A proxy on a non-default port must include
64+
the port in its forwarded headers. List the external origin in
65+
`adk.web.cors.origins` if the proxy does not report the external URL, or if it
66+
reports `ws` or `wss` as the scheme of WebSocket requests (as Traefik does).
67+
Avoid `*`: it lets any website open in your browser call the server.
68+
69+
### DNS rebinding
70+
71+
The server also blocks pages that resolve their own domain to this machine. When
72+
`server.address` is a loopback address (the default for `mvn google-adk:web`),
73+
it rejects a non-loopback `Host`. On a request that arrives over loopback, it
74+
rejects a non-loopback `Origin`. Origins in `adk.web.cors.origins`, and their
75+
hosts, are exempt. A UI reached over loopback under another name, through a
76+
same-machine proxy or a hosts-file alias, therefore needs its origin listed. `*`
77+
turns both checks off. `AdkWebServer.start()` and `main()` do not set
78+
`server.address`, so set `server.address=127.0.0.1` there to turn on the `Host`
79+
check.

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import org.slf4j.Logger;
2121
import org.slf4j.LoggerFactory;
2222
import org.springframework.beans.factory.ObjectProvider;
23+
import org.springframework.beans.factory.annotation.Value;
2324
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
2425
import org.springframework.boot.web.server.autoconfigure.servlet.ForwardedHeaderFilterCustomizer;
2526
import org.springframework.boot.web.servlet.FilterRegistrationBean;
@@ -48,6 +49,7 @@
4849
* <li>{@link CorsFilter}: Applies the CORS configuration to incoming requests.
4950
* <li>{@link ForwardedHeaderFilter}: Applies reverse proxy headers to the URL of every request,
5051
* so the same-origin checks see the URL the browser used.
52+
* <li>{@code OriginGuardFilter}: Rejects requests from pages rebound onto this machine by DNS.
5153
* </ul>
5254
*/
5355
@Configuration
@@ -84,6 +86,12 @@ public CorsFilter corsFilter(CorsConfigurationSource corsConfigurationSource) {
8486
return new CorsFilter(corsConfigurationSource);
8587
}
8688

89+
@Bean
90+
OriginGuardFilter originGuardFilter(
91+
AdkWebCorsProperties corsProperties, @Value("${server.address:}") String serverAddress) {
92+
return new OriginGuardFilter(corsProperties.origins(), serverAddress);
93+
}
94+
8795
/**
8896
* Makes {@code server.forward-headers-strategy=framework} the default, so same-origin checks see
8997
* the URL the browser used behind a TLS-terminating proxy. Registered like Spring Boot's own
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> allowedOrigins;
47+
private final ImmutableSet<String> allowedHosts;
48+
private final boolean boundToLoopback;
49+
50+
OriginGuardFilter(List<String> origins, String bindAddress) {
51+
this.allowAll = origins.contains("*");
52+
this.allowedOrigins =
53+
origins.stream()
54+
.map(OriginGuardFilter::normalizeOrigin)
55+
.collect(ImmutableSet.toImmutableSet());
56+
this.allowedHosts =
57+
allowedOrigins.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 server name and local address from page headers.
73+
HttpServletRequest containerRequest = unwrap(request);
74+
if (boundToLoopback && isForeignHost(containerRequest)) {
75+
reject(response, "host not allowed");
76+
return;
77+
}
78+
String origin = request.getHeader(HttpHeaders.ORIGIN);
79+
if (origin != null
80+
&& !allowedOrigins.contains(normalizeOrigin(origin))
81+
&& isLoopback(containerRequest.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 containerRequest) {
92+
// HTTP/2 sends the authority as the server name rather than a Host header.
93+
String host = containerRequest.getHeader(HttpHeaders.HOST);
94+
if (host == null) {
95+
host = containerRequest.getServerName();
96+
}
97+
return !isLoopback(host) && !allowedHosts.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 (HttpServletRequest) current;
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/test/java/com/google/adk/web/AdkWebServerCorsTest.java‎

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
import org.springframework.boot.test.web.server.LocalServerPort;
4343
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
4444
import org.springframework.test.web.servlet.MockMvc;
45+
import org.springframework.test.web.servlet.request.RequestPostProcessor;
4546

4647
/**
4748
* Tests for the browser origins allowed to call the REST API and open the {@code /run_live}
@@ -61,17 +62,21 @@ class DefaultOrigins {
6162
@ValueSource(strings = {REMOTE_ORIGIN, "http://localhost:4200"})
6263
public void restApi_fromOtherOrigin_isRejected(String origin, @Autowired MockMvc mockMvc)
6364
throws Exception {
65+
// The DNS-rebinding guard does not apply to requests over the network, so CORS decides.
6466
mockMvc
65-
.perform(get("/list-apps").header(ORIGIN, origin))
67+
.perform(get("/list-apps").header(ORIGIN, origin).with(overNetwork()))
6668
.andExpect(status().isForbidden())
6769
.andExpect(header().doesNotExist(ACCESS_CONTROL_ALLOW_ORIGIN));
6870
}
6971

7072
@Test
7173
public void restApi_fromSameOrigin_isAllowed(@Autowired MockMvc mockMvc) throws Exception {
72-
// Same-origin requests skip the origin list, so the bundled UI works from any host name.
74+
// Same-origin requests skip the origin list, so any host name on the network works.
7375
mockMvc
74-
.perform(get("http://devbox:8000/list-apps").header(ORIGIN, "http://devbox:8000"))
76+
.perform(
77+
get("http://devbox:8000/list-apps")
78+
.header(ORIGIN, "http://devbox:8000")
79+
.with(overNetwork()))
7580
.andExpect(status().isOk());
7681
}
7782

@@ -82,7 +87,8 @@ public void restApi_behindTlsProxy_isAllowed(@Autowired MockMvc mockMvc) throws
8287
.perform(
8388
get("http://app.example.com/list-apps")
8489
.header("X-Forwarded-Proto", "https")
85-
.header(ORIGIN, "https://app.example.com"))
90+
.header(ORIGIN, "https://app.example.com")
91+
.with(overNetwork()))
8692
.andExpect(status().isOk());
8793
}
8894

@@ -93,7 +99,8 @@ public void restApi_behindTlsProxy_fromRemoteOrigin_isRejected(@Autowired MockMv
9399
.perform(
94100
get("http://app.example.com/list-apps")
95101
.header("X-Forwarded-Proto", "https")
96-
.header(ORIGIN, REMOTE_ORIGIN))
102+
.header(ORIGIN, REMOTE_ORIGIN)
103+
.with(overNetwork()))
97104
.andExpect(status().isForbidden());
98105
}
99106

@@ -105,7 +112,8 @@ public void restApi_behindTlsProxy_fromPlainHttpOrigin_isRejected(@Autowired Moc
105112
.perform(
106113
get("http://app.example.com/list-apps")
107114
.header("X-Forwarded-Proto", "https")
108-
.header(ORIGIN, "http://app.example.com"))
115+
.header(ORIGIN, "http://app.example.com")
116+
.with(overNetwork()))
109117
.andExpect(status().isForbidden());
110118
}
111119

@@ -115,7 +123,8 @@ public void preflight_fromRemoteOrigin_isRejected(@Autowired MockMvc mockMvc) th
115123
.perform(
116124
options("/apps/test-app/users/test-user/sessions/test-session")
117125
.header(ORIGIN, REMOTE_ORIGIN)
118-
.header(ACCESS_CONTROL_REQUEST_METHOD, "DELETE"))
126+
.header(ACCESS_CONTROL_REQUEST_METHOD, "DELETE")
127+
.with(overNetwork()))
119128
.andExpect(status().isForbidden())
120129
.andExpect(header().doesNotExist(ACCESS_CONTROL_ALLOW_ORIGIN));
121130
}
@@ -133,12 +142,13 @@ public void runLive_fromOtherOrigin_isRejected(String origin, @LocalServerPort i
133142

134143
@Test
135144
public void runLive_behindTlsProxy_completesHandshake(@LocalServerPort int port) {
145+
// A same-machine TLS proxy at https://127.0.0.1; only forwarded headers make this match.
136146
WebSocket.Builder handshake =
137147
httpClient
138148
.newWebSocketBuilder()
139-
.header(ORIGIN, "https://app.example.com")
149+
.header(ORIGIN, "https://127.0.0.1")
140150
.header("X-Forwarded-Proto", "https")
141-
.header("X-Forwarded-Host", "app.example.com");
151+
.header("X-Forwarded-Host", "127.0.0.1");
142152

143153
connect(port, handshake).abort();
144154
}
@@ -153,6 +163,20 @@ public void runLive_behindTlsProxy_fromPlainHttpOrigin_isRejected(@LocalServerPo
153163

154164
assertHandshakeForbidden(port, handshake);
155165
}
166+
167+
@Test
168+
public void runLive_reboundOriginBehindHostRewritingProxy_isRejected(
169+
@LocalServerPort int port) {
170+
// A rebound page reaching a same-machine proxy that forwards the page's own host.
171+
WebSocket.Builder handshake =
172+
httpClient
173+
.newWebSocketBuilder()
174+
.header(ORIGIN, "https://evil.example")
175+
.header("X-Forwarded-Proto", "https")
176+
.header("X-Forwarded-Host", "evil.example");
177+
178+
assertHandshakeForbidden(port, handshake);
179+
}
156180
}
157181

158182
@Nested
@@ -166,12 +190,13 @@ public void restApi_behindTlsProxy_isRejected(@Autowired MockMvc mockMvc) throws
166190
.perform(
167191
get("http://app.example.com/list-apps")
168192
.header("X-Forwarded-Proto", "https")
169-
.header(ORIGIN, "https://app.example.com"))
193+
.header(ORIGIN, "https://app.example.com")
194+
.with(overNetwork()))
170195
.andExpect(status().isForbidden());
171196
}
172197
}
173198

174-
/** With the CORS filter mapped away from /run_live, only the handshake's origin check runs. */
199+
/** With the CORS filter mapped away from /run_live, the handshake's own origin check decides. */
175200
@Nested
176201
@SpringBootTest(
177202
webEnvironment = WebEnvironment.RANDOM_PORT,
@@ -184,8 +209,9 @@ public void runLive_fromSameOrigin_completesHandshake(@LocalServerPort int port)
184209
}
185210

186211
@Test
187-
public void runLive_fromRemoteOrigin_isRejected(@LocalServerPort int port) {
188-
assertHandshakeForbidden(port, REMOTE_ORIGIN);
212+
public void runLive_fromOtherLocalPort_isRejected(@LocalServerPort int port) {
213+
// A loopback origin passes the DNS-rebinding guard, so only the handshake check refuses it.
214+
assertHandshakeForbidden(port, "http://localhost:4200");
189215
}
190216
}
191217

@@ -211,7 +237,8 @@ public void restApi_behindTlsProxy_isAllowed(@Autowired MockMvc mockMvc) throws
211237
.perform(
212238
get("http://app.example.com/list-apps")
213239
.header("X-Forwarded-Proto", "https")
214-
.header(ORIGIN, "https://app.example.com"))
240+
.header(ORIGIN, "https://app.example.com")
241+
.with(overNetwork()))
215242
.andExpect(status().isOk());
216243
}
217244

@@ -222,7 +249,8 @@ public void runLive_fromConfiguredOrigin_completesHandshake(@LocalServerPort int
222249

223250
@Test
224251
public void runLive_fromUnlistedOrigin_isRejected(@LocalServerPort int port) {
225-
assertHandshakeForbidden(port, REMOTE_ORIGIN);
252+
// A loopback origin passes the DNS-rebinding guard, so the configured list must refuse it.
253+
assertHandshakeForbidden(port, "http://localhost:4200");
226254
}
227255
}
228256

@@ -247,6 +275,14 @@ public void runLive_fromRemoteOrigin_completesHandshake(@LocalServerPort int por
247275
}
248276
}
249277

278+
/** Delivers a MockMvc request on a routable address, as a LAN host or remote proxy does. */
279+
private static RequestPostProcessor overNetwork() {
280+
return request -> {
281+
request.setLocalAddr("10.0.0.5");
282+
return request;
283+
};
284+
}
285+
250286
private static WebSocket openRunLive(int port, String origin) {
251287
return connect(port, httpClient.newWebSocketBuilder().header(ORIGIN, origin));
252288
}

0 commit comments

Comments
 (0)