From 3ca4b6587c2595c3abb995a215604b292eb34a97 Mon Sep 17 00:00:00 2001 From: Abdellahi Brahim Date: Wed, 14 Jan 2026 12:55:56 +0000 Subject: [PATCH] Fix malformed URL error when relative path contains colon in first segment Per RFC 3986 section 4.2, a colon in the first segment of a relative-path reference can be mistaken for a scheme name (e.g., "user:value" looks like "scheme:authority"). This adds encodeColonInFirstPathSegment() which encodes colons in the first path segment of relative URLs as %3A, preventing OkHttp's URL resolution from misinterpreting them as URL schemes. Fixes #3080 --- .../java/retrofit2/RequestFactoryTest.java | 38 +++++++++++++ .../main/java/retrofit2/RequestBuilder.java | 56 ++++++++++++++++++- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/retrofit/java-test/src/test/java/retrofit2/RequestFactoryTest.java b/retrofit/java-test/src/test/java/retrofit2/RequestFactoryTest.java index b4eae2ce2d..82732ce1b2 100644 --- a/retrofit/java-test/src/test/java/retrofit2/RequestFactoryTest.java +++ b/retrofit/java-test/src/test/java/retrofit2/RequestFactoryTest.java @@ -1410,6 +1410,44 @@ Call method(@Path("ping") String ping, @Query("kit") String kit) { assertThat(request.body()).isNull(); } + @Test + public void getWithPathAndQueryColonParam() { + class Example { + @GET("/foo/bar/{ping}/") // + Call method(@Path("ping") String ping, @Query("kit") String kit) { + return null; + } + } + + Request request = buildRequest(Example.class, "pong:colon", "kat:colon"); + assertThat(request.method()).isEqualTo("GET"); + assertThat(request.headers().size()).isEqualTo(0); + // Colon in path segment after first slash is not encoded (safe in that position). + // Colon in query is encoded by OkHttp. + assertThat(request.url().toString()) + .isEqualTo("http://example.com/foo/bar/pong:colon/?kit=kat%3Acolon"); + assertThat(request.body()).isNull(); + } + + @Test + public void getWithColonInRelativeUrlFirstSegment() { + // Regression test for https://github.com/square/retrofit/issues/3080 + // A colon in the first segment of a relative URL (before the first slash) can be + // misinterpreted as a URL scheme separator. This test ensures such colons are encoded. + class Example { + @PUT("user:email={email}/login") // + Call method(@Path("email") String email, @Body String pass) { + return null; + } + } + + Request request = buildRequest(Example.class, "me@test.com", "password"); + assertThat(request.method()).isEqualTo("PUT"); + // Colon in first path segment encoded as %3A to prevent scheme misinterpretation + assertThat(request.url().toString()) + .isEqualTo("http://example.com/user%3Aemail=me@test.com/login"); + } + @Test public void getWithQueryParamList() { class Example { diff --git a/retrofit/src/main/java/retrofit2/RequestBuilder.java b/retrofit/src/main/java/retrofit2/RequestBuilder.java index 22542f1cc7..f54c0ec153 100644 --- a/retrofit/src/main/java/retrofit2/RequestBuilder.java +++ b/retrofit/src/main/java/retrofit2/RequestBuilder.java @@ -34,6 +34,56 @@ final class RequestBuilder { }; private static final String PATH_SEGMENT_ALWAYS_ENCODE_SET = " \"<>^`{}|\\?#"; + /** + * Encodes colons in the first path segment of a relative URL to prevent them from being + * misinterpreted as URL scheme separators. Per RFC 3986 section 4.2, a colon in the first segment + * of a relative-path reference can be mistaken for a scheme name. + * + *

This method only encodes colons in relative paths. If the URL looks like it has a scheme + * (e.g., starts with "http://", "https://"), it is returned unchanged. + */ + private static String encodeColonInFirstPathSegment(String relativeUrl) { + if (relativeUrl.isEmpty() || relativeUrl.charAt(0) == '/') { + // Absolute path or empty, no encoding needed. + return relativeUrl; + } + + int firstColon = relativeUrl.indexOf(':'); + if (firstColon == -1) { + // No colon, nothing to encode. + return relativeUrl; + } + + int firstSlash = relativeUrl.indexOf('/'); + if (firstSlash != -1 && firstSlash < firstColon) { + // Colon is after the first slash, so it's not in the first segment. + return relativeUrl; + } + + // Check if this looks like a URL scheme (scheme followed by "://"). + // Per RFC 3986, a scheme is followed by ":" and authority starts with "//". + if (relativeUrl.length() > firstColon + 2 + && relativeUrl.charAt(firstColon + 1) == '/' + && relativeUrl.charAt(firstColon + 2) == '/') { + // This looks like a scheme (e.g., "http://..."), don't encode. + return relativeUrl; + } + + // Encode all colons in the first segment (before the first slash or end of string). + int endOfFirstSegment = firstSlash == -1 ? relativeUrl.length() : firstSlash; + StringBuilder encoded = new StringBuilder(); + for (int i = 0; i < endOfFirstSegment; i++) { + char c = relativeUrl.charAt(i); + if (c == ':') { + encoded.append("%3A"); + } else { + encoded.append(c); + } + } + encoded.append(relativeUrl.substring(endOfFirstSegment)); + return encoded.toString(); + } + /** * Matches strings that contain {@code .} or {@code ..} as a complete path segment. This also * matches dots in their percent-encoded form, {@code %2E}. @@ -187,7 +237,8 @@ private static void canonicalizeForPath( void addQueryParam(String name, @Nullable String value, boolean encoded) { if (relativeUrl != null) { // Do a one-time combination of the built relative URL and the base URL. - urlBuilder = baseUrl.newBuilder(relativeUrl); + String encodedRelativeUrl = encodeColonInFirstPathSegment(relativeUrl); + urlBuilder = baseUrl.newBuilder(encodedRelativeUrl); if (urlBuilder == null) { throw new IllegalArgumentException( "Malformed URL. Base: " + baseUrl + ", Relative: " + relativeUrl); @@ -239,7 +290,8 @@ Request.Builder get() { } else { // No query parameters triggered builder creation, just combine the relative URL and base URL. //noinspection ConstantConditions Non-null if urlBuilder is null. - url = baseUrl.resolve(relativeUrl); + String encodedRelativeUrl = encodeColonInFirstPathSegment(relativeUrl); + url = baseUrl.resolve(encodedRelativeUrl); if (url == null) { throw new IllegalArgumentException( "Malformed URL. Base: " + baseUrl + ", Relative: " + relativeUrl);