Skip to content

Commit 7f39805

Browse files
authored
api: Fix encoding of IPv6 scopes. (#12564)
Both `java.net.Uri` and Guava's `InetAddresses` predate standardization of IPv6 scopes in URIs. They both emit/accept a naked % between the [square bracketed] address. This causes the current implementation to crash in io.grpc.Uri#getHost while percent decoding. RFC 6874 says that % in an IP-literal must be percent-encoded just as is done everywhere else. RFC 3986 & 9844 say not to support scopes at all but I think we should, for feature parity with `java.net.Uri`. (Other contemporary libraries take the same approach, e.g. https://pkg.go.dev/net/url). A future PR will provide a first-class method to convert from `java.net.Uri` handling all the edge cases.
1 parent ed6d175 commit 7f39805

File tree

2 files changed

+92
-1
lines changed

2 files changed

+92
-1
lines changed

api/src/main/java/io/grpc/Uri.java

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,12 @@
142142
*
143143
* <p>{@link java.net.URI} and {@link Uri} both support IPv6 literals in square brackets as defined
144144
* by RFC 2732.
145+
*
146+
* <p>{@link java.net.URI} supports IPv6 scope IDs but accepts and emits a non-standard syntax.
147+
* {@link Uri} implements the newer RFC 6874, which percent encodes scope IDs and the % delimiter
148+
* itself. RFC 9844 claims to obsolete RFC 6874 because web browsers would not support it. This
149+
* class implements RFC 6874 anyway, mostly to avoid creating a barrier to migration away from
150+
* {@link java.net.URI}.
145151
*/
146152
@Internal
147153
public final class Uri {
@@ -825,12 +831,32 @@ public Builder setHost(@Nullable String regName) {
825831
*/
826832
@CanIgnoreReturnValue
827833
public Builder setHost(@Nullable InetAddress addr) {
828-
this.host = addr != null ? InetAddresses.toUriString(addr) : null;
834+
this.host = addr != null ? toUriString(addr) : null;
829835
return this;
830836
}
831837

838+
private static String toUriString(InetAddress addr) {
839+
// InetAddresses.toUriString(addr) is almost enough but neglects RFC 6874 percent encoding.
840+
String inetAddrStr = InetAddresses.toUriString(addr);
841+
int percentIndex = inetAddrStr.indexOf('%');
842+
if (percentIndex < 0) {
843+
return inetAddrStr;
844+
}
845+
846+
String scope = inetAddrStr.substring(percentIndex, inetAddrStr.length() - 1);
847+
return inetAddrStr.substring(0, percentIndex) + percentEncode(scope, unreservedChars) + "]";
848+
}
849+
832850
@CanIgnoreReturnValue
833851
Builder setRawHost(String host) {
852+
if (host.startsWith("[") && host.endsWith("]")) {
853+
// IP-literal: Guava's isUriInetAddress() is almost enough but it doesn't check the scope.
854+
int percentIndex = host.indexOf('%');
855+
if (percentIndex > 0) {
856+
String scope = host.substring(percentIndex, host.length() - 1);
857+
checkPercentEncodedArg(scope, "scope", unreservedChars);
858+
}
859+
}
834860
// IP-literal validation is complicated so we delegate it to Guava. We use this particular
835861
// method of InetAddresses because it doesn't try to match interfaces on the local machine.
836862
// (The validity of a URI should be the same no matter which machine does the parsing.)

api/src/test/java/io/grpc/UriTest.java

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,13 @@
1818

1919
import static com.google.common.truth.Truth.assertThat;
2020
import static org.junit.Assert.assertThrows;
21+
import static org.junit.Assume.assumeNoException;
2122

2223
import com.google.common.net.InetAddresses;
2324
import com.google.common.testing.EqualsTester;
25+
import java.net.Inet6Address;
2426
import java.net.URISyntaxException;
27+
import java.net.UnknownHostException;
2528
import java.util.BitSet;
2629
import org.junit.Test;
2730
import org.junit.runner.RunWith;
@@ -77,6 +80,20 @@ public void parse_ipv6Literal_noPort() throws URISyntaxException {
7780
assertThat(uri.getPort()).isLessThan(0);
7881
}
7982

83+
@Test
84+
public void parse_ipv6ScopedLiteral() throws URISyntaxException {
85+
Uri uri = Uri.parse("http://[fe80::1%25eth0]");
86+
assertThat(uri.getRawHost()).isEqualTo("[fe80::1%25eth0]");
87+
assertThat(uri.getHost()).isEqualTo("[fe80::1%eth0]");
88+
}
89+
90+
@Test
91+
public void parse_ipv6ScopedPercentEncodedLiteral() throws URISyntaxException {
92+
Uri uri = Uri.parse("http://[fe80::1%25foo-bar%2Fblah]");
93+
assertThat(uri.getRawHost()).isEqualTo("[fe80::1%25foo-bar%2Fblah]");
94+
assertThat(uri.getHost()).isEqualTo("[fe80::1%foo-bar/blah]");
95+
}
96+
8097
@Test
8198
public void parse_noQuery() throws URISyntaxException {
8299
Uri uri = Uri.parse("scheme://authority/path#fragment");
@@ -203,6 +220,13 @@ public void parse_invalidBackslashInHost_throws() {
203220
assertThat(e).hasMessageThat().contains("Invalid character in host");
204221
}
205222

223+
@Test
224+
public void parse_invalidBackslashScope_throws() {
225+
URISyntaxException e =
226+
assertThrows(URISyntaxException.class, () -> Uri.parse("http://[::1%25foo\\bar]"));
227+
assertThat(e).hasMessageThat().contains("Invalid character in scope");
228+
}
229+
206230
@Test
207231
public void parse_emptyPort_throws() {
208232
URISyntaxException e =
@@ -397,6 +421,47 @@ public void builder_ipv6Literal() throws URISyntaxException {
397421
assertThat(uri.toString()).isEqualTo("scheme://[2001:4860:4860::8844]");
398422
}
399423

424+
@Test
425+
public void builder_ipv6ScopedLiteral_numeric() throws UnknownHostException {
426+
Uri uri =
427+
Uri.newBuilder()
428+
.setScheme("http")
429+
// Create an address with a numeric scope_id, which should always be valid.
430+
.setHost(
431+
Inet6Address.getByAddress(null, InetAddresses.forString("fe80::1").getAddress(), 1))
432+
.build();
433+
434+
// We expect the scope ID to be percent encoded.
435+
assertThat(uri.getRawHost()).isEqualTo("[fe80::1%251]");
436+
assertThat(uri.getHost()).isEqualTo("[fe80::1%1]");
437+
}
438+
439+
@Test
440+
public void builder_ipv6ScopedLiteral_named() throws UnknownHostException {
441+
// Unfortunately, there's no Java API to create an Inet6Address with an arbitrary interface-
442+
// scoped name. There's actually no way to hermetically create an Inet6Address with a scope name
443+
// at all! The following address/interface is likely to be present on Linux test runners.
444+
Inet6Address address;
445+
try {
446+
address = (Inet6Address) InetAddresses.forString("::1%lo");
447+
} catch (IllegalArgumentException e) {
448+
assumeNoException(e);
449+
return; // Not reached.
450+
}
451+
Uri uri = Uri.newBuilder().setScheme("http").setHost(address).build();
452+
453+
// We expect the scope ID to be percent encoded.
454+
assertThat(uri.getRawHost()).isEqualTo("[::1%25lo]");
455+
assertThat(uri.getHost()).isEqualTo("[::1%lo]");
456+
}
457+
458+
@Test
459+
public void builder_ipv6PercentEncodedScopedLiteral() {
460+
Uri uri = Uri.newBuilder().setScheme("http").setRawHost("[fe80::1%25foo%2Dbar%2Fblah]").build();
461+
assertThat(uri.getRawHost()).isEqualTo("[fe80::1%25foo%2Dbar%2Fblah]");
462+
assertThat(uri.getHost()).isEqualTo("[fe80::1%foo-bar/blah]");
463+
}
464+
400465
@Test
401466
public void builder_encodingWithAllowedReservedChars() throws URISyntaxException {
402467
Uri uri =

0 commit comments

Comments
 (0)