CVE-2021-43795: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (CWE-22)
value: Armeria is an open source microservice framework. In affected versions an attacker can access an Armeria server's local file system beyond its restricted directory by sending an HTTP request whose path contains `%2F` (encoded `/`), such as `/files/..%2Fsecrets.txt`, bypassing Armeria's path validation logic. Armeria 1.13.4 or above contains the hardened path validation logic that handles `%2F` properly. This vulnerability can be worked around by inserting a decorator that performs an additional validation on the request path.
CWE description: The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.
CVSS v3 base score: 7.5
Published: 2021-12-02T18:15Z
Repository: https://github.com/line/armeria
Language: Java
Files changed: core/src/main/java/com/linecorp/armeria/internal/common/ArmeriaHttpUtil.java, core/src/main/java/com/linecorp/armeria/internal/common/PathAndQuery.java, core/src/main/java/com/linecorp/armeria/server/RoutingResultBuilder.java, core/src/test/java/com/linecorp/armeria/internal/common/ArmeriaHttpUtilTest.java, core/src/test/java/com/linecorp/armeria/internal/common/PathAndQueryTest.java, core/src/test/java/com/linecorp/armeria/server/RoutingResultTest.java
Commit date: 2021-12-02 19:54:13 +0900
Commit message: Merge pull request from GHSA-8fp4-rp6c-5gcv

Motivation:

- We changed how `PathAndQuery` handles `%2F` (/) in 1.12.0 via #3855.
  This change introduces an unexpected hole in its double-dot detection
  logic.
- Since we decided not to decode `%2F`, we should not decode it
  whereever possible.

Modifications:

- Hardened the double-dot detection logic in `PathAndQuery`.
- `Bytes.data` now always store the bytes in their decoded form. We keep
  whether the byte has to be encoded in a separate `BitSet`.
- Split `ArmeriaHttpUtil.decodePath()` into `decodePath()` and
  `decodePathParam()`.
  - We don't decode `%2F` in `decodePath()` but we do in
    `decodePathParam()`.
  - `RoutingResultBuilder.rawParam()` now uses `decodePathParam()`
    because `decodePath()` doesn't decode `%2F` anymore.

Result:

- A path that contains double dots with `%2F`, such as
  `/files/..%2Fsecrets.txt`, are now rejected correctly.
Diff: diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/ArmeriaHttpUtil.java b/core/src/main/java/com/linecorp/armeria/internal/common/ArmeriaHttpUtil.java
index ca047f70b..9a32af549 100644
--- a/core/src/main/java/com/linecorp/armeria/internal/common/ArmeriaHttpUtil.java
+++ b/core/src/main/java/com/linecorp/armeria/internal/common/ArmeriaHttpUtil.java
@@ -302,15 +302,41 @@ public final class ArmeriaHttpUtil {
     /**
      * Decodes a percent-encoded path string.
      */
     public static String decodePath(String path) {
         if (path.indexOf('%') < 0) {
-            // No need to decoded; not percent-encoded
+            // No need to decode because it's not percent-encoded
             return path;
         }
 
+        // Decode percent-encoded characters, but don't decode %2F into /, so that a user can choose
+        // to use it as a non-separator.
+        //
+        // For example, for the path pattern `/orgs/{org_name}/agents/{agent_name}`:
+        // - orgs/mi6/agents/ethan-hunt
+        //   - org_name: mi6
+        //   - agent_name: ethan-hunt
+        // - orgs/mi%2F6/agents/ethan-hunt
+        //   - org_name: mi/6
+        //   - agent_name: ethan-hunt
+        return slowDecodePath(path, false);
+    }
+
+    /**
+     * Decodes a single percent-encoded path parameter.
+     */
+    public static String decodePathParam(String pathParam) {
+        if (pathParam.indexOf('%') < 0) {
+            // No need to decode because it's not percent-encoded
+            return pathParam;
+        }
+
         // Decode percent-encoded characters.
+        return slowDecodePath(pathParam, true);
+    }
+
+    private static String slowDecodePath(String path, boolean decodeSlash) {
         // An invalid character is replaced with 0xFF, which will be replaced into '�' by UTF-8 decoder.
         final int len = path.length();
         try (TemporaryThreadLocals tempThreadLocals = TemporaryThreadLocals.acquire()) {
             final byte[] buf = tempThreadLocals.byteArray(len);
             int dstLen = 0;
@@ -333,11 +359,18 @@ public final class ArmeriaHttpUtil {
                 final int digit2 = decodeHexNibble(path.charAt(++i));
                 if (digit1 < 0 || digit2 < 0) {
                     // The first or second digit is not hexadecimal.
                     buf[dstLen++] = (byte) 0xFF;
                 } else {
-                    buf[dstLen++] = (byte) ((digit1 << 4) | digit2);
+                    final byte decoded = (byte) ((digit1 << 4) | digit2);
+                    if (decodeSlash || decoded != 0x2F) {
+                        buf[dstLen++] = decoded;
+                    } else {
+                        buf[dstLen++] = '%';
+                        buf[dstLen++] = '2';
+                        buf[dstLen++] = (byte) path.charAt(i); // f or F - preserve the case.
+                    }
                 }
             }
 
             return new String(buf, 0, dstLen, StandardCharsets.UTF_8);
         }
diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/PathAndQuery.java b/core/src/main/java/com/linecorp/armeria/internal/common/PathAndQuery.java
index edaa12500..7245f2f23 100644
--- a/core/src/main/java/com/linecorp/armeria/internal/common/PathAndQuery.java
+++ b/core/src/main/java/com/linecorp/armeria/internal/common/PathAndQuery.java
@@ -42,17 +42,29 @@ import it.unimi.dsi.fastutil.bytes.ByteArrays;
  */
 public final class PathAndQuery {
 
     private static final PathAndQuery ROOT_PATH_QUERY = new PathAndQuery("/", null);
 
+    /**
+     * The lookup table for the characters allowed in a path.
+     */
     private static final BitSet ALLOWED_PATH_CHARS = new BitSet();
+
+    /**
+     * The lookup table for the characters allowed in a query string.
+     */
     private static final BitSet ALLOWED_QUERY_CHARS = new BitSet();
 
-    private static final int PERCENT_ENCODING_MARKER = 0xFF;
+    /**
+     * The lookup table for the reserved characters that require percent-encoding.
+     */
+    private static final BitSet RESERVED_CHARS = new BitSet();
 
-    private static final byte[] RAW_CHAR_TO_MARKER = new byte[256];
-    private static final String[] MARKER_TO_PERCENT_ENCODED_CHAR = new String[256];
+    /**
+     * The table that converts a byte into a percent-encoded chars, e.g. 'A' -> "%41".
+     */
+    private static final char[][] TO_PERCENT_ENCODED_CHARS = new char[256][];
 
     static {
         final String allowedPathChars =
                 "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/?#[]@!$&'()*+,;=";
         for (int i = 0; i < allowedPathChars.length(); i++) {
@@ -63,13 +75,17 @@ public final class PathAndQuery {
                 "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/?#[]@!$&'()*,;=";
         for (int i = 0; i < allowedQueryChars.length(); i++) {
             ALLOWED_QUERY_CHARS.set(allowedQueryChars.charAt(i));
         }
 
-        for (final ReservedChar reservedChar : ReservedChar.values()) {
-            RAW_CHAR_TO_MARKER[reservedChar.rawChar] = reservedChar.marker;
-            MARKER_TO_PERCENT_ENCODED_CHAR[reservedChar.marker] = reservedChar.percentEncodedChar;
+        final String reservedChars = ":/?#[]@!$&'()*+,;=";
+        for (int i = 0; i < reservedChars.length(); i++) {
+            RESERVED_CHARS.set(reservedChars.charAt(i));
+        }
+
+        for (int i = 0; i < TO_PERCENT_ENCODED_CHARS.length; i++) {
+            TO_PERCENT_ENCODED_CHARS[i] = String.format("%%%02X", i).toCharArray();
         }
     }
 
     private static final Bytes EMPTY_QUERY = new Bytes(0);
     private static final Bytes ROOT_PATH = new Bytes(new byte[] { '/' });
@@ -180,11 +196,11 @@ public final class PathAndQuery {
     @Override
     public String toString() {
         if (query == null) {
             return path;
         }
-        return path + "?" + query;
+        return path + '?' + query;
     }
 
     @Nullable
     private static PathAndQuery splitPathAndQuery(@Nullable final String pathAndQuery) {
         final Bytes path;
@@ -211,22 +227,21 @@ public final class PathAndQuery {
                 return null;
             }
             query = null;
         }
 
-        if (path.data[0] != '/') {
+        if (path.data[0] != '/' || path.isEncoded(0)) {
             // Do not accept a relative path.
             return null;
         }
 
         // Reject the prohibited patterns.
-        if (pathContainsDoubleDots(path)) {
+        if (pathContainsDoubleDots(path) || queryContainsDoubleDots(query)) {
             return null;
         }
 
-        return new PathAndQuery(encodeToPercents(path, true),
-                                query != null ? encodeToPercents(query, false) : null);
+        return new PathAndQuery(encodePathToPercents(path), encodeQueryToPercents(query));
     }
 
     @Nullable
     private static Bytes decodePercentsAndEncodeToUtf8(String value, int start, int end, boolean isPath) {
         final int length = end - start;
@@ -259,36 +274,25 @@ public final class PathAndQuery {
                 if (isPath) {
                     if (decoded == '/') {
                         // Do not decode '%2F' and '%2f' in the path to '/' for compatibility with
                         // other implementations in the ecosystem, e.g. HTTP/JSON to gRPC transcoding.
                         // https://github.com/googleapis/googleapis/blob/02710fa0ea5312d79d7fb986c9c9823fb41049a9/google/api/http.proto#L257-L258
-
-                        // Insert a special mark so we can distinguish a raw character ('/') and
-                        // percent-encoded character ('%2F') in a path string.
-                        // We will encode this mark back into a percent-encoded character later.
-                        final byte marker = RAW_CHAR_TO_MARKER['/'];
-                        buf.ensure(2);
-                        buf.add((byte) PERCENT_ENCODING_MARKER);
-                        buf.add(marker);
+                        buf.ensure(1);
+                        buf.addEncoded((byte) '/');
                         wasSlash = false;
                     } else {
                         if (appendOneByte(buf, decoded, wasSlash, isPath)) {
                             wasSlash = false;
                         } else {
                             return null;
                         }
                     }
                 } else {
                     // If query:
-                    final byte marker = RAW_CHAR_TO_MARKER[decoded];
-                    if (marker != 0) {
-                        // Insert a special mark so we can distinguish a raw character and percent-encoded
-                        // character in a query string, such as '&' and '%26'.
-                        // We will encode this mark back into a percent-encoded character later.
-                        buf.ensure(2);
-                        buf.add((byte) PERCENT_ENCODING_MARKER);
-                        buf.add(marker);
+                    if (RESERVED_CHARS.get(decoded)) {
+                        buf.ensure(1);
+                        buf.addEncoded((byte) decoded);
                         wasSlash = false;
                     } else if (appendOneByte(buf, decoded, wasSlash, isPath)) {
                         wasSlash = decoded == '/';
                     } else {
                         return null;
@@ -299,11 +303,11 @@ public final class PathAndQuery {
                 continue;
             }
 
             if (cp == '+' && !isPath) {
                 buf.ensure(1);
-                buf.add((byte) ' ');
+                buf.addEncoded((byte) ' ');
                 wasSlash = false;
                 continue;
             }
 
             if (cp <= 0x7F) {
@@ -314,42 +318,42 @@ public final class PathAndQuery {
                 continue;
             }
 
             if (cp <= 0x7ff) {
                 buf.ensure(2);
-                buf.add((byte) ((cp >>> 6) | 0b110_00000));
-                buf.add((byte) (cp & 0b111111 | 0b10_000000));
+                buf.addEncoded((byte) ((cp >>> 6) | 0b110_00000));
+                buf.addEncoded((byte) (cp & 0b111111 | 0b10_000000));
             } else if (cp <= 0xffff) {
                 buf.ensure(3);
-                buf.add((byte) ((cp >>> 12) | 0b1110_0000));
-                buf.add((byte) (((cp >>> 6) & 0b111111) | 0b10_000000));
-                buf.add((byte) ((cp & 0b111111) | 0b10_000000));
+                buf.addEncoded((byte) ((cp >>> 12) | 0b1110_0000));
+                buf.addEncoded((byte) (((cp >>> 6) & 0b111111) | 0b10_000000));
+                buf.addEncoded((byte) ((cp & 0b111111) | 0b10_000000));
             } else if (cp <= 0x1fffff) {
                 buf.ensure(4);
-                buf.add((byte) ((cp >>> 18) | 0b11110_000));
-                buf.add((byte) (((cp >>> 12) & 0b111111) | 0b10_000000));
-                buf.add((byte) (((cp >>> 6) & 0b111111) | 0b10_000000));
-                buf.add((byte) ((cp & 0b111111) | 0b10_000000));
+                buf.addEncoded((byte) ((cp >>> 18) | 0b11110_000));
+                buf.addEncoded((byte) (((cp >>> 12) & 0b111111) | 0b10_000000));
+                buf.addEncoded((byte) (((cp >>> 6) & 0b111111) | 0b10_000000));
+                buf.addEncoded((byte) ((cp & 0b111111) | 0b10_000000));
             } else if (cp <= 0x3ffffff) {
                 // A valid unicode character will never reach here, but for completeness.
                 // http://unicode.org/mail-arch/unicode-ml/Archives-Old/UML018/0330.html
                 buf.ensure(5);
-                buf.add((byte) ((cp >>> 24) | 0b111110_00));
-                buf.add((byte) (((cp >>> 18) & 0b111111) | 0b10_000000));
-                buf.add((byte) (((cp >>> 12) & 0b111111) | 0b10_000000));
-                buf.add((byte) (((cp >>> 6) & 0b111111) | 0b10_000000));
-                buf.add((byte) ((cp & 0b111111) | 0b10_000000));
+                buf.addEncoded((byte) ((cp >>> 24) | 0b111110_00));
+                buf.addEncoded((byte) (((cp >>> 18) & 0b111111) | 0b10_000000));
+                buf.addEncoded((byte) (((cp >>> 12) & 0b111111) | 0b10_000000));
+                buf.addEncoded((byte) (((cp >>> 6) & 0b111111) | 0b10_000000));
+                buf.addEncoded((byte) ((cp & 0b111111) | 0b10_000000));
             } else {
                 // A valid unicode character will never reach here, but for completeness.
                 // http://unicode.org/mail-arch/unicode-ml/Archives-Old/UML018/0330.html
                 buf.ensure(6);
-                buf.add((byte) ((cp >>> 30) | 0b1111110_0));
-                buf.add((byte) (((cp >>> 24) & 0b111111) | 0b10_000000));
-                buf.add((byte) (((cp >>> 18) & 0b111111) | 0b10_000000));
-                buf.add((byte) (((cp >>> 12) & 0b111111) | 0b10_000000));
-                buf.add((byte) (((cp >>> 6) & 0b111111) | 0b10_000000));
-                buf.add((byte) ((cp & 0b111111) | 0b10_000000));
+                buf.addEncoded((byte) ((cp >>> 30) | 0b1111110_0));
+                buf.addEncoded((byte) (((cp >>> 24) & 0b111111) | 0b10_000000));
+                buf.addEncoded((byte) (((cp >>> 18) & 0b111111) | 0b10_000000));
+                buf.addEncoded((byte) (((cp >>> 12) & 0b111111) | 0b10_000000));
+                buf.addEncoded((byte) (((cp >>> 6) & 0b111111) | 0b10_000000));
+                buf.addEncoded((byte) ((cp & 0b111111) | 0b10_000000));
             }
 
             wasSlash = false;
         }
 
@@ -378,12 +382,17 @@ public final class PathAndQuery {
                 buf.add((byte) '/');
             } else {
                 // Remove the consecutive slashes: '/path//with///consecutive////slashes'.
             }
         } else {
+            final BitSet allowedChars = isPath ? ALLOWED_PATH_CHARS : ALLOWED_QUERY_CHARS;
             buf.ensure(1);
-            buf.add((byte) cp);
+            if (allowedChars.get(cp)) {
+                buf.add((byte) cp);
+            } else {
+                buf.addEncoded((byte) cp);
+            }
         }
 
         return true;
     }
 
@@ -392,81 +401,148 @@ public final class PathAndQuery {
         byte b0 = 0;
         byte b1 = 0;
         byte b2 = '/';
         for (int i = 1; i < length; i++) {
             final byte b3 = path.data[i];
-            if (b3 == '/' && b2 == '.' && b1 == '.' && b0 == '/') {
+            // Flag if the last four bytes are `/../`.
+            if (b1 == '.' && b2 == '.' && isSlash(b0) && isSlash(b3)) {
                 return true;
             }
             b0 = b1;
             b1 = b2;
             b2 = b3;
         }
 
-        return b0 == '/' && b1 == '.' && b2 == '.';
+        // Flag if the last three bytes are `/..`.
+        return b1 == '.' && b2 == '.' && isSlash(b0);
     }
 
-    private static String encodeToPercents(Bytes value, boolean isPath) {
-        final BitSet allowedChars = isPath ? ALLOWED_PATH_CHARS : ALLOWED_QUERY_CHARS;
-        final int length = value.length;
-        boolean needsEncoding = false;
+    private static boolean queryContainsDoubleDots(@Nullable Bytes query) {
+        if (query == null) {
+            return false;
+        }
+
+        final int length = query.length;
+        boolean lookingForEquals = true;
+        byte b0 = 0;
+        byte b1 = 0;
+        byte b2 = '/';
         for (int i = 0; i < length; i++) {
-            if (!allowedChars.get(value.data[i] & 0xFF)) {
-                needsEncoding = true;
-                break;
+            byte b3 = query.data[i];
+
+            // Treat the delimiters as `/` so that we can use isSlash() for matching them.
+            switch (b3) {
+                case '=':
+                    // Treat only the first `=` as `/`, e.g.
+                    // - `foo=..` and `foo=../` should be flagged.
+                    // - `foo=..=` shouldn't be flagged because `..=` is not a relative path.
+                    if (lookingForEquals) {
+                        lookingForEquals = false;
+                        b3 = '/';
+                    }
+                    break;
+                case '&':
+                case ';':
+                    b3 = '/';
+                    lookingForEquals = true;
+                    break;
+            }
+
+            // Flag if the last four bytes are `/../` or `/..&`
+            if (b1 == '.' && b2 == '.' && isSlash(b0) && isSlash(b3)) {
+                return true;
             }
+
+            b0 = b1;
+            b1 = b2;
+            b2 = b3;
+        }
+
+        return b1 == '.' && b2 == '.' && isSlash(b0);
+    }
+
+    private static boolean isSlash(byte b) {
+        switch (b) {
+            case '/':
+            case '\\':
+                return true;
+            default:
+                return false;
         }
+    }
 
-        if (!needsEncoding) {
+    private static String encodePathToPercents(Bytes value) {
+        if (!value.hasEncodedBytes()) {
             // Deprecated, but it fits perfect for our use case.
             // noinspection deprecation
-            return new String(value.data, 0, 0, length);
+            return new String(value.data, 0, 0, value.length);
         }
 
-        final StringBuilder buf = new StringBuilder(length);
+        // Slow path: some percent-encoded chars.
+        return slowEncodePathToPercents(value);
+    }
+
+    @Nullable
+    private static String encodeQueryToPercents(@Nullable Bytes value) {
+        if (value == null) {
+            return null;
+        }
+
+        if (!value.hasEncodedBytes()) {
+            // Deprecated, but it fits perfect for our use case.
+            // noinspection deprecation
+            return new String(value.data, 0, 0, value.length);
+        }
+
+        // Slow path: some percent-encoded chars.
+        return slowEncodeQueryToPercents(value);
+    }
+
+    private static String slowEncodePathToPercents(Bytes value) {
+        final int length = value.length;
+        final StringBuilder buf = new StringBuilder(length + value.numEncodedBytes() * 2);
         for (int i = 0; i < length; i++) {
             final int b = value.data[i] & 0xFF;
 
-            if (b == PERCENT_ENCODING_MARKER && (i + 1) < length) {
-                final int marker = value.data[i + 1] & 0xFF;
-                final String percentEncodedChar = MARKER_TO_PERCENT_ENCODED_CHAR[marker];
-                if (percentEncodedChar != null) {
-                    buf.append(percentEncodedChar);
-                    i++;
-                    continue;
-                }
+            if (value.isEncoded(i)) {
+                buf.append(TO_PERCENT_ENCODED_CHARS[b]);
+                continue;
             }
 
-            if (allowedChars.get(b)) {
-                buf.append((char) b);
-            } else if (b == ' ') {
-                if (isPath) {
-                    buf.append("%20");
-                } else {
-                    buf.append('+');
-                }
-            } else {
-                buf.append('%');
-                appendHexNibble(buf, b >>> 4);
-                appendHexNibble(buf, b & 0xF);
-            }
+            buf.append((char) b);
         }
 
         return buf.toString();
     }
 
-    private static void appendHexNibble(StringBuilder buf, int nibble) {
-        if (nibble < 10) {
-            buf.append((char) ('0' + nibble));
-        } else {
-            buf.append((char) ('A' + nibble - 10));
+    private static String slowEncodeQueryToPercents(Bytes value) {
+        final int length = value.length;
+        final StringBuilder buf = new StringBuilder(length + value.numEncodedBytes() * 2);
+        for (int i = 0; i < length; i++) {
+            final int b = value.data[i] & 0xFF;
+
+            if (value.isEncoded(i)) {
+                if (b == ' ') {
+                    buf.append('+');
+                } else {
+                    buf.append(TO_PERCENT_ENCODED_CHARS[b]);
+                }
+                continue;
+            }
+
+            buf.append((char) b);
         }
+
+        return buf.toString();
     }
 
     private static final class Bytes {
         byte[] data;
         int length;
+        @Nullable
+        private BitSet encoded;
+        private int numEncodedBytes;
 
         Bytes(int initialCapacity) {
             data = new byte[initialCapacity];
         }
 
@@ -477,10 +553,31 @@ public final class PathAndQuery {
 
         void add(byte b) {
             data[length++] = b;
         }
 
+        void addEncoded(byte b) {
+            if (encoded == null) {
+                encoded = new BitSet();
+            }
+            encoded.set(length);
+            data[length++] = b;
+            numEncodedBytes++;
+        }
+
+        boolean isEncoded(int index) {
+            return encoded != null && encoded.get(index);
+        }
+
+        boolean hasEncodedBytes() {
+            return encoded != null;
+        }
+
+        int numEncodedBytes() {
+            return numEncodedBytes;
+        }
+
         void ensure(int numBytes) {
             int newCapacity = length + numBytes;
             if (newCapacity <= data.length) {
                 return;
             }
@@ -529,44 +626,6 @@ public final class PathAndQuery {
             }
 
             return c1;
         }
     }
-
-    /**
-     * Reserved characters which require percent-encoding. These values are only used for constructing
-     * {@link #RAW_CHAR_TO_MARKER} and {@link #MARKER_TO_PERCENT_ENCODED_CHAR} mapping tables.
-     *
-     * @see <a href="https://datatracker.ietf.org/doc/html/rfc3986#section-2.2">RFC 3986, section 2.2</a>
-     */
-    private enum ReservedChar {
-        GEN_DELIM_01(':', "%3A", (byte) 0x01),
-        GEN_DELIM_02('/', "%2F", (byte) 0x02),
-        GEN_DELIM_03('?', "%3F", (byte) 0x03),
-        GEN_DELIM_04('#', "%23", (byte) 0x04),
-        GEN_DELIM_05('[', "%5B", (byte) 0x05),
-        GEN_DELIM_06(']', "%5D", (byte) 0x06),
-        GEN_DELIM_07('@', "%40", (byte) 0x07),
-
-        SUB_DELIM_01('!', "%21", (byte) 0x11),
-        SUB_DELIM_02('$', "%24", (byte) 0x12),
-        SUB_DELIM_03('&', "%26", (byte) 0x13),
-        SUB_DELIM_04('\'', "%27", (byte) 0x14),
-        SUB_DELIM_05('(', "%28", (byte) 0x15),
-        SUB_DELIM_06(')', "%29", (byte) 0x16),
-        SUB_DELIM_07('*', "%2A", (byte) 0x17),
-        SUB_DELIM_08('+', "%2B", (byte) 0x18),
-        SUB_DELIM_09(',', "%2C", (byte) 0x19),
-        SUB_DELIM_10(';', "%3B", (byte) 0x1A),
-        SUB_DELIM_11('=', "%3D", (byte) 0x1B);
-
-        private final int rawChar;
-        private final String percentEncodedChar;
-        private final byte marker;
-
-        ReservedChar(int rawChar, String percentEncodedChar, byte marker) {
-            this.rawChar = rawChar;
-            this.percentEncodedChar = percentEncodedChar;
-            this.marker = marker;
-        }
-    }
 }
diff --git a/core/src/main/java/com/linecorp/armeria/server/RoutingResultBuilder.java b/core/src/main/java/com/linecorp/armeria/server/RoutingResultBuilder.java
index 6b6a0f085..8bfccd8f4 100644
--- a/core/src/main/java/com/linecorp/armeria/server/RoutingResultBuilder.java
+++ b/core/src/main/java/com/linecorp/armeria/server/RoutingResultBuilder.java
@@ -94,11 +94,11 @@ public final class RoutingResultBuilder {
     /**
      * Adds an encoded path parameter, which will be decoded in UTF-8 automatically.
      */
     public RoutingResultBuilder rawParam(String name, String value) {
         pathParams().put(requireNonNull(name, "name"),
-                         ArmeriaHttpUtil.decodePath(requireNonNull(value, "value")));
+                         ArmeriaHttpUtil.decodePathParam(requireNonNull(value, "value")));
         return this;
     }
 
     /**
      * Sets the score.
diff --git a/core/src/test/java/com/linecorp/armeria/internal/common/ArmeriaHttpUtilTest.java b/core/src/test/java/com/linecorp/armeria/internal/common/ArmeriaHttpUtilTest.java
index 6da5dab1d..458610bc2 100644
--- a/core/src/test/java/com/linecorp/armeria/internal/common/ArmeriaHttpUtilTest.java
+++ b/core/src/test/java/com/linecorp/armeria/internal/common/ArmeriaHttpUtilTest.java
@@ -33,12 +33,15 @@ import java.net.URISyntaxException;
 import java.util.Arrays;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.function.BiConsumer;
+import java.util.function.Function;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 
 import com.google.common.collect.ImmutableList;
 
 import com.linecorp.armeria.common.Http1HeaderNaming;
 import com.linecorp.armeria.common.HttpHeaderNames;
@@ -87,25 +90,40 @@ class ArmeriaHttpUtilTest {
         assertThat(concatPaths("/a/", "")).isEqualTo("/a/");
         assertThat(concatPaths("/a", "?foo=bar")).isEqualTo("/a?foo=bar");
         assertThat(concatPaths("/a/", "?foo=bar")).isEqualTo("/a/?foo=bar");
     }
 
-    @Test
-    void testDecodePath() throws Exception {
+    @ParameterizedTest
+    @CsvSource({ "true", "false" })
+    void testDecodePath(boolean isPathParam) throws Exception {
+        final Function<String, String> decodeFunc;
+        if (isPathParam) {
+            decodeFunc = ArmeriaHttpUtil::decodePathParam;
+        } else {
+            decodeFunc = ArmeriaHttpUtil::decodePath;
+        }
+
         // Fast path
         final String pathThatDoesNotNeedDecode = "/foo_bar_baz";
-        assertThat(decodePath(pathThatDoesNotNeedDecode)).isSameAs(pathThatDoesNotNeedDecode);
+        assertThat(decodeFunc.apply(pathThatDoesNotNeedDecode)).isSameAs(pathThatDoesNotNeedDecode);
 
         // Slow path
-        assertThat(decodePath("/foo%20bar\u007fbaz")).isEqualTo("/foo bar\u007fbaz");
-        assertThat(decodePath("/%C2%A2")).isEqualTo("/¢"); // Valid UTF-8 sequence
-        assertThat(decodePath("/%20\u0080")).isEqualTo("/ �"); // Unallowed character
-        assertThat(decodePath("/%")).isEqualTo("/�"); // No digit
-        assertThat(decodePath("/%1")).isEqualTo("/�"); // Only a single digit
-        assertThat(decodePath("/%G0")).isEqualTo("/�"); // First digit is not hex.
-        assertThat(decodePath("/%0G")).isEqualTo("/�"); // Second digit is not hex.
-        assertThat(decodePath("/%C3%28")).isEqualTo("/�("); // Invalid UTF-8 sequence
+        assertThat(decodeFunc.apply("/foo%20bar\u007fbaz")).isEqualTo("/foo bar\u007fbaz");
+        assertThat(decodeFunc.apply("/%C2%A2")).isEqualTo("/¢"); // Valid UTF-8 sequence
+        assertThat(decodeFunc.apply("/%20\u0080")).isEqualTo("/ �"); // Unallowed character
+        assertThat(decodeFunc.apply("/%")).isEqualTo("/�"); // No digit
+        assertThat(decodeFunc.apply("/%1")).isEqualTo("/�"); // Only a single digit
+        assertThat(decodeFunc.apply("/%G0")).isEqualTo("/�"); // First digit is not hex.
+        assertThat(decodeFunc.apply("/%0G")).isEqualTo("/�"); // Second digit is not hex.
+        assertThat(decodeFunc.apply("/%C3%28")).isEqualTo("/�("); // Invalid UTF-8 sequence
+
+        // %2F (/) must be decoded only for path parameters.
+        if (isPathParam) {
+            assertThat(decodeFunc.apply("/%2F")).isEqualTo("//");
+        } else {
+            assertThat(decodeFunc.apply("/%2F")).isEqualTo("/%2F");
+        }
     }
 
     @Test
     void testParseDirectives() {
         final Map<String, String> values = new LinkedHashMap<>();
diff --git a/core/src/test/java/com/linecorp/armeria/internal/common/PathAndQueryTest.java b/core/src/test/java/com/linecorp/armeria/internal/common/PathAndQueryTest.java
index e6fbc5053..94f924d9a 100644
--- a/core/src/test/java/com/linecorp/armeria/internal/common/PathAndQueryTest.java
+++ b/core/src/test/java/com/linecorp/armeria/internal/common/PathAndQueryTest.java
@@ -15,167 +15,293 @@
  */
 package com.linecorp.armeria.internal.common;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
+import java.util.Set;
+
 import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import com.google.common.base.Ascii;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Maps;
+
+import com.linecorp.armeria.common.QueryParams;
+import com.linecorp.armeria.common.annotation.Nullable;
 
 class PathAndQueryTest {
+
+    private static final Logger logger = LoggerFactory.getLogger(PathAndQueryTest.class);
+
+    private static final Set<String> QUERY_SEPARATORS = ImmutableSet.of("&", ";");
+
+    private static final Set<String> BAD_DOUBLE_DOT_PATTERNS = ImmutableSet.of(
+            "..", "/..", "../", "/../",
+            "../foo", "/../foo",
+            "foo/..", "/foo/..",
+            "foo/../", "/foo/../",
+            "foo/../bar", "/foo/../bar",
+
+            // Dots escaped
+            ".%2e", "/.%2e", "%2E./", "/%2E./", ".%2E/", "/.%2E/",
+            "foo/.%2e", "/foo/.%2e",
+            "foo/%2E./", "/foo/%2E./",
+            "foo/%2E./bar", "/foo/%2E./bar",
+
+            // Slashes escaped
+            "%2f..", "..%2F", "/..%2F", "%2F../", "%2f..%2f",
+            "/foo%2f..", "/foo%2f../", "/foo/..%2f","/foo%2F..%2F",
+
+            // Dots and slashes escaped
+            ".%2E%2F"
+    );
+
+    private static final Set<String> GOOD_DOUBLE_DOT_PATTERNS = ImmutableSet.of(
+            "..a", "a..", "a..b",
+            "/..a", "/a..", "/a..b",
+            "..a/", "a../", "a..b/",
+            "/..a/", "/a../", "/a..b/"
+    );
+
     @Test
     void empty() {
-        final PathAndQuery res = PathAndQuery.parse(null);
+        final PathAndQuery res = parse(null);
         assertThat(res).isNotNull();
         assertThat(res.path()).isEqualTo("/");
         assertThat(res.query()).isNull();
 
-        final PathAndQuery res2 = PathAndQuery.parse("");
+        final PathAndQuery res2 = parse("");
         assertThat(res2).isNotNull();
         assertThat(res2.path()).isEqualTo("/");
         assertThat(res2.query()).isNull();
 
-        final PathAndQuery res3 = PathAndQuery.parse("?");
+        final PathAndQuery res3 = parse("?");
         assertThat(res3).isNotNull();
         assertThat(res3.path()).isEqualTo("/");
         assertThat(res3.query()).isEqualTo("");
     }
 
     @Test
     void relative() {
-        assertThat(PathAndQuery.parse("foo")).isNull();
+        assertThat(parse("foo")).isNull();
     }
 
     @Test
-    void doubleDots() {
-        assertThat(PathAndQuery.parse("/..")).isNull();
-        assertThat(PathAndQuery.parse("/../")).isNull();
-        assertThat(PathAndQuery.parse("/../foo")).isNull();
-        assertThat(PathAndQuery.parse("/foo/..")).isNull();
-        assertThat(PathAndQuery.parse("/foo/../")).isNull();
-        assertThat(PathAndQuery.parse("/foo/../bar")).isNull();
+    void doubleDotsInPath() {
+        BAD_DOUBLE_DOT_PATTERNS.forEach(pattern -> assertProhibited(pattern));
+        GOOD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
+            final String path = pattern.startsWith("/") ? pattern : '/' + pattern;
+            final PathAndQuery res = parse(path);
+            assertThat(res).as("Ensure %s is allowed.", path).isNotNull();
+            assertThat(res.path()).as("Ensure %s is parsed as-is.", path).isEqualTo(path);
+        });
+    }
 
-        // Escaped
-        assertThat(PathAndQuery.parse("/.%2e")).isNull();
-        assertThat(PathAndQuery.parse("/%2E./")).isNull();
-        assertThat(PathAndQuery.parse("/foo/.%2e")).isNull();
-        assertThat(PathAndQuery.parse("/foo/%2E./")).isNull();
+    @Test
+    void doubleDotsInFreeFormQuery() {
+        BAD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
+            assertProhibited("/?" + pattern);
+        });
+
+        GOOD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
+            assertQueryStringAllowed("/?" + pattern, pattern);
+        });
+    }
 
-        // Not the double dots we are looking for.
-        final PathAndQuery res = PathAndQuery.parse("/..a");
-        assertThat(res).isNotNull();
-        assertThat(res.path()).isEqualTo("/..a");
-        final PathAndQuery res2 = PathAndQuery.parse("/a..");
-        assertThat(res2).isNotNull();
-        assertThat(res2.path()).isEqualTo("/a..");
+    @Test
+    void doubleDotsInNameValueQuery() {
+        // Dots in a query param name.
+        BAD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
+            assertProhibited("/?" + pattern + "=foo");
+        });
+        GOOD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
+            assertQueryStringAllowed("/?" + pattern + "=foo");
+        });
+
+        // Dots in a query param value.
+        BAD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
+            assertProhibited("/?foo=" + pattern);
+        });
+        GOOD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
+            assertQueryStringAllowed("/?foo=" + pattern);
+        });
+
+        QUERY_SEPARATORS.forEach(qs -> {
+            // Dots in the second query param name.
+            BAD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
+                assertProhibited("/?a=b" + qs + pattern + "=c");
+            });
+            GOOD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
+                assertQueryStringAllowed("/?a=b" + qs + pattern + "=c");
+            });
+
+            // Dots in the second query param value.
+            BAD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
+                assertProhibited("/?a=b" + qs + "c=" + pattern);
+            });
+            GOOD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
+                assertQueryStringAllowed("/?a=b" + qs + "c=" + pattern);
+            });
+
+            // Dots in the name of the query param in the middle.
+            BAD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
+                assertProhibited("/?a=b" + qs + pattern + "=c" + qs + "d=e");
+            });
+            GOOD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
+                assertQueryStringAllowed("/?a=b" + qs + pattern + "=c" + qs + "d=e");
+            });
+
+            // Dots in the value of the query param in the middle.
+            BAD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
+                assertProhibited("/?a=b" + qs + "c=" + pattern + qs + "d=e");
+            });
+            GOOD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
+                assertQueryStringAllowed("/?a=b" + qs + "c=" + pattern + qs + "d=e");
+            });
+        });
+    }
+
+    /**
+     * {@link PathAndQuery} treats the first `=` in a query parameter as `/` internally to simplify
+     * the detection the logic. This test makes sure the `=` appeared later is not treated as `/`.
+     */
+    @Test
+    void dotsAndEqualsInNameValueQuery() {
+        QUERY_SEPARATORS.forEach(qs -> {
+            final PathAndQuery res = parse("/?a=..=" + qs + "b=..=");
+            assertThat(res).isNotNull();
+            assertThat(res.query()).isEqualTo("a=..=" + qs + "b=..=");
+            assertThat(QueryParams.fromQueryString(res.query(), true)).containsExactly(
+                    Maps.immutableEntry("a", "..="),
+                    Maps.immutableEntry("b", "..=")
+            );
+
+            final PathAndQuery res2 = parse("/?a==.." + qs + "b==..");
+            assertThat(res2).isNotNull();
+            assertThat(res2.query()).isEqualTo("a==.." + qs + "b==..");
+            assertThat(QueryParams.fromQueryString(res2.query(), true)).containsExactly(
+                    Maps.immutableEntry("a", "=.."),
+                    Maps.immutableEntry("b", "=..")
+            );
+
+            final PathAndQuery res3 = parse("/?a==..=" + qs + "b==..=");
+            assertThat(res3).isNotNull();
+            assertThat(res3.query()).isEqualTo("a==..=" + qs + "b==..=");
+            assertThat(QueryParams.fromQueryString(res3.query(), true)).containsExactly(
+                    Maps.immutableEntry("a", "=..="),
+                    Maps.immutableEntry("b", "=..=")
+            );
+        });
     }
 
     @Test
     void hexadecimal() {
-        assertThat(PathAndQuery.parse("/%")).isNull();
-        assertThat(PathAndQuery.parse("/%0")).isNull();
-        assertThat(PathAndQuery.parse("/%0X")).isNull();
-        assertThat(PathAndQuery.parse("/%X0")).isNull();
+        assertThat(parse("/%")).isNull();
+        assertThat(parse("/%0")).isNull();
+        assertThat(parse("/%0X")).isNull();
+        assertThat(parse("/%X0")).isNull();
     }
 
     @Test
     void controlChars() {
-        assertThat(PathAndQuery.parse("/\0")).isNull();
-        assertThat(PathAndQuery.parse("/a\nb")).isNull();
-        assertThat(PathAndQuery.parse("/a\u007fb")).isNull();
+        assertThat(parse("/\0")).isNull();
+        assertThat(parse("/a\nb")).isNull();
+        assertThat(parse("/a\u007fb")).isNull();
 
         // Escaped
-        assertThat(PathAndQuery.parse("/%00")).isNull();
-        assertThat(PathAndQuery.parse("/a%09b")).isNull();
-        assertThat(PathAndQuery.parse("/a%0ab")).isNull();
-        assertThat(PathAndQuery.parse("/a%0db")).isNull();
-        assertThat(PathAndQuery.parse("/a%7fb")).isNull();
+        assertThat(parse("/%00")).isNull();
+        assertThat(parse("/a%09b")).isNull();
+        assertThat(parse("/a%0ab")).isNull();
+        assertThat(parse("/a%0db")).isNull();
+        assertThat(parse("/a%7fb")).isNull();
 
         // With query string
-        assertThat(PathAndQuery.parse("/\0?c")).isNull();
-        assertThat(PathAndQuery.parse("/a\tb?c")).isNull();
-        assertThat(PathAndQuery.parse("/a\nb?c")).isNull();
-        assertThat(PathAndQuery.parse("/a\rb?c")).isNull();
-        assertThat(PathAndQuery.parse("/a\u007fb?c")).isNull();
+        assertThat(parse("/\0?c")).isNull();
+        assertThat(parse("/a\tb?c")).isNull();
+        assertThat(parse("/a\nb?c")).isNull();
+        assertThat(parse("/a\rb?c")).isNull();
+        assertThat(parse("/a\u007fb?c")).isNull();
 
         // With query string with control chars
-        assertThat(PathAndQuery.parse("/?\0")).isNull();
-        assertThat(PathAndQuery.parse("/?%00")).isNull();
-        assertThat(PathAndQuery.parse("/?a\u007fb")).isNull();
-        assertThat(PathAndQuery.parse("/?a%7Fb")).isNull();
+        assertThat(parse("/?\0")).isNull();
+        assertThat(parse("/?%00")).isNull();
+        assertThat(parse("/?a\u007fb")).isNull();
+        assertThat(parse("/?a%7Fb")).isNull();
         // However, 0x0A, 0x0D, 0x09 should be accepted in a query string.
-        assertThat(PathAndQuery.parse("/?a\tb").query()).isEqualTo("a%09b");
-        assertThat(PathAndQuery.parse("/?a\nb").query()).isEqualTo("a%0Ab");
-        assertThat(PathAndQuery.parse("/?a\rb").query()).isEqualTo("a%0Db");
-        assertThat(PathAndQuery.parse("/?a%09b").query()).isEqualTo("a%09b");
-        assertThat(PathAndQuery.parse("/?a%0Ab").query()).isEqualTo("a%0Ab");
-        assertThat(PathAndQuery.parse("/?a%0Db").query()).isEqualTo("a%0Db");
+        assertThat(parse("/?a\tb").query()).isEqualTo("a%09b");
+        assertThat(parse("/?a\nb").query()).isEqualTo("a%0Ab");
+        assertThat(parse("/?a\rb").query()).isEqualTo("a%0Db");
+        assertThat(parse("/?a%09b").query()).isEqualTo("a%09b");
+        assertThat(parse("/?a%0Ab").query()).isEqualTo("a%0Ab");
+        assertThat(parse("/?a%0Db").query()).isEqualTo("a%0Db");
     }
 
     @Test
     void percent() {
-        final PathAndQuery res = PathAndQuery.parse("/%25");
+        final PathAndQuery res = parse("/%25");
         assertThat(res).isNotNull();
         assertThat(res.path()).isEqualTo("/%25");
         assertThat(res.query()).isNull();
     }
 
     @Test
     void shouldNotDecodeSlash() {
-        final PathAndQuery res = PathAndQuery.parse("%2F?%2F");
+        final PathAndQuery res = parse("%2F?%2F");
         // Do not accept a relative path.
         assertThat(res).isNull();
-        final PathAndQuery res1 = PathAndQuery.parse("/%2F?%2F");
+        final PathAndQuery res1 = parse("/%2F?%2F");
         assertThat(res1).isNotNull();
         assertThat(res1.path()).isEqualTo("/%2F");
         assertThat(res1.query()).isEqualTo("%2F");
 
-        final PathAndQuery pathOnly = PathAndQuery.parse("/foo%2F");
+        final PathAndQuery pathOnly = parse("/foo%2F");
         assertThat(pathOnly).isNotNull();
         assertThat(pathOnly.path()).isEqualTo("/foo%2F");
         assertThat(pathOnly.query()).isNull();
 
-        final PathAndQuery queryOnly = PathAndQuery.parse("/?%2f=%2F");
+        final PathAndQuery queryOnly = parse("/?%2f=%2F");
         assertThat(queryOnly).isNotNull();
         assertThat(queryOnly.path()).isEqualTo("/");
         assertThat(queryOnly.query()).isEqualTo("%2F=%2F");
     }
 
     @Test
     void consecutiveSlashes() {
-        final PathAndQuery res = PathAndQuery.parse(
+        final PathAndQuery res = parse(
                 "/path//with///consecutive////slashes?/query//with///consecutive////slashes");
         assertThat(res).isNotNull();
         assertThat(res.path()).isEqualTo("/path/with/consecutive/slashes");
         assertThat(res.query()).isEqualTo("/query//with///consecutive////slashes");
 
         // Encoded slashes
-        final PathAndQuery res2 = PathAndQuery.parse(
+        final PathAndQuery res2 = parse(
                 "/path%2F/with/%2F/consecutive//%2F%2Fslashes?/query%2F/with/%2F/consecutive//%2F%2Fslashes");
         assertThat(res2).isNotNull();
         assertThat(res2.path()).isEqualTo("/path%2F/with/%2F/consecutive/%2F%2Fslashes");
         assertThat(res2.query()).isEqualTo("/query%2F/with/%2F/consecutive//%2F%2Fslashes");
     }
 
     @Test
     void colon() {
-        assertThat(PathAndQuery.parse("/:")).isNotNull();
-        assertThat(PathAndQuery.parse("/:/")).isNotNull();
-        assertThat(PathAndQuery.parse("/a/:")).isNotNull();
-        assertThat(PathAndQuery.parse("/a/:/")).isNotNull();
+        assertThat(parse("/:")).isNotNull();
+        assertThat(parse("/:/")).isNotNull();
+        assertThat(parse("/a/:")).isNotNull();
+        assertThat(parse("/a/:/")).isNotNull();
     }
 
     @Test
     void rawUnicode() {
         // 2- and 3-byte UTF-8
-        final PathAndQuery res1 = PathAndQuery.parse("/\u00A2?\u20AC"); // ¢ and €
+        final PathAndQuery res1 = parse("/\u00A2?\u20AC"); // ¢ and €
         assertThat(res1).isNotNull();
         assertThat(res1.path()).isEqualTo("/%C2%A2");
         assertThat(res1.query()).isEqualTo("%E2%82%AC");
 
         // 4-byte UTF-8
-        final PathAndQuery res2 = PathAndQuery.parse("/\uD800\uDF48"); // 𐍈
+        final PathAndQuery res2 = parse("/\uD800\uDF48"); // 𐍈
         assertThat(res2).isNotNull();
         assertThat(res2.path()).isEqualTo("/%F0%90%8D%88");
         assertThat(res2.query()).isNull();
 
         // 5- and 6-byte forms are only theoretically possible, so we won't test them here.
@@ -183,124 +309,157 @@ class PathAndQueryTest {
 
     @Test
     void encodedUnicode() {
         final String encodedPath = "/%ec%95%88";
         final String encodedQuery = "%eb%85%95";
-        final PathAndQuery res = PathAndQuery.parse(encodedPath + '?' + encodedQuery);
+        final PathAndQuery res = parse(encodedPath + '?' + encodedQuery);
         assertThat(res).isNotNull();
         assertThat(res.path()).isEqualTo(Ascii.toUpperCase(encodedPath));
         assertThat(res.query()).isEqualTo(Ascii.toUpperCase(encodedQuery));
     }
 
     @Test
     void noEncoding() {
-        final PathAndQuery res = PathAndQuery.parse("/a?b=c");
+        final PathAndQuery res = parse("/a?b=c");
         assertThat(res).isNotNull();
         assertThat(res.path()).isEqualTo("/a");
         assertThat(res.query()).isEqualTo("b=c");
     }
 
     @Test
     void space() {
-        final PathAndQuery res = PathAndQuery.parse("/ ? ");
+        final PathAndQuery res = parse("/ ? ");
         assertThat(res).isNotNull();
         assertThat(res.path()).isEqualTo("/%20");
         assertThat(res.query()).isEqualTo("+");
 
-        final PathAndQuery res2 = PathAndQuery.parse("/%20?%20");
+        final PathAndQuery res2 = parse("/%20?%20");
         assertThat(res2).isNotNull();
         assertThat(res2.path()).isEqualTo("/%20");
         assertThat(res2.query()).isEqualTo("+");
     }
 
     @Test
     void plus() {
-        final PathAndQuery res = PathAndQuery.parse("/+?a+b=c+d");
+        final PathAndQuery res = parse("/+?a+b=c+d");
         assertThat(res).isNotNull();
         assertThat(res.path()).isEqualTo("/+");
         assertThat(res.query()).isEqualTo("a+b=c+d");
 
-        final PathAndQuery res2 = PathAndQuery.parse("/%2b?a%2bb=c%2bd");
+        final PathAndQuery res2 = parse("/%2b?a%2bb=c%2bd");
         assertThat(res2).isNotNull();
         assertThat(res2.path()).isEqualTo("/+");
         assertThat(res2.query()).isEqualTo("a%2Bb=c%2Bd");
     }
 
     @Test
     void ampersand() {
-        final PathAndQuery res = PathAndQuery.parse("/&?a=1&a=2&b=3");
+        final PathAndQuery res = parse("/&?a=1&a=2&b=3");
         assertThat(res).isNotNull();
         assertThat(res.path()).isEqualTo("/&");
         assertThat(res.query()).isEqualTo("a=1&a=2&b=3");
 
         // '%26' in a query string should never be decoded into '&'.
-        final PathAndQuery res2 = PathAndQuery.parse("/%26?a=1%26a=2&b=3");
+        final PathAndQuery res2 = parse("/%26?a=1%26a=2&b=3");
         assertThat(res2).isNotNull();
         assertThat(res2.path()).isEqualTo("/&");
         assertThat(res2.query()).isEqualTo("a=1%26a=2&b=3");
     }
 
     @Test
     void semicolon() {
-        final PathAndQuery res = PathAndQuery.parse("/;?a=b;c=d");
+        final PathAndQuery res = parse("/;?a=b;c=d");
         assertThat(res).isNotNull();
         assertThat(res.path()).isEqualTo("/;");
         assertThat(res.query()).isEqualTo("a=b;c=d");
 
         // '%3B' in a query string should never be decoded into ';'.
-        final PathAndQuery res2 = PathAndQuery.parse("/%3b?a=b%3Bc=d");
+        final PathAndQuery res2 = parse("/%3b?a=b%3Bc=d");
         assertThat(res2).isNotNull();
         assertThat(res2.path()).isEqualTo("/;");
         assertThat(res2.query()).isEqualTo("a=b%3Bc=d");
     }
 
     @Test
     void equal() {
-        final PathAndQuery res = PathAndQuery.parse("/=?a=b=1");
+        final PathAndQuery res = parse("/=?a=b=1");
         assertThat(res).isNotNull();
         assertThat(res.path()).isEqualTo("/=");
         assertThat(res.query()).isEqualTo("a=b=1");
 
         // '%3D' in a query string should never be decoded into '='.
-        final PathAndQuery res2 = PathAndQuery.parse("/%3D?a%3db=1");
+        final PathAndQuery res2 = parse("/%3D?a%3db=1");
         assertThat(res2).isNotNull();
         assertThat(res2.path()).isEqualTo("/=");
         assertThat(res2.query()).isEqualTo("a%3Db=1");
     }
 
     @Test
     void sharp() {
-        final PathAndQuery res = PathAndQuery.parse("/#?a=b#1");
+        final PathAndQuery res = parse("/#?a=b#1");
         assertThat(res).isNotNull();
         assertThat(res.path()).isEqualTo("/#");
         assertThat(res.query()).isEqualTo("a=b#1");
 
         // '%23' in a query string should never be decoded into '#'.
-        final PathAndQuery res2 = PathAndQuery.parse("/%23?a=b%231");
+        final PathAndQuery res2 = parse("/%23?a=b%231");
         assertThat(res2).isNotNull();
         assertThat(res2.path()).isEqualTo("/#");
         assertThat(res2.query()).isEqualTo("a=b%231");
     }
 
     @Test
     void allReservedCharacters() {
-        final PathAndQuery res = PathAndQuery.parse("/#/:[]@!$&'()*+,;=?a=/#/:[]@!$&'()*+,;=");
+        final PathAndQuery res = parse("/#/:[]@!$&'()*+,;=?a=/#/:[]@!$&'()*+,;=");
         assertThat(res).isNotNull();
         assertThat(res.path()).isEqualTo("/#/:[]@!$&'()*+,;=");
         assertThat(res.query()).isEqualTo("a=/#/:[]@!$&'()*+,;=");
 
         final PathAndQuery res2 =
-                PathAndQuery.parse("/%23%2F%3A%5B%5D%40%21%24%26%27%28%29%2A%2B%2C%3B%3D%3F" +
-                                   "?a=%23%2F%3A%5B%5D%40%21%24%26%27%28%29%2A%2B%2C%3B%3D%3F");
+                parse("/%23%2F%3A%5B%5D%40%21%24%26%27%28%29%2A%2B%2C%3B%3D%3F" +
+                      "?a=%23%2F%3A%5B%5D%40%21%24%26%27%28%29%2A%2B%2C%3B%3D%3F");
         assertThat(res2).isNotNull();
         assertThat(res2.path()).isEqualTo("/#%2F:[]@!$&'()*+,;=?");
         assertThat(res2.query()).isEqualTo("a=%23%2F%3A%5B%5D%40%21%24%26%27%28%29%2A%2B%2C%3B%3D%3F");
     }
 
     @Test
     void doubleQuote() {
-        final PathAndQuery res = PathAndQuery.parse("/\"?\"");
+        final PathAndQuery res = parse("/\"?\"");
         assertThat(res).isNotNull();
         assertThat(res.path()).isEqualTo("/%22");
         assertThat(res.query()).isEqualTo("%22");
     }
+
+    private static void assertProhibited(String rawPath) {
+        assertThat(parse(rawPath))
+                .as("Ensure parse(\"%s\") returns null.", rawPath)
+                .isNull();
+    }
+
+    private static void assertQueryStringAllowed(String rawPath) {
+        assertThat(rawPath).startsWith("/?");
+        final String expectedQuery = rawPath.substring(2);
+        assertQueryStringAllowed(rawPath, expectedQuery);
+    }
+
+    private static void assertQueryStringAllowed(String rawPath, String expectedQuery) {
+        final PathAndQuery res = parse(rawPath);
+        assertThat(res)
+                .as("parse(\"%s\") must return non-null.", rawPath)
+                .isNotNull();
+        assertThat(res.query())
+                .as("parse(\"%s\").query() must return \"%s\".", rawPath, expectedQuery)
+                .isEqualTo(expectedQuery);
+    }
+
+    @Nullable
+    private static PathAndQuery parse(@Nullable String rawPath) {
+        final PathAndQuery res = PathAndQuery.parse(rawPath);
+        if (res != null) {
+            logger.info("parse({}) => path: {}, query: {}", rawPath, res.path(), res.query());
+        } else {
+            logger.info("parse({}) => null", rawPath);
+        }
+        return res;
+    }
 }
diff --git a/core/src/test/java/com/linecorp/armeria/server/RoutingResultTest.java b/core/src/test/java/com/linecorp/armeria/server/RoutingResultTest.java
index 4e6f5eb36..8b154b0cd 100644
--- a/core/src/test/java/com/linecorp/armeria/server/RoutingResultTest.java
+++ b/core/src/test/java/com/linecorp/armeria/server/RoutingResultTest.java
@@ -17,14 +17,15 @@
 package com.linecorp.armeria.server;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
 import java.net.URISyntaxException;
-import java.util.AbstractMap.SimpleEntry;
 
 import org.junit.jupiter.api.Test;
 
+import com.google.common.collect.Maps;
+
 import com.linecorp.armeria.common.MediaType;
 
 class RoutingResultTest {
 
     @Test
@@ -43,9 +44,18 @@ class RoutingResultTest {
                                                    .negotiatedResponseMediaType(MediaType.JSON_UTF_8)
                                                    .build();
         assertThat(routingResult.isPresent()).isTrue();
         assertThat(routingResult.path()).isEqualTo("/foo");
         assertThat(routingResult.query()).isEqualTo("bar=baz");
-        assertThat(routingResult.pathParams()).containsOnly(new SimpleEntry<>("qux", "quux"));
+        assertThat(routingResult.pathParams()).containsOnly(Maps.immutableEntry("qux", "quux"));
         assertThat(routingResult.negotiatedResponseMediaType()).isSameAs(MediaType.JSON_UTF_8);
     }
+
+    @Test
+    void percentEncodedPathParam() {
+        final RoutingResultBuilder builder = RoutingResult.builder();
+        final RoutingResult routingResult = builder.path("/foo")
+                                                   .rawParam("bar", "%62az%2Fqu%78")
+                                                   .build();
+        assertThat(routingResult.pathParams()).containsOnly(Maps.immutableEntry("bar", "baz/qux"));
+    }
 }

Vulnerable code:             // No need to decoded; not percent-encoded
                    buf[dstLen++] = (byte) ((digit1 << 4) | digit2);
    private static final int PERCENT_ENCODING_MARKER = 0xFF;
    private static final byte[] RAW_CHAR_TO_MARKER = new byte[256];
    private static final String[] MARKER_TO_PERCENT_ENCODED_CHAR = new String[256];
        for (final ReservedChar reservedChar : ReservedChar.values()) {
            RAW_CHAR_TO_MARKER[reservedChar.rawChar] = reservedChar.marker;
            MARKER_TO_PERCENT_ENCODED_CHAR[reservedChar.marker] = reservedChar.percentEncodedChar;
        return path + "?" + query;
        if (path.data[0] != '/') {
        if (pathContainsDoubleDots(path)) {
        return new PathAndQuery(encodeToPercents(path, true),
                                query != null ? encodeToPercents(query, false) : null);

                        // Insert a special mark so we can distinguish a raw character ('/') and
                        // percent-encoded character ('%2F') in a path string.
                        // We will encode this mark back into a percent-encoded character later.
                        final byte marker = RAW_CHAR_TO_MARKER['/'];
                        buf.ensure(2);
                        buf.add((byte) PERCENT_ENCODING_MARKER);
                        buf.add(marker);
                    final byte marker = RAW_CHAR_TO_MARKER[decoded];
                    if (marker != 0) {
                        // Insert a special mark so we can distinguish a raw character and percent-encoded
                        // character in a query string, such as '&' and '%26'.
                        // We will encode this mark back into a percent-encoded character later.
                        buf.ensure(2);
                        buf.add((byte) PERCENT_ENCODING_MARKER);
                        buf.add(marker);
                buf.add((byte) ' ');
                buf.add((byte) ((cp >>> 6) | 0b110_00000));
                buf.add((byte) (cp & 0b111111 | 0b10_000000));
                buf.add((byte) ((cp >>> 12) | 0b1110_0000));
                buf.add((byte) (((cp >>> 6) & 0b111111) | 0b10_000000));
                buf.add((byte) ((cp & 0b111111) | 0b10_000000));
                buf.add((byte) ((cp >>> 18) | 0b11110_000));
                buf.add((byte) (((cp >>> 12) & 0b111111) | 0b10_000000));
                buf.add((byte) (((cp >>> 6) & 0b111111) | 0b10_000000));
                buf.add((byte) ((cp & 0b111111) | 0b10_000000));
                buf.add((byte) ((cp >>> 24) | 0b111110_00));
                buf.add((byte) (((cp >>> 18) & 0b111111) | 0b10_000000));
                buf.add((byte) (((cp >>> 12) & 0b111111) | 0b10_000000));
                buf.add((byte) (((cp >>> 6) & 0b111111) | 0b10_000000));
                buf.add((byte) ((cp & 0b111111) | 0b10_000000));
                buf.add((byte) ((cp >>> 30) | 0b1111110_0));
                buf.add((byte) (((cp >>> 24) & 0b111111) | 0b10_000000));
                buf.add((byte) (((cp >>> 18) & 0b111111) | 0b10_000000));
                buf.add((byte) (((cp >>> 12) & 0b111111) | 0b10_000000));
                buf.add((byte) (((cp >>> 6) & 0b111111) | 0b10_000000));
                buf.add((byte) ((cp & 0b111111) | 0b10_000000));
            buf.add((byte) cp);
            if (b3 == '/' && b2 == '.' && b1 == '.' && b0 == '/') {
        return b0 == '/' && b1 == '.' && b2 == '.';
    private static String encodeToPercents(Bytes value, boolean isPath) {
        final BitSet allowedChars = isPath ? ALLOWED_PATH_CHARS : ALLOWED_QUERY_CHARS;
        final int length = value.length;
        boolean needsEncoding = false;
            if (!allowedChars.get(value.data[i] & 0xFF)) {
                needsEncoding = true;
                break;
        if (!needsEncoding) {
            return new String(value.data, 0, 0, length);
        final StringBuilder buf = new StringBuilder(length);
            if (b == PERCENT_ENCODING_MARKER && (i + 1) < length) {
                final int marker = value.data[i + 1] & 0xFF;
                final String percentEncodedChar = MARKER_TO_PERCENT_ENCODED_CHAR[marker];
                if (percentEncodedChar != null) {
                    buf.append(percentEncodedChar);
                    i++;
                    continue;
                }
            if (allowedChars.get(b)) {
                buf.append((char) b);
            } else if (b == ' ') {
                if (isPath) {
                    buf.append("%20");
                } else {
                    buf.append('+');
                }
            } else {
                buf.append('%');
                appendHexNibble(buf, b >>> 4);
                appendHexNibble(buf, b & 0xF);
            }
    private static void appendHexNibble(StringBuilder buf, int nibble) {
        if (nibble < 10) {
            buf.append((char) ('0' + nibble));
        } else {
            buf.append((char) ('A' + nibble - 10));

    /**
     * Reserved characters which require percent-encoding. These values are only used for constructing
     * {@link #RAW_CHAR_TO_MARKER} and {@link #MARKER_TO_PERCENT_ENCODED_CHAR} mapping tables.
     *
     * @see <a href="https://datatracker.ietf.org/doc/html/rfc3986#section-2.2">RFC 3986, section 2.2</a>
     */
    private enum ReservedChar {
        GEN_DELIM_01(':', "%3A", (byte) 0x01),
        GEN_DELIM_02('/', "%2F", (byte) 0x02),
        GEN_DELIM_03('?', "%3F", (byte) 0x03),
        GEN_DELIM_04('#', "%23", (byte) 0x04),
        GEN_DELIM_05('[', "%5B", (byte) 0x05),
        GEN_DELIM_06(']', "%5D", (byte) 0x06),
        GEN_DELIM_07('@', "%40", (byte) 0x07),

        SUB_DELIM_01('!', "%21", (byte) 0x11),
        SUB_DELIM_02('$', "%24", (byte) 0x12),
        SUB_DELIM_03('&', "%26", (byte) 0x13),
        SUB_DELIM_04('\'', "%27", (byte) 0x14),
        SUB_DELIM_05('(', "%28", (byte) 0x15),
        SUB_DELIM_06(')', "%29", (byte) 0x16),
        SUB_DELIM_07('*', "%2A", (byte) 0x17),
        SUB_DELIM_08('+', "%2B", (byte) 0x18),
        SUB_DELIM_09(',', "%2C", (byte) 0x19),
        SUB_DELIM_10(';', "%3B", (byte) 0x1A),
        SUB_DELIM_11('=', "%3D", (byte) 0x1B);

        private final int rawChar;
        private final String percentEncodedChar;
        private final byte marker;

        ReservedChar(int rawChar, String percentEncodedChar, byte marker) {
            this.rawChar = rawChar;
            this.percentEncodedChar = percentEncodedChar;
            this.marker = marker;
        }
    }
                         ArmeriaHttpUtil.decodePath(requireNonNull(value, "value")));
    @Test
    void testDecodePath() throws Exception {
        assertThat(decodePath(pathThatDoesNotNeedDecode)).isSameAs(pathThatDoesNotNeedDecode);
        assertThat(decodePath("/foo%20bar\u007fbaz")).isEqualTo("/foo bar\u007fbaz");
        assertThat(decodePath("/%C2%A2")).isEqualTo("/¢"); // Valid UTF-8 sequence
        assertThat(decodePath("/%20\u0080")).isEqualTo("/ �"); // Unallowed character
        assertThat(decodePath("/%")).isEqualTo("/�"); // No digit
        assertThat(decodePath("/%1")).isEqualTo("/�"); // Only a single digit
        assertThat(decodePath("/%G0")).isEqualTo("/�"); // First digit is not hex.
        assertThat(decodePath("/%0G")).isEqualTo("/�"); // Second digit is not hex.
        assertThat(decodePath("/%C3%28")).isEqualTo("/�("); // Invalid UTF-8 sequence
        final PathAndQuery res = PathAndQuery.parse(null);
        final PathAndQuery res2 = PathAndQuery.parse("");
        final PathAndQuery res3 = PathAndQuery.parse("?");
        assertThat(PathAndQuery.parse("foo")).isNull();
    void doubleDots() {
        assertThat(PathAndQuery.parse("/..")).isNull();
        assertThat(PathAndQuery.parse("/../")).isNull();
        assertThat(PathAndQuery.parse("/../foo")).isNull();
        assertThat(PathAndQuery.parse("/foo/..")).isNull();
        assertThat(PathAndQuery.parse("/foo/../")).isNull();
        assertThat(PathAndQuery.parse("/foo/../bar")).isNull();
        // Escaped
        assertThat(PathAndQuery.parse("/.%2e")).isNull();
        assertThat(PathAndQuery.parse("/%2E./")).isNull();
        assertThat(PathAndQuery.parse("/foo/.%2e")).isNull();
        assertThat(PathAndQuery.parse("/foo/%2E./")).isNull();
        // Not the double dots we are looking for.
        final PathAndQuery res = PathAndQuery.parse("/..a");
        assertThat(res).isNotNull();
        assertThat(res.path()).isEqualTo("/..a");
        final PathAndQuery res2 = PathAndQuery.parse("/a..");
        assertThat(res2).isNotNull();
        assertThat(res2.path()).isEqualTo("/a..");
        assertThat(PathAndQuery.parse("/%")).isNull();
        assertThat(PathAndQuery.parse("/%0")).isNull();
        assertThat(PathAndQuery.parse("/%0X")).isNull();
        assertThat(PathAndQuery.parse("/%X0")).isNull();
        assertThat(PathAndQuery.parse("/\0")).isNull();
        assertThat(PathAndQuery.parse("/a\nb")).isNull();
        assertThat(PathAndQuery.parse("/a\u007fb")).isNull();
        assertThat(PathAndQuery.parse("/%00")).isNull();
        assertThat(PathAndQuery.parse("/a%09b")).isNull();
        assertThat(PathAndQuery.parse("/a%0ab")).isNull();
        assertThat(PathAndQuery.parse("/a%0db")).isNull();
        assertThat(PathAndQuery.parse("/a%7fb")).isNull();
        assertThat(PathAndQuery.parse("/\0?c")).isNull();
        assertThat(PathAndQuery.parse("/a\tb?c")).isNull();
        assertThat(PathAndQuery.parse("/a\nb?c")).isNull();
        assertThat(PathAndQuery.parse("/a\rb?c")).isNull();
        assertThat(PathAndQuery.parse("/a\u007fb?c")).isNull();
        assertThat(PathAndQuery.parse("/?\0")).isNull();
        assertThat(PathAndQuery.parse("/?%00")).isNull();
        assertThat(PathAndQuery.parse("/?a\u007fb")).isNull();
        assertThat(PathAndQuery.parse("/?a%7Fb")).isNull();
        assertThat(PathAndQuery.parse("/?a\tb").query()).isEqualTo("a%09b");
        assertThat(PathAndQuery.parse("/?a\nb").query()).isEqualTo("a%0Ab");
        assertThat(PathAndQuery.parse("/?a\rb").query()).isEqualTo("a%0Db");
        assertThat(PathAndQuery.parse("/?a%09b").query()).isEqualTo("a%09b");
        assertThat(PathAndQuery.parse("/?a%0Ab").query()).isEqualTo("a%0Ab");
        assertThat(PathAndQuery.parse("/?a%0Db").query()).isEqualTo("a%0Db");
        final PathAndQuery res = PathAndQuery.parse("/%25");
        final PathAndQuery res = PathAndQuery.parse("%2F?%2F");
        final PathAndQuery res1 = PathAndQuery.parse("/%2F?%2F");
        final PathAndQuery pathOnly = PathAndQuery.parse("/foo%2F");
        final PathAndQuery queryOnly = PathAndQuery.parse("/?%2f=%2F");
        final PathAndQuery res = PathAndQuery.parse(
        final PathAndQuery res2 = PathAndQuery.parse(
        assertThat(PathAndQuery.parse("/:")).isNotNull();
        assertThat(PathAndQuery.parse("/:/")).isNotNull();
        assertThat(PathAndQuery.parse("/a/:")).isNotNull();
        assertThat(PathAndQuery.parse("/a/:/")).isNotNull();
        final PathAndQuery res1 = PathAndQuery.parse("/\u00A2?\u20AC"); // ¢ and €
        final PathAndQuery res2 = PathAndQuery.parse("/\uD800\uDF48"); // 𐍈
        final PathAndQuery res = PathAndQuery.parse(encodedPath + '?' + encodedQuery);
        final PathAndQuery res = PathAndQuery.parse("/a?b=c");
        final PathAndQuery res = PathAndQuery.parse("/ ? ");
        final PathAndQuery res2 = PathAndQuery.parse("/%20?%20");
        final PathAndQuery res = PathAndQuery.parse("/+?a+b=c+d");
        final PathAndQuery res2 = PathAndQuery.parse("/%2b?a%2bb=c%2bd");
        final PathAndQuery res = PathAndQuery.parse("/&?a=1&a=2&b=3");
        final PathAndQuery res2 = PathAndQuery.parse("/%26?a=1%26a=2&b=3");
        final PathAndQuery res = PathAndQuery.parse("/;?a=b;c=d");
        final PathAndQuery res2 = PathAndQuery.parse("/%3b?a=b%3Bc=d");
        final PathAndQuery res = PathAndQuery.parse("/=?a=b=1");
        final PathAndQuery res2 = PathAndQuery.parse("/%3D?a%3db=1");
        final PathAndQuery res = PathAndQuery.parse("/#?a=b#1");
        final PathAndQuery res2 = PathAndQuery.parse("/%23?a=b%231");
        final PathAndQuery res = PathAndQuery.parse("/#/:[]@!$&'()*+,;=?a=/#/:[]@!$&'()*+,;=");
                PathAndQuery.parse("/%23%2F%3A%5B%5D%40%21%24%26%27%28%29%2A%2B%2C%3B%3D%3F" +
                                   "?a=%23%2F%3A%5B%5D%40%21%24%26%27%28%29%2A%2B%2C%3B%3D%3F");
        final PathAndQuery res = PathAndQuery.parse("/\"?\"");
import java.util.AbstractMap.SimpleEntry;
        assertThat(routingResult.pathParams()).containsOnly(new SimpleEntry<>("qux", "quux"));
Fixed code:             // No need to decode because it's not percent-encoded
        // Decode percent-encoded characters, but don't decode %2F into /, so that a user can choose
        // to use it as a non-separator.
        //
        // For example, for the path pattern `/orgs/{org_name}/agents/{agent_name}`:
        // - orgs/mi6/agents/ethan-hunt
        //   - org_name: mi6
        //   - agent_name: ethan-hunt
        // - orgs/mi%2F6/agents/ethan-hunt
        //   - org_name: mi/6
        //   - agent_name: ethan-hunt
        return slowDecodePath(path, false);
    }

    /**
     * Decodes a single percent-encoded path parameter.
     */
    public static String decodePathParam(String pathParam) {
        if (pathParam.indexOf('%') < 0) {
            // No need to decode because it's not percent-encoded
            return pathParam;
        }

        return slowDecodePath(pathParam, true);
    }

    private static String slowDecodePath(String path, boolean decodeSlash) {
                    final byte decoded = (byte) ((digit1 << 4) | digit2);
                    if (decodeSlash || decoded != 0x2F) {
                        buf[dstLen++] = decoded;
                    } else {
                        buf[dstLen++] = '%';
                        buf[dstLen++] = '2';
                        buf[dstLen++] = (byte) path.charAt(i); // f or F - preserve the case.
                    }
    /**
     * The lookup table for the characters allowed in a path.
     */

    /**
     * The lookup table for the characters allowed in a query string.
     */
    /**
     * The lookup table for the reserved characters that require percent-encoding.
     */
    private static final BitSet RESERVED_CHARS = new BitSet();
    /**
     * The table that converts a byte into a percent-encoded chars, e.g. 'A' -> "%41".
     */
    private static final char[][] TO_PERCENT_ENCODED_CHARS = new char[256][];
        final String reservedChars = ":/?#[]@!$&'()*+,;=";
        for (int i = 0; i < reservedChars.length(); i++) {
            RESERVED_CHARS.set(reservedChars.charAt(i));
        }

        for (int i = 0; i < TO_PERCENT_ENCODED_CHARS.length; i++) {
            TO_PERCENT_ENCODED_CHARS[i] = String.format("%%%02X", i).toCharArray();
        return path + '?' + query;
        if (path.data[0] != '/' || path.isEncoded(0)) {
        if (pathContainsDoubleDots(path) || queryContainsDoubleDots(query)) {
        return new PathAndQuery(encodePathToPercents(path), encodeQueryToPercents(query));
                        buf.ensure(1);
                        buf.addEncoded((byte) '/');
                    if (RESERVED_CHARS.get(decoded)) {
                        buf.ensure(1);
                        buf.addEncoded((byte) decoded);
                buf.addEncoded((byte) ' ');
                buf.addEncoded((byte) ((cp >>> 6) | 0b110_00000));
                buf.addEncoded((byte) (cp & 0b111111 | 0b10_000000));
                buf.addEncoded((byte) ((cp >>> 12) | 0b1110_0000));
                buf.addEncoded((byte) (((cp >>> 6) & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) ((cp & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) ((cp >>> 18) | 0b11110_000));
                buf.addEncoded((byte) (((cp >>> 12) & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) (((cp >>> 6) & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) ((cp & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) ((cp >>> 24) | 0b111110_00));
                buf.addEncoded((byte) (((cp >>> 18) & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) (((cp >>> 12) & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) (((cp >>> 6) & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) ((cp & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) ((cp >>> 30) | 0b1111110_0));
                buf.addEncoded((byte) (((cp >>> 24) & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) (((cp >>> 18) & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) (((cp >>> 12) & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) (((cp >>> 6) & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) ((cp & 0b111111) | 0b10_000000));
            final BitSet allowedChars = isPath ? ALLOWED_PATH_CHARS : ALLOWED_QUERY_CHARS;
            if (allowedChars.get(cp)) {
                buf.add((byte) cp);
            } else {
                buf.addEncoded((byte) cp);
            }
            // Flag if the last four bytes are `/../`.
            if (b1 == '.' && b2 == '.' && isSlash(b0) && isSlash(b3)) {
        // Flag if the last three bytes are `/..`.
        return b1 == '.' && b2 == '.' && isSlash(b0);
    private static boolean queryContainsDoubleDots(@Nullable Bytes query) {
        if (query == null) {
            return false;
        }

        final int length = query.length;
        boolean lookingForEquals = true;
        byte b0 = 0;
        byte b1 = 0;
        byte b2 = '/';
            byte b3 = query.data[i];

            // Treat the delimiters as `/` so that we can use isSlash() for matching them.
            switch (b3) {
                case '=':
                    // Treat only the first `=` as `/`, e.g.
                    // - `foo=..` and `foo=../` should be flagged.
                    // - `foo=..=` shouldn't be flagged because `..=` is not a relative path.
                    if (lookingForEquals) {
                        lookingForEquals = false;
                        b3 = '/';
                    }
                    break;
                case '&':
                case ';':
                    b3 = '/';
                    lookingForEquals = true;
                    break;
            }

            // Flag if the last four bytes are `/../` or `/..&`
            if (b1 == '.' && b2 == '.' && isSlash(b0) && isSlash(b3)) {
                return true;

            b0 = b1;
            b1 = b2;
            b2 = b3;
        }

        return b1 == '.' && b2 == '.' && isSlash(b0);
    }

    private static boolean isSlash(byte b) {
        switch (b) {
            case '/':
            case '\\':
                return true;
            default:
                return false;
    }
    private static String encodePathToPercents(Bytes value) {
        if (!value.hasEncodedBytes()) {
            return new String(value.data, 0, 0, value.length);
        // Slow path: some percent-encoded chars.
        return slowEncodePathToPercents(value);
    }

    @Nullable
    private static String encodeQueryToPercents(@Nullable Bytes value) {
        if (value == null) {
            return null;
        }

        if (!value.hasEncodedBytes()) {
            // Deprecated, but it fits perfect for our use case.
            // noinspection deprecation
            return new String(value.data, 0, 0, value.length);
        }

        // Slow path: some percent-encoded chars.
        return slowEncodeQueryToPercents(value);
    }

    private static String slowEncodePathToPercents(Bytes value) {
        final int length = value.length;
        final StringBuilder buf = new StringBuilder(length + value.numEncodedBytes() * 2);
            if (value.isEncoded(i)) {
                buf.append(TO_PERCENT_ENCODED_CHARS[b]);
                continue;
            buf.append((char) b);
    private static String slowEncodeQueryToPercents(Bytes value) {
        final int length = value.length;
        final StringBuilder buf = new StringBuilder(length + value.numEncodedBytes() * 2);
        for (int i = 0; i < length; i++) {
            final int b = value.data[i] & 0xFF;

            if (value.isEncoded(i)) {
                if (b == ' ') {
                    buf.append('+');
                } else {
                    buf.append(TO_PERCENT_ENCODED_CHARS[b]);
                }
                continue;
            }

            buf.append((char) b);

        return buf.toString();
        @Nullable
        private BitSet encoded;
        private int numEncodedBytes;
        void addEncoded(byte b) {
            if (encoded == null) {
                encoded = new BitSet();
            }
            encoded.set(length);
            data[length++] = b;
            numEncodedBytes++;
        }

        boolean isEncoded(int index) {
            return encoded != null && encoded.get(index);
        }

        boolean hasEncodedBytes() {
            return encoded != null;
        }

        int numEncodedBytes() {
            return numEncodedBytes;
        }

                         ArmeriaHttpUtil.decodePathParam(requireNonNull(value, "value")));
import java.util.function.Function;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
    @ParameterizedTest
    @CsvSource({ "true", "false" })
    void testDecodePath(boolean isPathParam) throws Exception {
        final Function<String, String> decodeFunc;
        if (isPathParam) {
            decodeFunc = ArmeriaHttpUtil::decodePathParam;
        } else {
            decodeFunc = ArmeriaHttpUtil::decodePath;
        }

        assertThat(decodeFunc.apply(pathThatDoesNotNeedDecode)).isSameAs(pathThatDoesNotNeedDecode);
        assertThat(decodeFunc.apply("/foo%20bar\u007fbaz")).isEqualTo("/foo bar\u007fbaz");
        assertThat(decodeFunc.apply("/%C2%A2")).isEqualTo("/¢"); // Valid UTF-8 sequence
        assertThat(decodeFunc.apply("/%20\u0080")).isEqualTo("/ �"); // Unallowed character
        assertThat(decodeFunc.apply("/%")).isEqualTo("/�"); // No digit
        assertThat(decodeFunc.apply("/%1")).isEqualTo("/�"); // Only a single digit
        assertThat(decodeFunc.apply("/%G0")).isEqualTo("/�"); // First digit is not hex.
        assertThat(decodeFunc.apply("/%0G")).isEqualTo("/�"); // Second digit is not hex.
        assertThat(decodeFunc.apply("/%C3%28")).isEqualTo("/�("); // Invalid UTF-8 sequence

        // %2F (/) must be decoded only for path parameters.
        if (isPathParam) {
            assertThat(decodeFunc.apply("/%2F")).isEqualTo("//");
        } else {
            assertThat(decodeFunc.apply("/%2F")).isEqualTo("/%2F");
        }
import java.util.Set;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Maps;

import com.linecorp.armeria.common.QueryParams;
import com.linecorp.armeria.common.annotation.Nullable;

    private static final Logger logger = LoggerFactory.getLogger(PathAndQueryTest.class);

    private static final Set<String> QUERY_SEPARATORS = ImmutableSet.of("&", ";");

    private static final Set<String> BAD_DOUBLE_DOT_PATTERNS = ImmutableSet.of(
            "..", "/..", "../", "/../",
            "../foo", "/../foo",
            "foo/..", "/foo/..",
            "foo/../", "/foo/../",
            "foo/../bar", "/foo/../bar",

            // Dots escaped
            ".%2e", "/.%2e", "%2E./", "/%2E./", ".%2E/", "/.%2E/",
            "foo/.%2e", "/foo/.%2e",
            "foo/%2E./", "/foo/%2E./",
            "foo/%2E./bar", "/foo/%2E./bar",

            // Slashes escaped
            "%2f..", "..%2F", "/..%2F", "%2F../", "%2f..%2f",
            "/foo%2f..", "/foo%2f../", "/foo/..%2f","/foo%2F..%2F",

            // Dots and slashes escaped
            ".%2E%2F"
    );

    private static final Set<String> GOOD_DOUBLE_DOT_PATTERNS = ImmutableSet.of(
            "..a", "a..", "a..b",
            "/..a", "/a..", "/a..b",
            "..a/", "a../", "a..b/",
            "/..a/", "/a../", "/a..b/"
    );

        final PathAndQuery res = parse(null);
        final PathAndQuery res2 = parse("");
        final PathAndQuery res3 = parse("?");
        assertThat(parse("foo")).isNull();
    void doubleDotsInPath() {
        BAD_DOUBLE_DOT_PATTERNS.forEach(pattern -> assertProhibited(pattern));
        GOOD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
            final String path = pattern.startsWith("/") ? pattern : '/' + pattern;
            final PathAndQuery res = parse(path);
            assertThat(res).as("Ensure %s is allowed.", path).isNotNull();
            assertThat(res.path()).as("Ensure %s is parsed as-is.", path).isEqualTo(path);
        });
    }
    @Test
    void doubleDotsInFreeFormQuery() {
        BAD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
            assertProhibited("/?" + pattern);
        });

        GOOD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
            assertQueryStringAllowed("/?" + pattern, pattern);
        });
    }
    @Test
    void doubleDotsInNameValueQuery() {
        // Dots in a query param name.
        BAD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
            assertProhibited("/?" + pattern + "=foo");
        });
        GOOD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
            assertQueryStringAllowed("/?" + pattern + "=foo");
        });

        // Dots in a query param value.
        BAD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
            assertProhibited("/?foo=" + pattern);
        });
        GOOD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
            assertQueryStringAllowed("/?foo=" + pattern);
        });

        QUERY_SEPARATORS.forEach(qs -> {
            // Dots in the second query param name.
            BAD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
                assertProhibited("/?a=b" + qs + pattern + "=c");
            });
            GOOD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
                assertQueryStringAllowed("/?a=b" + qs + pattern + "=c");
            });

            // Dots in the second query param value.
            BAD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
                assertProhibited("/?a=b" + qs + "c=" + pattern);
            });
            GOOD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
                assertQueryStringAllowed("/?a=b" + qs + "c=" + pattern);
            });

            // Dots in the name of the query param in the middle.
            BAD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
                assertProhibited("/?a=b" + qs + pattern + "=c" + qs + "d=e");
            });
            GOOD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
                assertQueryStringAllowed("/?a=b" + qs + pattern + "=c" + qs + "d=e");
            });

            // Dots in the value of the query param in the middle.
            BAD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
                assertProhibited("/?a=b" + qs + "c=" + pattern + qs + "d=e");
            });
            GOOD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
                assertQueryStringAllowed("/?a=b" + qs + "c=" + pattern + qs + "d=e");
            });
        });
    }

    /**
     * {@link PathAndQuery} treats the first `=` in a query parameter as `/` internally to simplify
     * the detection the logic. This test makes sure the `=` appeared later is not treated as `/`.
     */
    @Test
    void dotsAndEqualsInNameValueQuery() {
        QUERY_SEPARATORS.forEach(qs -> {
            final PathAndQuery res = parse("/?a=..=" + qs + "b=..=");
            assertThat(res).isNotNull();
            assertThat(res.query()).isEqualTo("a=..=" + qs + "b=..=");
            assertThat(QueryParams.fromQueryString(res.query(), true)).containsExactly(
                    Maps.immutableEntry("a", "..="),
                    Maps.immutableEntry("b", "..=")
            );

            final PathAndQuery res2 = parse("/?a==.." + qs + "b==..");
            assertThat(res2).isNotNull();
            assertThat(res2.query()).isEqualTo("a==.." + qs + "b==..");
            assertThat(QueryParams.fromQueryString(res2.query(), true)).containsExactly(
                    Maps.immutableEntry("a", "=.."),
                    Maps.immutableEntry("b", "=..")
            );

            final PathAndQuery res3 = parse("/?a==..=" + qs + "b==..=");
            assertThat(res3).isNotNull();
            assertThat(res3.query()).isEqualTo("a==..=" + qs + "b==..=");
            assertThat(QueryParams.fromQueryString(res3.query(), true)).containsExactly(
                    Maps.immutableEntry("a", "=..="),
                    Maps.immutableEntry("b", "=..=")
            );
        });
        assertThat(parse("/%")).isNull();
        assertThat(parse("/%0")).isNull();
        assertThat(parse("/%0X")).isNull();
        assertThat(parse("/%X0")).isNull();
        assertThat(parse("/\0")).isNull();
        assertThat(parse("/a\nb")).isNull();
        assertThat(parse("/a\u007fb")).isNull();
        assertThat(parse("/%00")).isNull();
        assertThat(parse("/a%09b")).isNull();
        assertThat(parse("/a%0ab")).isNull();
        assertThat(parse("/a%0db")).isNull();
        assertThat(parse("/a%7fb")).isNull();
        assertThat(parse("/\0?c")).isNull();
        assertThat(parse("/a\tb?c")).isNull();
        assertThat(parse("/a\nb?c")).isNull();
        assertThat(parse("/a\rb?c")).isNull();
        assertThat(parse("/a\u007fb?c")).isNull();
        assertThat(parse("/?\0")).isNull();
        assertThat(parse("/?%00")).isNull();
        assertThat(parse("/?a\u007fb")).isNull();
        assertThat(parse("/?a%7Fb")).isNull();
        assertThat(parse("/?a\tb").query()).isEqualTo("a%09b");
        assertThat(parse("/?a\nb").query()).isEqualTo("a%0Ab");
        assertThat(parse("/?a\rb").query()).isEqualTo("a%0Db");
        assertThat(parse("/?a%09b").query()).isEqualTo("a%09b");
        assertThat(parse("/?a%0Ab").query()).isEqualTo("a%0Ab");
        assertThat(parse("/?a%0Db").query()).isEqualTo("a%0Db");
        final PathAndQuery res = parse("/%25");
        final PathAndQuery res = parse("%2F?%2F");
        final PathAndQuery res1 = parse("/%2F?%2F");
        final PathAndQuery pathOnly = parse("/foo%2F");
        final PathAndQuery queryOnly = parse("/?%2f=%2F");
        final PathAndQuery res = parse(
        final PathAndQuery res2 = parse(
        assertThat(parse("/:")).isNotNull();
        assertThat(parse("/:/")).isNotNull();
        assertThat(parse("/a/:")).isNotNull();
        assertThat(parse("/a/:/")).isNotNull();
        final PathAndQuery res1 = parse("/\u00A2?\u20AC"); // ¢ and €
        final PathAndQuery res2 = parse("/\uD800\uDF48"); // 𐍈
        final PathAndQuery res = parse(encodedPath + '?' + encodedQuery);
        final PathAndQuery res = parse("/a?b=c");
        final PathAndQuery res = parse("/ ? ");
        final PathAndQuery res2 = parse("/%20?%20");
        final PathAndQuery res = parse("/+?a+b=c+d");
        final PathAndQuery res2 = parse("/%2b?a%2bb=c%2bd");
        final PathAndQuery res = parse("/&?a=1&a=2&b=3");
        final PathAndQuery res2 = parse("/%26?a=1%26a=2&b=3");
        final PathAndQuery res = parse("/;?a=b;c=d");
        final PathAndQuery res2 = parse("/%3b?a=b%3Bc=d");
        final PathAndQuery res = parse("/=?a=b=1");
        final PathAndQuery res2 = parse("/%3D?a%3db=1");
        final PathAndQuery res = parse("/#?a=b#1");
        final PathAndQuery res2 = parse("/%23?a=b%231");
        final PathAndQuery res = parse("/#/:[]@!$&'()*+,;=?a=/#/:[]@!$&'()*+,;=");
                parse("/%23%2F%3A%5B%5D%40%21%24%26%27%28%29%2A%2B%2C%3B%3D%3F" +
                      "?a=%23%2F%3A%5B%5D%40%21%24%26%27%28%29%2A%2B%2C%3B%3D%3F");
        final PathAndQuery res = parse("/\"?\"");

    private static void assertProhibited(String rawPath) {
        assertThat(parse(rawPath))
                .as("Ensure parse(\"%s\") returns null.", rawPath)
                .isNull();
    }

    private static void assertQueryStringAllowed(String rawPath) {
        assertThat(rawPath).startsWith("/?");
        final String expectedQuery = rawPath.substring(2);
        assertQueryStringAllowed(rawPath, expectedQuery);
    }

    private static void assertQueryStringAllowed(String rawPath, String expectedQuery) {
        final PathAndQuery res = parse(rawPath);
        assertThat(res)
                .as("parse(\"%s\") must return non-null.", rawPath)
                .isNotNull();
        assertThat(res.query())
                .as("parse(\"%s\").query() must return \"%s\".", rawPath, expectedQuery)
                .isEqualTo(expectedQuery);
    }

    @Nullable
    private static PathAndQuery parse(@Nullable String rawPath) {
        final PathAndQuery res = PathAndQuery.parse(rawPath);
        if (res != null) {
            logger.info("parse({}) => path: {}, query: {}", rawPath, res.path(), res.query());
        } else {
            logger.info("parse({}) => null", rawPath);
        }
        return res;
    }
import com.google.common.collect.Maps;

        assertThat(routingResult.pathParams()).containsOnly(Maps.immutableEntry("qux", "quux"));

    @Test
    void percentEncodedPathParam() {
        final RoutingResultBuilder builder = RoutingResult.builder();
        final RoutingResult routingResult = builder.path("/foo")
                                                   .rawParam("bar", "%62az%2Fqu%78")
                                                   .build();
        assertThat(routingResult.pathParams()).containsOnly(Maps.immutableEntry("bar", "baz/qux"));
    }