Description
HttpUrl.Builder's isDotDot() matcher recognizes some percent-encoded forms of ".." (%2e., .%2e, %2e%2e) but misses two more: ..%2e and %2e... These extra forms therefore are not normalized away during URL resolution, even though they decode to exactly the same dot-dot segment.
This can cause path-traversal sandbox checks built on top of HttpUrl.resolve(...).encodedPath() to be bypassed.
Reproducer (okhttp 4.12.0)
import okhttp3.HttpUrl;
HttpUrl base = HttpUrl.parse("http://example.com/static/");
// Forms that are correctly normalized (path collapses to "/"):
base.resolve(".."); // -> http://example.com/ path=/
base.resolve("%2e%2e"); // -> http://example.com/ path=/
base.resolve(".%2e"); // -> http://example.com/ path=/
base.resolve("%2e."); // -> http://example.com/ path=/
// Forms that are NOT recognized — left in the path literally:
base.resolve("..%2e"); // -> http://example.com/static/..%2e path=/static/..%2e
base.resolve("%2e.."); // -> http://example.com/static/%2e.. path=/static/%2e..
base.resolve("..%2E"); // -> http://example.com/static/..%2E path=/static/..%2E
Root cause
HttpUrl.kt lines 1455-1460:
private fun isDotDot(input: String): Boolean {
return input == ".." ||
input.equals("%2e.", ignoreCase = true) ||
input.equals(".%2e", ignoreCase = true) ||
input.equals("%2e%2e", ignoreCase = true)
}
The enumeration covers 4 forms but not ..%2e or %2e.. (i.e. one literal dot followed by an encoded dot, then itself).
Threat model
Code that uses HttpUrl.resolve() to compute the absolute form of a user-supplied path and then validates the resulting encodedPath() against a prefix (e.g. "/static/") is bypassed:
val resolved = base.resolve(userInput)
require(resolved.encodedPath().startsWith("/static/")) // passes for "..%2e"
// later: read file at resolved.encodedPath()
// /static/..%2e → on most filesystems, %2e is decoded → /static/../ → traversal
Many HTTP servers and file-serving libraries do percent-decode %2e later in the pipeline, so the bypass becomes a path traversal in practice. URL specs (RFC 3986 §6.2.2.3) treat ..%2e and %2e.. as equivalent to ...
Suggested fix
private fun isDotDot(input: String): Boolean {
return input == ".." ||
input.equals("%2e.", ignoreCase = true) ||
input.equals(".%2e", ignoreCase = true) ||
- input.equals("%2e%2e", ignoreCase = true)
+ input.equals("%2e%2e", ignoreCase = true) ||
+ input.equals("..%2e", ignoreCase = true) ||
+ input.equals("%2e..", ignoreCase = true)
}
(Two new branches; same ignoreCase semantics already used for the others.)
Environment
- okhttp: 4.12.0 (also expected on 5.x; the matcher is unchanged in
okhttp/src/main/kotlin/okhttp3/HttpUrl.kt)
Discovered via jqwik property-based testing while fuzzing the URL-resolution invariant path-normalization(resolve(x)) is canonical.
Description
HttpUrl.Builder'sisDotDot()matcher recognizes some percent-encoded forms of".."(%2e.,.%2e,%2e%2e) but misses two more:..%2eand%2e... These extra forms therefore are not normalized away during URL resolution, even though they decode to exactly the same dot-dot segment.This can cause path-traversal sandbox checks built on top of
HttpUrl.resolve(...).encodedPath()to be bypassed.Reproducer (okhttp 4.12.0)
Root cause
HttpUrl.ktlines 1455-1460:The enumeration covers 4 forms but not
..%2eor%2e..(i.e. one literal dot followed by an encoded dot, then itself).Threat model
Code that uses
HttpUrl.resolve()to compute the absolute form of a user-supplied path and then validates the resultingencodedPath()against a prefix (e.g."/static/") is bypassed:Many HTTP servers and file-serving libraries do percent-decode
%2elater in the pipeline, so the bypass becomes a path traversal in practice. URL specs (RFC 3986 §6.2.2.3) treat..%2eand%2e..as equivalent to...Suggested fix
private fun isDotDot(input: String): Boolean { return input == ".." || input.equals("%2e.", ignoreCase = true) || input.equals(".%2e", ignoreCase = true) || - input.equals("%2e%2e", ignoreCase = true) + input.equals("%2e%2e", ignoreCase = true) || + input.equals("..%2e", ignoreCase = true) || + input.equals("%2e..", ignoreCase = true) }(Two new branches; same
ignoreCasesemantics already used for the others.)Environment
okhttp/src/main/kotlin/okhttp3/HttpUrl.kt)Discovered via jqwik property-based testing while fuzzing the URL-resolution invariant
path-normalization(resolve(x)) is canonical.