Skip to content

Commit 0ef1b39

Browse files
fix(xds): Allow and normalize trailing dot (FQDN) in matchHostName (#12644)
## Summary `matchHostName` in `RoutingUtils` and `XdsNameResolver` currently rejects hostnames and patterns with a trailing dot (`.`) via `checkArgument`. A trailing dot denotes a **Fully Qualified Domain Name (FQDN)** as defined in [RFC 1034 Section 3.1](https://www.rfc-editor.org/rfc/rfc1034#section-3.1), and is a valid, well-defined representation of an absolute domain name. Rejecting it is inconsistent with the RFC. This change removes the trailing-dot rejection and adds normalization to strip the trailing dot before matching, making `example.com.` and `example.com` match equivalently. ## Background Per [RFC 1034 Section 3.1](https://www.rfc-editor.org/rfc/rfc1034#section-3.1): > "If the name ends with a dot, it is an absolute name ... For example, `poneria.ISI.EDU.`" A trailing dot simply indicates that the name is rooted at the DNS root and is semantically equivalent to the same name without the trailing dot. Treating it as invalid prevents legitimate FQDNs from being used as hostnames or virtual host domain patterns in xDS routing configuration. ## Motivation This was discovered when using gRPC Proxyless Service Mesh on a Kubernetes cluster with Istio. The issue surfaced after upgrading Istio from 1.26.8 to 1.28.3. The Istio change [istio/istio#56008](istio/istio#56008) began sending FQDN-style domain names (with trailing dots) in xDS route configuration, which caused grpc-java to throw an `IllegalArgumentException` in `matchHostName`: ```text java.lang.IllegalArgumentException: Invalid pattern/domain name at com.google.common.base.Preconditions.checkArgument(Preconditions.java:143) ``` The root cause is that grpc-java's `matchHostName` was not RFC-compliant in rejecting trailing dots — the Istio upgrade merely made it visible. The fix here is to bring grpc-java into compliance with RFC 1034, independent of any specific Istio version. ## Changes - `xds/src/main/java/io/grpc/xds/RoutingUtils.java`: Removed trailing-dot rejection and added FQDN normalization in `matchHostName`. - `xds/src/main/java/io/grpc/xds/XdsNameResolver.java`: Same as above. - `xds/src/test/java/io/grpc/xds/XdsNameResolverTest.java`: Added `matchHostName_trailingDot` test covering exact match, prefix wildcard, and suffix wildcard with trailing dot combinations. ## References - [RFC 1034 – Domain Names: Concepts and Facilities](https://www.rfc-editor.org/rfc/rfc1034) - [RFC 1035 – Domain Names: Implementation and Specification](https://www.rfc-editor.org/rfc/rfc1035) - [istio/istio#56008](istio/istio#56008) – Istio change that began sending FQDN domain names in xDS configuration
1 parent ac44e96 commit 0ef1b39

File tree

4 files changed

+176
-105
lines changed

4 files changed

+176
-105
lines changed

xds/src/main/java/io/grpc/xds/RoutingUtils.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,15 +92,24 @@ static VirtualHost findVirtualHostForHostName(List<VirtualHost> virtualHosts, St
9292
* </ol>
9393
*/
9494
private static boolean matchHostName(String hostName, String pattern) {
95-
checkArgument(hostName.length() != 0 && !hostName.startsWith(".") && !hostName.endsWith("."),
95+
checkArgument(hostName.length() != 0 && !hostName.startsWith("."),
9696
"Invalid host name");
97-
checkArgument(pattern.length() != 0 && !pattern.startsWith(".") && !pattern.endsWith("."),
97+
checkArgument(pattern.length() != 0 && !pattern.startsWith("."),
9898
"Invalid pattern/domain name");
9999

100100
hostName = hostName.toLowerCase(Locale.US);
101101
pattern = pattern.toLowerCase(Locale.US);
102102
// hostName and pattern are now in lower case -- domain names are case-insensitive.
103103

104+
// Strip trailing dot to normalize FQDN (e.g. "example.com.") to a relative form,
105+
// as per RFC 1034 Section 3.1 the two are semantically equivalent.
106+
if (hostName.endsWith(".")) {
107+
hostName = hostName.substring(0, hostName.length() - 1);
108+
}
109+
if (pattern.endsWith(".")) {
110+
pattern = pattern.substring(0, pattern.length() - 1);
111+
}
112+
104113
if (!pattern.contains("*")) {
105114
// Not a wildcard pattern -- hostName and pattern must match exactly.
106115
return hostName.equals(pattern);

xds/src/main/java/io/grpc/xds/XdsNameResolver.java

Lines changed: 0 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,6 @@
7373
import java.util.HashMap;
7474
import java.util.HashSet;
7575
import java.util.List;
76-
import java.util.Locale;
7776
import java.util.Map;
7877
import java.util.Objects;
7978
import java.util.Set;
@@ -341,66 +340,6 @@ private void updateResolutionResult(XdsConfig xdsConfig) {
341340
}
342341
}
343342

344-
/**
345-
* Returns {@code true} iff {@code hostName} matches the domain name {@code pattern} with
346-
* case-insensitive.
347-
*
348-
* <p>Wildcard pattern rules:
349-
* <ol>
350-
* <li>A single asterisk (*) matches any domain.</li>
351-
* <li>Asterisk (*) is only permitted in the left-most or the right-most part of the pattern,
352-
* but not both.</li>
353-
* </ol>
354-
*/
355-
@VisibleForTesting
356-
static boolean matchHostName(String hostName, String pattern) {
357-
checkArgument(hostName.length() != 0 && !hostName.startsWith(".") && !hostName.endsWith("."),
358-
"Invalid host name");
359-
checkArgument(pattern.length() != 0 && !pattern.startsWith(".") && !pattern.endsWith("."),
360-
"Invalid pattern/domain name");
361-
362-
hostName = hostName.toLowerCase(Locale.US);
363-
pattern = pattern.toLowerCase(Locale.US);
364-
// hostName and pattern are now in lower case -- domain names are case-insensitive.
365-
366-
if (!pattern.contains("*")) {
367-
// Not a wildcard pattern -- hostName and pattern must match exactly.
368-
return hostName.equals(pattern);
369-
}
370-
// Wildcard pattern
371-
372-
if (pattern.length() == 1) {
373-
return true;
374-
}
375-
376-
int index = pattern.indexOf('*');
377-
378-
// At most one asterisk (*) is allowed.
379-
if (pattern.indexOf('*', index + 1) != -1) {
380-
return false;
381-
}
382-
383-
// Asterisk can only match prefix or suffix.
384-
if (index != 0 && index != pattern.length() - 1) {
385-
return false;
386-
}
387-
388-
// HostName must be at least as long as the pattern because asterisk has to
389-
// match one or more characters.
390-
if (hostName.length() < pattern.length()) {
391-
return false;
392-
}
393-
394-
if (index == 0 && hostName.endsWith(pattern.substring(1))) {
395-
// Prefix matching fails.
396-
return true;
397-
}
398-
399-
// Pattern matches hostname if suffix matching succeeds.
400-
return index == pattern.length() - 1
401-
&& hostName.startsWith(pattern.substring(0, pattern.length() - 1));
402-
}
403-
404343
private final class ConfigSelector extends InternalConfigSelector {
405344
@Override
406345
public Result selectConfig(PickSubchannelArgs args) {

xds/src/test/java/io/grpc/xds/RoutingUtilsTest.java

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package io.grpc.xds;
1818

1919
import static com.google.common.truth.Truth.assertThat;
20+
import static org.junit.Assert.assertThrows;
2021
import static org.mockito.Mockito.mock;
2122

2223
import com.google.common.collect.ImmutableMap;
@@ -88,6 +89,170 @@ public void findVirtualHostForHostName_asteriskMatchAnyDomain() {
8889
.isEqualTo(vHost1);
8990
}
9091

92+
@Test
93+
public void findVirtualHostForHostName_trailingDot() {
94+
// FQDN (trailing dot) is semantically equivalent to the relative form
95+
// per RFC 1034 Section 3.1.
96+
List<Route> routes = Collections.emptyList();
97+
VirtualHost vHost1 = VirtualHost.create("virtualhost01.googleapis.com",
98+
Collections.singletonList("a.googleapis.com"), routes,
99+
ImmutableMap.of());
100+
VirtualHost vHost2 = VirtualHost.create("virtualhost02.googleapis.com",
101+
Collections.singletonList("*.googleapis.com"), routes,
102+
ImmutableMap.of());
103+
VirtualHost vHost3 = VirtualHost.create("virtualhost03.googleapis.com",
104+
Collections.singletonList("*"), routes,
105+
ImmutableMap.of());
106+
List<VirtualHost> virtualHosts = Arrays.asList(vHost1, vHost2, vHost3);
107+
108+
// Trailing dot in hostName, exact match.
109+
assertThat(RoutingUtils.findVirtualHostForHostName(
110+
virtualHosts, "a.googleapis.com.")).isEqualTo(vHost1);
111+
// Trailing dot in hostName, wildcard match.
112+
assertThat(RoutingUtils.findVirtualHostForHostName(
113+
virtualHosts, "b.googleapis.com.")).isEqualTo(vHost2);
114+
115+
// Trailing dot in domain pattern, exact match.
116+
VirtualHost vHost4 = VirtualHost.create("virtualhost04.googleapis.com",
117+
Collections.singletonList("a.googleapis.com."), routes,
118+
ImmutableMap.of());
119+
List<VirtualHost> virtualHosts2 =
120+
Arrays.asList(vHost4, vHost2, vHost3);
121+
assertThat(RoutingUtils.findVirtualHostForHostName(
122+
virtualHosts2, "a.googleapis.com")).isEqualTo(vHost4);
123+
124+
// Trailing dot in both hostName and domain pattern.
125+
assertThat(RoutingUtils.findVirtualHostForHostName(
126+
virtualHosts2, "a.googleapis.com.")).isEqualTo(vHost4);
127+
128+
// Trailing dot in domain pattern, wildcard match.
129+
VirtualHost vHost5 = VirtualHost.create("virtualhost05.googleapis.com",
130+
Collections.singletonList("*.googleapis.com."), routes,
131+
ImmutableMap.of());
132+
List<VirtualHost> virtualHosts3 =
133+
Arrays.asList(vHost5, vHost3);
134+
assertThat(RoutingUtils.findVirtualHostForHostName(
135+
virtualHosts3, "b.googleapis.com")).isEqualTo(vHost5);
136+
assertThat(RoutingUtils.findVirtualHostForHostName(
137+
virtualHosts3, "b.googleapis.com.")).isEqualTo(vHost5);
138+
}
139+
140+
@Test
141+
public void findVirtualHostForHostName_exactMatch() {
142+
List<Route> routes = Collections.emptyList();
143+
VirtualHost vHostFoo = VirtualHost.create("vhost-foo",
144+
Collections.singletonList("foo.googleapis.com"), routes,
145+
ImmutableMap.of());
146+
VirtualHost vHostBar = VirtualHost.create("vhost-bar",
147+
Collections.singletonList("bar.googleapis.com"), routes,
148+
ImmutableMap.of());
149+
List<VirtualHost> virtualHosts =
150+
Arrays.asList(vHostFoo, vHostBar);
151+
152+
assertThat(RoutingUtils.findVirtualHostForHostName(
153+
virtualHosts, "foo.googleapis.com")).isEqualTo(vHostFoo);
154+
assertThat(RoutingUtils.findVirtualHostForHostName(
155+
virtualHosts, "bar.googleapis.com")).isEqualTo(vHostBar);
156+
// No match returns null.
157+
assertThat(RoutingUtils.findVirtualHostForHostName(
158+
virtualHosts, "baz.googleapis.com")).isNull();
159+
assertThat(RoutingUtils.findVirtualHostForHostName(
160+
virtualHosts, "foo.googleapis")).isNull();
161+
}
162+
163+
@Test
164+
public void findVirtualHostForHostName_invalidHostName() {
165+
List<Route> routes = Collections.emptyList();
166+
VirtualHost vHost = VirtualHost.create("vhost",
167+
Collections.singletonList("a.googleapis.com"), routes,
168+
ImmutableMap.of());
169+
List<VirtualHost> virtualHosts = Collections.singletonList(vHost);
170+
171+
// Empty hostName.
172+
assertThrows(IllegalArgumentException.class,
173+
() -> RoutingUtils.findVirtualHostForHostName(
174+
virtualHosts, ""));
175+
// HostName starting with dot.
176+
assertThrows(IllegalArgumentException.class,
177+
() -> RoutingUtils.findVirtualHostForHostName(
178+
virtualHosts, ".a.googleapis.com"));
179+
}
180+
181+
@Test
182+
public void findVirtualHostForHostName_invalidPattern() {
183+
List<Route> routes = Collections.emptyList();
184+
// Empty domain pattern.
185+
VirtualHost vHostEmpty = VirtualHost.create("vhost-empty",
186+
Collections.singletonList(""), routes,
187+
ImmutableMap.of());
188+
assertThrows(IllegalArgumentException.class,
189+
() -> RoutingUtils.findVirtualHostForHostName(
190+
Collections.singletonList(vHostEmpty),
191+
"a.googleapis.com"));
192+
// Domain pattern starting with dot.
193+
VirtualHost vHostDot = VirtualHost.create("vhost-dot",
194+
Collections.singletonList(".a.googleapis.com"), routes,
195+
ImmutableMap.of());
196+
assertThrows(IllegalArgumentException.class,
197+
() -> RoutingUtils.findVirtualHostForHostName(
198+
Collections.singletonList(vHostDot),
199+
"a.googleapis.com"));
200+
}
201+
202+
@Test
203+
public void findVirtualHostForHostName_prefixWildcard() {
204+
List<Route> routes = Collections.emptyList();
205+
VirtualHost vHostWild = VirtualHost.create("vhost-wild",
206+
Collections.singletonList("*.foo.googleapis.com"),
207+
routes, ImmutableMap.of());
208+
VirtualHost vHostOther = VirtualHost.create("vhost-other",
209+
Collections.singletonList("other.googleapis.com"),
210+
routes, ImmutableMap.of());
211+
List<VirtualHost> virtualHosts =
212+
Arrays.asList(vHostWild, vHostOther);
213+
214+
// Prefix wildcard matches.
215+
assertThat(RoutingUtils.findVirtualHostForHostName(
216+
virtualHosts, "bar.foo.googleapis.com"))
217+
.isEqualTo(vHostWild);
218+
// Base domain without subdomain does not match *.foo.googleapis.com.
219+
assertThat(RoutingUtils.findVirtualHostForHostName(
220+
virtualHosts, "foo.googleapis.com")).isNull();
221+
222+
// Longer prefix wildcard is preferred over shorter one.
223+
VirtualHost vHostLong = VirtualHost.create("vhost-long",
224+
Collections.singletonList("*.bar.foo.googleapis.com"),
225+
routes, ImmutableMap.of());
226+
List<VirtualHost> virtualHosts2 =
227+
Arrays.asList(vHostLong, vHostWild);
228+
assertThat(RoutingUtils.findVirtualHostForHostName(
229+
virtualHosts2, "baz.bar.foo.googleapis.com"))
230+
.isEqualTo(vHostLong);
231+
}
232+
233+
@Test
234+
public void findVirtualHostForHostName_postfixWildcard() {
235+
List<Route> routes = Collections.emptyList();
236+
VirtualHost vHostWild = VirtualHost.create("vhost-wild",
237+
Collections.singletonList("foo.*"), routes,
238+
ImmutableMap.of());
239+
VirtualHost vHostOther = VirtualHost.create("vhost-other",
240+
Collections.singletonList("bar.googleapis.com"),
241+
routes, ImmutableMap.of());
242+
List<VirtualHost> virtualHosts =
243+
Arrays.asList(vHostWild, vHostOther);
244+
245+
// Postfix wildcard matches.
246+
assertThat(RoutingUtils.findVirtualHostForHostName(
247+
virtualHosts, "foo.googleapis.com"))
248+
.isEqualTo(vHostWild);
249+
assertThat(RoutingUtils.findVirtualHostForHostName(
250+
virtualHosts, "foo.com")).isEqualTo(vHostWild);
251+
// Different prefix does not match foo.*.
252+
assertThat(RoutingUtils.findVirtualHostForHostName(
253+
virtualHosts, "bar.foo.googleapis.com")).isNull();
254+
}
255+
91256
@Test
92257
public void routeMatching_pathOnly() {
93258
Metadata headers = new Metadata();

xds/src/test/java/io/grpc/xds/XdsNameResolverTest.java

Lines changed: 0 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2020,48 +2020,6 @@ public void generateServiceConfig_forPerMethodConfig() throws IOException {
20202020
.isEqualTo(expectedServiceConfig);
20212021
}
20222022

2023-
@Test
2024-
public void matchHostName_exactlyMatch() {
2025-
String pattern = "foo.googleapis.com";
2026-
assertThat(XdsNameResolver.matchHostName("bar.googleapis.com", pattern)).isFalse();
2027-
assertThat(XdsNameResolver.matchHostName("fo.googleapis.com", pattern)).isFalse();
2028-
assertThat(XdsNameResolver.matchHostName("oo.googleapis.com", pattern)).isFalse();
2029-
assertThat(XdsNameResolver.matchHostName("googleapis.com", pattern)).isFalse();
2030-
assertThat(XdsNameResolver.matchHostName("foo.googleapis", pattern)).isFalse();
2031-
assertThat(XdsNameResolver.matchHostName("foo.googleapis.com", pattern)).isTrue();
2032-
}
2033-
2034-
@Test
2035-
public void matchHostName_prefixWildcard() {
2036-
String pattern = "*.foo.googleapis.com";
2037-
assertThat(XdsNameResolver.matchHostName("foo.googleapis.com", pattern)).isFalse();
2038-
assertThat(XdsNameResolver.matchHostName("bar-baz.foo.googleapis", pattern)).isFalse();
2039-
assertThat(XdsNameResolver.matchHostName("bar.foo.googleapis.com", pattern)).isTrue();
2040-
pattern = "*-bar.foo.googleapis.com";
2041-
assertThat(XdsNameResolver.matchHostName("bar.foo.googleapis.com", pattern)).isFalse();
2042-
assertThat(XdsNameResolver.matchHostName("baz-bar.foo.googleapis", pattern)).isFalse();
2043-
assertThat(XdsNameResolver.matchHostName("-bar.foo.googleapis.com", pattern)).isFalse();
2044-
assertThat(XdsNameResolver.matchHostName("baz-bar.foo.googleapis.com", pattern))
2045-
.isTrue();
2046-
}
2047-
2048-
@Test
2049-
public void matchHostName_postfixWildCard() {
2050-
String pattern = "foo.*";
2051-
assertThat(XdsNameResolver.matchHostName("bar.googleapis.com", pattern)).isFalse();
2052-
assertThat(XdsNameResolver.matchHostName("bar.foo.googleapis.com", pattern)).isFalse();
2053-
assertThat(XdsNameResolver.matchHostName("foo.googleapis.com", pattern)).isTrue();
2054-
assertThat(XdsNameResolver.matchHostName("foo.com", pattern)).isTrue();
2055-
pattern = "foo-*";
2056-
assertThat(XdsNameResolver.matchHostName("bar-.googleapis.com", pattern)).isFalse();
2057-
assertThat(XdsNameResolver.matchHostName("foo.googleapis.com", pattern)).isFalse();
2058-
assertThat(XdsNameResolver.matchHostName("foo.googleapis.com", pattern)).isFalse();
2059-
assertThat(XdsNameResolver.matchHostName("foo-", pattern)).isFalse();
2060-
assertThat(XdsNameResolver.matchHostName("foo-bar.com", pattern)).isTrue();
2061-
assertThat(XdsNameResolver.matchHostName("foo-.com", pattern)).isTrue();
2062-
assertThat(XdsNameResolver.matchHostName("foo-bar", pattern)).isTrue();
2063-
}
2064-
20652023
@Test
20662024
public void resolved_faultAbortInLdsUpdate() {
20672025
resolver.start(mockListener);

0 commit comments

Comments
 (0)