Skip to content

HttpUrl.isDotDot misses '..%2e' and '%2e..' forms — path-traversal sandbox bypass #9451

@zhangjiashuo-cs

Description

@zhangjiashuo-cs

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions