CVE-2021-43795 (published 2021-12-02T18:15Z)

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.

Severity: MEDIUM
CVSS v2 base score: 5.0 (AV:N/AC:L/Au:N/C:P/I:N/A:N)
CVSS v3 base score: 7.5, HIGH (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N)

Weakness information:
cwe_id: CWE-22
cwe_name: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
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.
extended_description: 
            Many file operations are intended to take place within a restricted directory. By using special elements such as ".." and "/" separators, attackers can escape outside of the restricted location to access files or directories that are elsewhere on the system. One of the most common special elements is the "../" sequence, which in most modern operating systems is interpreted as the parent directory of the current location. This is referred to as relative path traversal. Path traversal also covers the use of absolute pathnames such as "/usr/local/bin" to access unexpected files. This is referred to as absolute path traversal.
         
         

Fix information:
repo_url: https://github.com/line/armeria
commit_details: repo_url: https://github.com/line/armeria
author: Trustin Lee
author_date: 2021-12-02 19:54:13+09:00
msg: 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.
repository: repo_url: https://github.com/line/armeria
repo_name: line/armeria
description: Your go-to microservice framework for any situation, from the creator of Netty et al. You can build any type of microservice leveraging your favorite technologies,  including gRPC, Thrift, Kotlin, Retrofit, Reactive Streams, Spring Boot and Dropwizard.
repo_language: Java
owner: line
file_changes: filename: ArmeriaHttpUtil.java
change_type: ModificationType.MODIFY
diff: @@ -304,11 +304,37 @@ public final class ArmeriaHttpUtil {
      */
     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()) {
@@ -335,7 +361,14 @@ public final class ArmeriaHttpUtil {
                     // 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.
+                    }
                 }
             }
 

programming_language: Java
filename: PathAndQuery.java
change_type: ModificationType.MODIFY
diff: @@ -44,13 +44,25 @@ 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 =
@@ -65,9 +77,13 @@ public final class PathAndQuery {
             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();
         }
     }
 
@@ -182,7 +198,7 @@ public final class PathAndQuery {
         if (query == null) {
             return path;
         }
-        return path + "?" + query;
+        return path + '?' + query;
     }
 
     @Nullable
@@ -213,18 +229,17 @@ public final class PathAndQuery {
             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
@@ -261,14 +276,8 @@ public final class PathAndQuery {
                         // 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)) {
@@ -279,14 +288,9 @@ public final class PathAndQuery {
                     }
                 } 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 == '/';
@@ -301,7 +305,7 @@ public final class PathAndQuery {
 
             if (cp == '+' && !isPath) {
                 buf.ensure(1);
-                buf.add((byte) ' ');
+                buf.addEncoded((byte) ' ');
                 wasSlash = false;
                 continue;
             }
@@ -316,38 +320,38 @@ public final class PathAndQuery {
 
             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;
@@ -380,8 +384,13 @@ public final class PathAndQuery {
                 // 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;
@@ -394,7 +403,8 @@ public final class PathAndQuery {
         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;
@@ -402,69 +412,135 @@ public final class PathAndQuery {
             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];
@@ -479,6 +555,27 @@ public final class PathAndQuery {
             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) {
@@ -531,42 +628,4 @@ 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;
-        }
-    }
 }

programming_language: Java
filename: RoutingResultBuilder.java
change_type: ModificationType.MODIFY
diff: @@ -96,7 +96,7 @@ public final class RoutingResultBuilder {
      */
     public RoutingResultBuilder rawParam(String name, String value) {
         pathParams().put(requireNonNull(name, "name"),
-                         ArmeriaHttpUtil.decodePath(requireNonNull(value, "value")));
+                         ArmeriaHttpUtil.decodePathParam(requireNonNull(value, "value")));
         return this;
     }
 

programming_language: Java
filename: ArmeriaHttpUtilTest.java
change_type: ModificationType.MODIFY
diff: @@ -35,8 +35,11 @@ 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;
 
@@ -89,21 +92,36 @@ class ArmeriaHttpUtilTest {
         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

programming_language: Java
filename: PathAndQueryTest.java
change_type: ModificationType.MODIFY
diff: @@ -17,24 +17,66 @@ 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("");
@@ -42,78 +84,162 @@ class PathAndQueryTest {
 
     @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();
@@ -121,20 +247,20 @@ class PathAndQueryTest {
 
     @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");
@@ -142,14 +268,14 @@ class PathAndQueryTest {
 
     @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");
@@ -158,22 +284,22 @@ class PathAndQueryTest {
 
     @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();
@@ -185,7 +311,7 @@ class PathAndQueryTest {
     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));
@@ -193,7 +319,7 @@ class PathAndQueryTest {
 
     @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");
@@ -201,12 +327,12 @@ class PathAndQueryTest {
 
     @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("+");
@@ -214,12 +340,12 @@ class PathAndQueryTest {
 
     @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");
@@ -227,13 +353,13 @@ class PathAndQueryTest {
 
     @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");
@@ -241,13 +367,13 @@ class PathAndQueryTest {
 
     @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");
@@ -255,13 +381,13 @@ class PathAndQueryTest {
 
     @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");
@@ -269,13 +395,13 @@ class PathAndQueryTest {
 
     @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");
@@ -283,14 +409,14 @@ class PathAndQueryTest {
 
     @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");
@@ -298,9 +424,42 @@ class PathAndQueryTest {
 
     @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;
+    }
 }

programming_language: Java
filename: RoutingResultTest.java
change_type: ModificationType.MODIFY
diff: @@ -19,10 +19,11 @@ 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 {
@@ -45,7 +46,16 @@ class RoutingResultTest {
         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"));
+    }
 }

programming_language: Java