VYPR
High severityNVD Advisory· Published Dec 2, 2021· Updated Aug 4, 2024

Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') in com.linecorp.armeria:armeria

CVE-2021-43795

Description

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.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
com.linecorp.armeria:armeriaMaven
>= 1.12.0, < 1.13.41.13.4

Affected products

1

Patches

1
e2697a575e9d

Merge pull request from GHSA-8fp4-rp6c-5gcv

https://github.com/line/armeriaTrustin LeeDec 2, 2021via ghsa
6 files changed · +501 222
  • core/src/main/java/com/linecorp/armeria/internal/common/ArmeriaHttpUtil.java+35 2 modified
    @@ -304,11 +304,37 @@ public static String concatPaths(@Nullable String path1, @Nullable String path2)
          */
         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 static String decodePath(String path) {
                         // 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.
    +                    }
                     }
                 }
     
    
  • core/src/main/java/com/linecorp/armeria/internal/common/PathAndQuery.java+184 125 modified
    @@ -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 String toString() {
             if (query == null) {
                 return path;
             }
    -        return path + "?" + query;
    +        return path + '?' + query;
         }
     
         @Nullable
    @@ -213,18 +229,17 @@ private static PathAndQuery splitPathAndQuery(@Nullable final String pathAndQuer
                 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 @@ private static Bytes decodePercentsAndEncodeToUtf8(String value, int start, int
                             // 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 @@ private static Bytes decodePercentsAndEncodeToUtf8(String value, int start, int
                         }
                     } 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 @@ private static Bytes decodePercentsAndEncodeToUtf8(String value, int start, int
     
                 if (cp == '+' && !isPath) {
                     buf.ensure(1);
    -                buf.add((byte) ' ');
    +                buf.addEncoded((byte) ' ');
                     wasSlash = false;
                     continue;
                 }
    @@ -316,38 +320,38 @@ private static Bytes decodePercentsAndEncodeToUtf8(String value, int start, int
     
                 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 @@ private static boolean appendOneByte(Bytes buf, int cp, boolean wasSlash, boolea
                     // 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,77 +403,144 @@ private static boolean pathContainsDoubleDots(Bytes path) {
             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];
    @@ -479,6 +555,27 @@ 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) {
    @@ -531,42 +628,4 @@ int nextCodePoint() {
                 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;
    -        }
    -    }
     }
    
  • core/src/main/java/com/linecorp/armeria/server/RoutingResultBuilder.java+1 1 modified
    @@ -96,7 +96,7 @@ public RoutingResultBuilder decodedParam(String name, String value) {
          */
         public RoutingResultBuilder rawParam(String name, String value) {
             pathParams().put(requireNonNull(name, "name"),
    -                         ArmeriaHttpUtil.decodePath(requireNonNull(value, "value")));
    +                         ArmeriaHttpUtil.decodePathParam(requireNonNull(value, "value")));
             return this;
         }
     
    
  • core/src/test/java/com/linecorp/armeria/internal/common/ArmeriaHttpUtilTest.java+29 11 modified
    @@ -35,8 +35,11 @@
     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 @@ void testConcatPaths() throws Exception {
             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
    
  • core/src/test/java/com/linecorp/armeria/internal/common/PathAndQueryTest.java+240 81 modified
    @@ -17,139 +17,265 @@
     
     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");
    @@ -158,22 +284,22 @@ void consecutiveSlashes() {
     
         @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,122 +311,155 @@ void rawUnicode() {
         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;
    +    }
     }
    
  • core/src/test/java/com/linecorp/armeria/server/RoutingResultTest.java+12 2 modified
    @@ -19,10 +19,11 @@
     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 @@ void routingResult() throws URISyntaxException {
             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"));
    +    }
     }
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.