Skip to content

Commit b9a7f6a

Browse files
committed
Forbid path traversal ('.' and '..') as @path parameters.
They're likely to have the unintended effect. For example, passing ".." to @delete /account/book/{isbn}/ yields @delete /account/.
1 parent 5088b0d commit b9a7f6a

File tree

2 files changed

+113
-1
lines changed

2 files changed

+113
-1
lines changed

retrofit/src/main/java/retrofit2/RequestBuilder.java

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package retrofit2;
1717

1818
import java.io.IOException;
19+
import java.util.regex.Pattern;
1920
import javax.annotation.Nullable;
2021
import okhttp3.FormBody;
2122
import okhttp3.Headers;
@@ -32,6 +33,21 @@ final class RequestBuilder {
3233
{ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F' };
3334
private static final String PATH_SEGMENT_ALWAYS_ENCODE_SET = " \"<>^`{}|\\?#";
3435

36+
/**
37+
* Matches strings that contain {@code .} or {@code ..} as a complete path segment. This also
38+
* matches dots in their percent-encoded form, {@code %2E}.
39+
*
40+
* <p>It is okay to have these strings within a larger path segment (like {@code a..z} or {@code
41+
* index.html}) but when alone they have a special meaning. A single dot resolves to no path
42+
* segment so {@code /one/./three/} becomes {@code /one/three/}. A double-dot pops the preceding
43+
* directory, so {@code /one/../three/} becomes {@code /three/}.
44+
*
45+
* <p>We forbid these in Retrofit paths because they're likely to have the unintended effect.
46+
* For example, passing {@code ..} to {@code DELETE /account/book/{isbn}/} yields {@code DELETE
47+
* /account/}.
48+
*/
49+
private static final Pattern PATH_TRAVERSAL = Pattern.compile("(.*/)?(\\.|%2e|%2E){1,2}(/.*)?");
50+
3551
private final String method;
3652

3753
private final HttpUrl baseUrl;
@@ -91,7 +107,13 @@ void addPathParam(String name, String value, boolean encoded) {
91107
// The relative URL is cleared when the first query parameter is set.
92108
throw new AssertionError();
93109
}
94-
relativeUrl = relativeUrl.replace("{" + name + "}", canonicalizeForPath(value, encoded));
110+
String replacement = canonicalizeForPath(value, encoded);
111+
String newRelativeUrl = relativeUrl.replace("{" + name + "}", replacement);
112+
if (PATH_TRAVERSAL.matcher(newRelativeUrl).matches()) {
113+
throw new IllegalArgumentException(
114+
"@Path parameters shouldn't perform path traversal ('.' or '..'): " + value);
115+
}
116+
relativeUrl = newRelativeUrl;
95117
}
96118

97119
private static String canonicalizeForPath(String input, boolean alreadyEncoded) {

retrofit/src/test/java/retrofit2/RequestFactoryTest.java

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -878,6 +878,88 @@ Call<ResponseBody> method(@Path(value = "ping", encoded = true) String ping) {
878878
assertThat(request.body()).isNull();
879879
}
880880

881+
@Test public void pathParametersAndPathTraversal() {
882+
class Example {
883+
@GET("/foo/bar/{ping}/") //
884+
Call<ResponseBody> method(@Path(value = "ping") String ping) {
885+
return null;
886+
}
887+
}
888+
889+
assertMalformedRequest(Example.class, ".");
890+
assertMalformedRequest(Example.class, "..");
891+
892+
assertThat(buildRequest(Example.class, "./a").url().encodedPath())
893+
.isEqualTo("/foo/bar/.%2Fa/");
894+
assertThat(buildRequest(Example.class, "a/.").url().encodedPath())
895+
.isEqualTo("/foo/bar/a%2F./");
896+
assertThat(buildRequest(Example.class, "a/..").url().encodedPath())
897+
.isEqualTo("/foo/bar/a%2F../");
898+
assertThat(buildRequest(Example.class, "../a").url().encodedPath())
899+
.isEqualTo("/foo/bar/..%2Fa/");
900+
assertThat(buildRequest(Example.class, "..\\..").url().encodedPath())
901+
.isEqualTo("/foo/bar/..%5C../");
902+
903+
assertThat(buildRequest(Example.class, "%2E").url().encodedPath())
904+
.isEqualTo("/foo/bar/%252E/");
905+
assertThat(buildRequest(Example.class, "%2E%2E").url().encodedPath())
906+
.isEqualTo("/foo/bar/%252E%252E/");
907+
}
908+
909+
@Test public void encodedPathParametersAndPathTraversal() {
910+
class Example {
911+
@GET("/foo/bar/{ping}/") //
912+
Call<ResponseBody> method(@Path(value = "ping", encoded = true) String ping) {
913+
return null;
914+
}
915+
}
916+
917+
assertMalformedRequest(Example.class, ".");
918+
assertMalformedRequest(Example.class, "%2E");
919+
assertMalformedRequest(Example.class, "%2e");
920+
assertMalformedRequest(Example.class, "..");
921+
assertMalformedRequest(Example.class, "%2E.");
922+
assertMalformedRequest(Example.class, "%2e.");
923+
assertMalformedRequest(Example.class, ".%2E");
924+
assertMalformedRequest(Example.class, ".%2e");
925+
assertMalformedRequest(Example.class, "%2E%2e");
926+
assertMalformedRequest(Example.class, "%2e%2E");
927+
assertMalformedRequest(Example.class, "./a");
928+
assertMalformedRequest(Example.class, "a/.");
929+
assertMalformedRequest(Example.class, "../a");
930+
assertMalformedRequest(Example.class, "a/..");
931+
assertMalformedRequest(Example.class, "a/../b");
932+
assertMalformedRequest(Example.class, "a/%2e%2E/b");
933+
934+
assertThat(buildRequest(Example.class, "...").url().encodedPath())
935+
.isEqualTo("/foo/bar/.../");
936+
assertThat(buildRequest(Example.class, "a..b").url().encodedPath())
937+
.isEqualTo("/foo/bar/a..b/");
938+
assertThat(buildRequest(Example.class, "a..").url().encodedPath())
939+
.isEqualTo("/foo/bar/a../");
940+
assertThat(buildRequest(Example.class, "a..b").url().encodedPath())
941+
.isEqualTo("/foo/bar/a..b/");
942+
assertThat(buildRequest(Example.class, "..b").url().encodedPath())
943+
.isEqualTo("/foo/bar/..b/");
944+
assertThat(buildRequest(Example.class, "..\\..").url().encodedPath())
945+
.isEqualTo("/foo/bar/..%5C../");
946+
}
947+
948+
@Test public void dotDotsOkayWhenNotFullPathSegment() {
949+
class Example {
950+
@GET("/foo{ping}bar/") //
951+
Call<ResponseBody> method(@Path(value = "ping", encoded = true) String ping) {
952+
return null;
953+
}
954+
}
955+
956+
assertMalformedRequest(Example.class, "/./");
957+
assertMalformedRequest(Example.class, "/../");
958+
959+
assertThat(buildRequest(Example.class, ".").url().encodedPath()).isEqualTo("/foo.bar/");
960+
assertThat(buildRequest(Example.class, "..").url().encodedPath()).isEqualTo("/foo..bar/");
961+
}
962+
881963
@Test public void pathParamRequired() {
882964
class Example {
883965
@GET("/foo/bar/{ping}/") //
@@ -2783,4 +2865,12 @@ static <T> Request buildRequest(Class<T> cls, Object... args) {
27832865

27842866
return buildRequest(cls, retrofitBuilder, args);
27852867
}
2868+
2869+
static void assertMalformedRequest(Class<?> cls, Object... args) {
2870+
try {
2871+
Request request = buildRequest(cls, args);
2872+
fail("expected a malformed request but was " + request);
2873+
} catch (IllegalArgumentException expected) {
2874+
}
2875+
}
27862876
}

0 commit comments

Comments
 (0)