diff --git a/core/src/main/java/com/linecorp/armeria/common/AbstractRequestContextBuilder.java b/core/src/main/java/com/linecorp/armeria/common/AbstractRequestContextBuilder.java
index 1d01dca8367..b9e21288ef1 100644
--- a/core/src/main/java/com/linecorp/armeria/common/AbstractRequestContextBuilder.java
+++ b/core/src/main/java/com/linecorp/armeria/common/AbstractRequestContextBuilder.java
@@ -142,13 +142,20 @@ protected AbstractRequestContextBuilder(boolean server, RpcRequest rpcReq, URI u
sessionProtocol = getSessionProtocol(uri);
if (server) {
- reqTarget = DefaultRequestTarget.createWithoutValidation(
- RequestTargetForm.ORIGIN, null, null,
- uri.getRawPath(), uri.getRawQuery(), null);
+ String path = uri.getRawPath();
+ final String query = uri.getRawQuery();
+ if (query != null) {
+ path += '?' + query;
+ }
+ final RequestTarget reqTarget = RequestTarget.forServer(path);
+ if (reqTarget == null) {
+ throw new IllegalArgumentException("invalid uri: " + uri);
+ }
+ this.reqTarget = reqTarget;
} else {
reqTarget = DefaultRequestTarget.createWithoutValidation(
RequestTargetForm.ORIGIN, null, null,
- uri.getRawPath(), uri.getRawQuery(), uri.getRawFragment());
+ uri.getRawPath(), uri.getRawPath(), uri.getRawQuery(), uri.getRawFragment());
}
}
diff --git a/core/src/main/java/com/linecorp/armeria/common/DefaultFlagsProvider.java b/core/src/main/java/com/linecorp/armeria/common/DefaultFlagsProvider.java
index 6947f86083d..f4873c4afbe 100644
--- a/core/src/main/java/com/linecorp/armeria/common/DefaultFlagsProvider.java
+++ b/core/src/main/java/com/linecorp/armeria/common/DefaultFlagsProvider.java
@@ -413,6 +413,11 @@ public Boolean allowDoubleDotsInQueryString() {
return false;
}
+ @Override
+ public Boolean allowSemicolonInPathComponent() {
+ return false;
+ }
+
@Override
public Path defaultMultipartUploadsLocation() {
return Paths.get(System.getProperty("java.io.tmpdir") +
diff --git a/core/src/main/java/com/linecorp/armeria/common/Flags.java b/core/src/main/java/com/linecorp/armeria/common/Flags.java
index d0591df12d5..b0d99054d70 100644
--- a/core/src/main/java/com/linecorp/armeria/common/Flags.java
+++ b/core/src/main/java/com/linecorp/armeria/common/Flags.java
@@ -375,6 +375,9 @@ private static boolean validateTransportType(TransportType transportType, String
private static final boolean ALLOW_DOUBLE_DOTS_IN_QUERY_STRING =
getValue(FlagsProvider::allowDoubleDotsInQueryString, "allowDoubleDotsInQueryString");
+ private static final boolean ALLOW_SEMICOLON_IN_PATH_COMPONENT =
+ getValue(FlagsProvider::allowSemicolonInPathComponent, "allowSemicolonInPathComponent");
+
private static final Path DEFAULT_MULTIPART_UPLOADS_LOCATION =
getValue(FlagsProvider::defaultMultipartUploadsLocation, "defaultMultipartUploadsLocation");
@@ -1340,6 +1343,27 @@ public static boolean allowDoubleDotsInQueryString() {
return ALLOW_DOUBLE_DOTS_IN_QUERY_STRING;
}
+ /**
+ * Returns whether to allow a semicolon ({@code ;}) in a request path component on the server-side.
+ * If disabled, the substring from the semicolon to before the next slash, commonly referred to as
+ * matrix variables, is removed. For example, {@code /foo;a=b/bar} will be converted to {@code /foo/bar}.
+ * Also, an exception is raised if a semicolon is used for binding a service. For example, the following
+ * code raises an exception:
+ *
{@code
+ * Server server =
+ * Server.builder()
+ * .service("/foo;bar", ...)
+ * .build();
+ * }
+ * Note that this flag has no effect on the client-side.
+ *
+ * This flag is disabled by default. Specify the
+ * {@code -Dcom.linecorp.armeria.allowSemicolonInPathComponent=true} JVM option to enable it.
+ */
+ public static boolean allowSemicolonInPathComponent() {
+ return ALLOW_SEMICOLON_IN_PATH_COMPONENT;
+ }
+
/**
* Returns the {@link Sampler} that determines whether to trace the stack trace of request contexts leaks
* and how frequently to keeps stack trace. A sampled exception will have the stack trace while the others
diff --git a/core/src/main/java/com/linecorp/armeria/common/FlagsProvider.java b/core/src/main/java/com/linecorp/armeria/common/FlagsProvider.java
index 5af62b43964..925d854a598 100644
--- a/core/src/main/java/com/linecorp/armeria/common/FlagsProvider.java
+++ b/core/src/main/java/com/linecorp/armeria/common/FlagsProvider.java
@@ -984,6 +984,28 @@ default Boolean allowDoubleDotsInQueryString() {
return null;
}
+ /**
+ * Returns whether to allow a semicolon ({@code ;}) in a request path component on the server-side.
+ * If disabled, the substring from the semicolon to before the next slash, commonly referred to as
+ * matrix variables, is removed. For example, {@code /foo;a=b/bar} will be converted to {@code /foo/bar}.
+ * Also, an exception is raised if a semicolon is used for binding a service. For example, the following
+ * code raises an exception:
+ *
{@code
+ * Server server =
+ * Server.builder()
+ * .service("/foo;bar", ...)
+ * .build();
+ * }
+ * Note that this flag has no effect on the client-side.
+ *
+ * This flag is disabled by default. Specify the
+ * {@code -Dcom.linecorp.armeria.allowSemicolonInPathComponent=true} JVM option to enable it.
+ */
+ @Nullable
+ default Boolean allowSemicolonInPathComponent() {
+ return null;
+ }
+
/**
* Returns the {@link Path} that is used to store the files uploaded from {@code multipart/form-data}
* requests.
diff --git a/core/src/main/java/com/linecorp/armeria/common/RequestTarget.java b/core/src/main/java/com/linecorp/armeria/common/RequestTarget.java
index 3dbd9d1be7b..f2d2332d7bc 100644
--- a/core/src/main/java/com/linecorp/armeria/common/RequestTarget.java
+++ b/core/src/main/java/com/linecorp/armeria/common/RequestTarget.java
@@ -44,7 +44,8 @@ public interface RequestTarget {
@Nullable
static RequestTarget forServer(String reqTarget) {
requireNonNull(reqTarget, "reqTarget");
- return DefaultRequestTarget.forServer(reqTarget, Flags.allowDoubleDotsInQueryString());
+ return DefaultRequestTarget.forServer(reqTarget, Flags.allowSemicolonInPathComponent(),
+ Flags.allowDoubleDotsInQueryString());
}
/**
@@ -102,6 +103,16 @@ static RequestTarget forClient(String reqTarget, @Nullable String prefix) {
*/
String path();
+ /**
+ * Returns the path of this {@link RequestTarget}, which always starts with {@code '/'}.
+ * Unlike {@link #path()}, the returned string contains matrix variables it the original request path
+ * contains them.
+ *
+ * @see
+ * Matrix Variables
+ */
+ String maybePathWithMatrixVariables();
+
/**
* Returns the query of this {@link RequestTarget}.
*/
diff --git a/core/src/main/java/com/linecorp/armeria/common/SystemPropertyFlagsProvider.java b/core/src/main/java/com/linecorp/armeria/common/SystemPropertyFlagsProvider.java
index 69874731a14..f6fe37c13e7 100644
--- a/core/src/main/java/com/linecorp/armeria/common/SystemPropertyFlagsProvider.java
+++ b/core/src/main/java/com/linecorp/armeria/common/SystemPropertyFlagsProvider.java
@@ -435,6 +435,11 @@ public Boolean allowDoubleDotsInQueryString() {
return getBoolean("allowDoubleDotsInQueryString");
}
+ @Override
+ public Boolean allowSemicolonInPathComponent() {
+ return getBoolean("allowSemicolonInPathComponent");
+ }
+
@Override
public Path defaultMultipartUploadsLocation() {
return getAndParse("defaultMultipartUploadsLocation", Paths::get);
diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/DefaultRequestTarget.java b/core/src/main/java/com/linecorp/armeria/internal/common/DefaultRequestTarget.java
index 3d1fb006c4d..1bbd4c61ee2 100644
--- a/core/src/main/java/com/linecorp/armeria/internal/common/DefaultRequestTarget.java
+++ b/core/src/main/java/com/linecorp/armeria/internal/common/DefaultRequestTarget.java
@@ -123,6 +123,7 @@ boolean mustPreserveEncoding(int cp) {
null,
null,
"*",
+ "*",
null,
null);
@@ -130,13 +131,14 @@ boolean mustPreserveEncoding(int cp) {
* The main implementation of {@link RequestTarget#forServer(String)}.
*/
@Nullable
- public static RequestTarget forServer(String reqTarget, boolean allowDoubleDotsInQueryString) {
+ public static RequestTarget forServer(String reqTarget, boolean allowSemicolonInPathComponent,
+ boolean allowDoubleDotsInQueryString) {
final RequestTarget cached = RequestTargetCache.getForServer(reqTarget);
if (cached != null) {
return cached;
}
- return slowForServer(reqTarget, allowDoubleDotsInQueryString);
+ return slowForServer(reqTarget, allowSemicolonInPathComponent, allowDoubleDotsInQueryString);
}
/**
@@ -183,8 +185,9 @@ public static RequestTarget forClient(String reqTarget, @Nullable String prefix)
*/
public static RequestTarget createWithoutValidation(
RequestTargetForm form, @Nullable String scheme, @Nullable String authority,
- String path, @Nullable String query, @Nullable String fragment) {
- return new DefaultRequestTarget(form, scheme, authority, path, query, fragment);
+ String path, String pathWithMatrixVariables, @Nullable String query, @Nullable String fragment) {
+ return new DefaultRequestTarget(
+ form, scheme, authority, path, pathWithMatrixVariables, query, fragment);
}
private final RequestTargetForm form;
@@ -193,6 +196,7 @@ public static RequestTarget createWithoutValidation(
@Nullable
private final String authority;
private final String path;
+ private final String maybePathWithMatrixVariables;
@Nullable
private final String query;
@Nullable
@@ -200,7 +204,8 @@ public static RequestTarget createWithoutValidation(
private boolean cached;
private DefaultRequestTarget(RequestTargetForm form, @Nullable String scheme, @Nullable String authority,
- String path, @Nullable String query, @Nullable String fragment) {
+ String path, String maybePathWithMatrixVariables,
+ @Nullable String query, @Nullable String fragment) {
assert (scheme != null && authority != null) ||
(scheme == null && authority == null) : "scheme: " + scheme + ", authority: " + authority;
@@ -209,6 +214,7 @@ private DefaultRequestTarget(RequestTargetForm form, @Nullable String scheme, @N
this.scheme = scheme;
this.authority = authority;
this.path = path;
+ this.maybePathWithMatrixVariables = maybePathWithMatrixVariables;
this.query = query;
this.fragment = fragment;
}
@@ -233,6 +239,11 @@ public String path() {
return path;
}
+ @Override
+ public String maybePathWithMatrixVariables() {
+ return maybePathWithMatrixVariables;
+ }
+
@Override
public String query() {
return query;
@@ -258,18 +269,6 @@ public void setCached() {
cached = true;
}
- /**
- * Returns a copy of this {@link RequestTarget} with its {@link #path()} overridden with
- * the specified {@code path}.
- */
- public RequestTarget withPath(String path) {
- if (this.path == path) {
- return this;
- }
-
- return new DefaultRequestTarget(form, scheme, authority, path, query, fragment);
- }
-
@Override
public boolean equals(@Nullable Object o) {
if (this == o) {
@@ -312,7 +311,8 @@ public String toString() {
}
@Nullable
- private static RequestTarget slowForServer(String reqTarget, boolean allowDoubleDotsInQueryString) {
+ private static RequestTarget slowForServer(String reqTarget, boolean allowSemicolonInPathComponent,
+ boolean allowDoubleDotsInQueryString) {
final Bytes path;
final Bytes query;
@@ -321,18 +321,18 @@ private static RequestTarget slowForServer(String reqTarget, boolean allowDouble
if (queryPos >= 0) {
if ((path = decodePercentsAndEncodeToUtf8(
reqTarget, 0, queryPos,
- ComponentType.SERVER_PATH, null)) == null) {
+ ComponentType.SERVER_PATH, null, allowSemicolonInPathComponent)) == null) {
return null;
}
if ((query = decodePercentsAndEncodeToUtf8(
reqTarget, queryPos + 1, reqTarget.length(),
- ComponentType.QUERY, EMPTY_BYTES)) == null) {
+ ComponentType.QUERY, EMPTY_BYTES, true)) == null) {
return null;
}
} else {
if ((path = decodePercentsAndEncodeToUtf8(
reqTarget, 0, reqTarget.length(),
- ComponentType.SERVER_PATH, null)) == null) {
+ ComponentType.SERVER_PATH, null, allowSemicolonInPathComponent)) == null) {
return null;
}
query = null;
@@ -356,14 +356,58 @@ private static RequestTarget slowForServer(String reqTarget, boolean allowDouble
return null;
}
+ final String encodedPath = encodePathToPercents(path);
+ final String matrixVariablesRemovedPath;
+ if (allowSemicolonInPathComponent) {
+ matrixVariablesRemovedPath = encodedPath;
+ } else {
+ matrixVariablesRemovedPath = removeMatrixVariables(encodedPath);
+ if (matrixVariablesRemovedPath == null) {
+ return null;
+ }
+ }
return new DefaultRequestTarget(RequestTargetForm.ORIGIN,
null,
null,
- encodePathToPercents(path),
+ matrixVariablesRemovedPath,
+ encodedPath,
encodeQueryToPercents(query),
null);
}
+ @Nullable
+ public static String removeMatrixVariables(String path) {
+ int semicolonIndex = path.indexOf(';');
+ if (semicolonIndex < 0) {
+ return path;
+ }
+ if (semicolonIndex == 0 || path.charAt(semicolonIndex - 1) == '/') {
+ // Invalid matrix variable e.g. /;a=b/foo
+ return null;
+ }
+ int subStringStartIndex = 0;
+ try (TemporaryThreadLocals threadLocals = TemporaryThreadLocals.acquire()) {
+ final StringBuilder sb = threadLocals.stringBuilder();
+ for (;;) {
+ sb.append(path, subStringStartIndex, semicolonIndex);
+ final int slashIndex = path.indexOf('/', semicolonIndex + 1);
+ if (slashIndex < 0) {
+ return sb.toString();
+ }
+ subStringStartIndex = slashIndex;
+ semicolonIndex = path.indexOf(';', subStringStartIndex + 1);
+ if (semicolonIndex < 0) {
+ sb.append(path, subStringStartIndex, path.length());
+ return sb.toString();
+ }
+ if (path.charAt(semicolonIndex - 1) == '/') {
+ // Invalid matrix variable e.g. /prefix/;a=b/foo
+ return null;
+ }
+ }
+ }
+ }
+
@Nullable
private static RequestTarget slowAbsoluteFormForClient(String reqTarget, int authorityPos) {
// Extract scheme and authority while looking for path.
@@ -396,7 +440,7 @@ private static RequestTarget slowAbsoluteFormForClient(String reqTarget, int aut
schemeAndAuthority.getScheme(),
schemeAndAuthority.getRawAuthority(),
"/",
- null,
+ "/", null,
null);
}
@@ -457,7 +501,7 @@ private static RequestTarget slowForClient(String reqTarget,
if (queryPos >= 0) {
if ((path = decodePercentsAndEncodeToUtf8(
reqTarget, pathPos, queryPos,
- ComponentType.CLIENT_PATH, SLASH_BYTES)) == null) {
+ ComponentType.CLIENT_PATH, SLASH_BYTES, true)) == null) {
return null;
}
@@ -465,19 +509,19 @@ private static RequestTarget slowForClient(String reqTarget,
// path?query#fragment
if ((query = decodePercentsAndEncodeToUtf8(
reqTarget, queryPos + 1, fragmentPos,
- ComponentType.QUERY, EMPTY_BYTES)) == null) {
+ ComponentType.QUERY, EMPTY_BYTES, true)) == null) {
return null;
}
if ((fragment = decodePercentsAndEncodeToUtf8(
reqTarget, fragmentPos + 1, reqTarget.length(),
- ComponentType.FRAGMENT, EMPTY_BYTES)) == null) {
+ ComponentType.FRAGMENT, EMPTY_BYTES, true)) == null) {
return null;
}
} else {
// path?query
if ((query = decodePercentsAndEncodeToUtf8(
reqTarget, queryPos + 1, reqTarget.length(),
- ComponentType.QUERY, EMPTY_BYTES)) == null) {
+ ComponentType.QUERY, EMPTY_BYTES, true)) == null) {
return null;
}
fragment = null;
@@ -487,20 +531,20 @@ private static RequestTarget slowForClient(String reqTarget,
// path#fragment
if ((path = decodePercentsAndEncodeToUtf8(
reqTarget, pathPos, fragmentPos,
- ComponentType.CLIENT_PATH, EMPTY_BYTES)) == null) {
+ ComponentType.CLIENT_PATH, EMPTY_BYTES, true)) == null) {
return null;
}
query = null;
if ((fragment = decodePercentsAndEncodeToUtf8(
reqTarget, fragmentPos + 1, reqTarget.length(),
- ComponentType.FRAGMENT, EMPTY_BYTES)) == null) {
+ ComponentType.FRAGMENT, EMPTY_BYTES, true)) == null) {
return null;
}
} else {
// path
if ((path = decodePercentsAndEncodeToUtf8(
reqTarget, pathPos, reqTarget.length(),
- ComponentType.CLIENT_PATH, EMPTY_BYTES)) == null) {
+ ComponentType.CLIENT_PATH, EMPTY_BYTES, true)) == null) {
return null;
}
query = null;
@@ -529,14 +573,14 @@ private static RequestTarget slowForClient(String reqTarget,
schemeAndAuthority.getScheme(),
schemeAndAuthority.getRawAuthority(),
encodedPath,
- encodedQuery,
+ encodedPath, encodedQuery,
encodedFragment);
} else {
return new DefaultRequestTarget(RequestTargetForm.ORIGIN,
null,
null,
encodedPath,
- encodedQuery,
+ encodedPath, encodedQuery,
encodedFragment);
}
}
@@ -577,7 +621,8 @@ private static boolean isRelativePath(Bytes path) {
@Nullable
private static Bytes decodePercentsAndEncodeToUtf8(String value, int start, int end,
- ComponentType type, @Nullable Bytes whenEmpty) {
+ ComponentType type, @Nullable Bytes whenEmpty,
+ boolean allowSemicolonInPathComponent) {
final int length = end - start;
if (length == 0) {
return whenEmpty;
@@ -605,7 +650,8 @@ private static Bytes decodePercentsAndEncodeToUtf8(String value, int start, int
}
final int decoded = (digit1 << 4) | digit2;
- if (type.mustPreserveEncoding(decoded)) {
+ if (type.mustPreserveEncoding(decoded) ||
+ (!allowSemicolonInPathComponent && decoded == ';')) {
buf.ensure(1);
buf.addEncoded((byte) decoded);
wasSlash = false;
diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/util/MappedPathUtil.java b/core/src/main/java/com/linecorp/armeria/internal/common/util/MappedPathUtil.java
new file mode 100644
index 00000000000..1f6c07d6303
--- /dev/null
+++ b/core/src/main/java/com/linecorp/armeria/internal/common/util/MappedPathUtil.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2023 LINE Corporation
+ *
+ * LINE Corporation licenses this file to you under the Apache License,
+ * version 2.0 (the "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at:
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.linecorp.armeria.internal.common.util;
+
+import com.linecorp.armeria.common.RequestTarget;
+import com.linecorp.armeria.common.annotation.Nullable;
+import com.linecorp.armeria.server.ServiceRequestContext;
+
+public final class MappedPathUtil {
+
+ /**
+ * Returns the path with its prefix removed. Unlike {@link ServiceRequestContext#mappedPath()}, this method
+ * returns the path with matrix variables if the mapped path contains matrix variables.
+ * This returns {@code null} if the path has matrix variables in the prefix.
+ */
+ @Nullable
+ public static String mappedPath(ServiceRequestContext ctx) {
+ final RequestTarget requestTarget = ctx.routingContext().requestTarget();
+ final String pathWithMatrixVariables = requestTarget.maybePathWithMatrixVariables();
+ if (pathWithMatrixVariables.equals(ctx.path())) {
+ return ctx.mappedPath();
+ }
+ // The request path contains matrix variables. e.g. "/foo/bar/users;name=alice"
+
+ final String prefix = ctx.path().substring(0, ctx.path().length() - ctx.mappedPath().length());
+ // The prefix is now `/foo/bar`
+ if (!pathWithMatrixVariables.startsWith(prefix)) {
+ // The request path has matrix variables in the wrong place. e.g. "/foo;name=alice/bar/users"
+ return null;
+ }
+ final String mappedPath = pathWithMatrixVariables.substring(prefix.length());
+ if (mappedPath.charAt(0) != '/') {
+ // Again, the request path has matrix variables in the wrong place. e.g. "/foo/bar;/users"
+ return null;
+ }
+ return mappedPath;
+ }
+
+ private MappedPathUtil() {}
+}
diff --git a/core/src/main/java/com/linecorp/armeria/server/ExactPathMapping.java b/core/src/main/java/com/linecorp/armeria/server/ExactPathMapping.java
index d9dcee4267b..fdffcbb6d71 100644
--- a/core/src/main/java/com/linecorp/armeria/server/ExactPathMapping.java
+++ b/core/src/main/java/com/linecorp/armeria/server/ExactPathMapping.java
@@ -16,6 +16,7 @@
package com.linecorp.armeria.server;
+import static com.google.common.base.Preconditions.checkArgument;
import static com.linecorp.armeria.internal.common.ArmeriaHttpUtil.concatPaths;
import static com.linecorp.armeria.internal.server.RouteUtil.ensureAbsolutePath;
@@ -25,6 +26,7 @@
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
+import com.linecorp.armeria.common.Flags;
import com.linecorp.armeria.common.annotation.Nullable;
final class ExactPathMapping extends AbstractPathMapping {
@@ -38,6 +40,10 @@ final class ExactPathMapping extends AbstractPathMapping {
}
private ExactPathMapping(String prefix, String exactPath) {
+ if (!Flags.allowSemicolonInPathComponent()) {
+ checkArgument(prefix.indexOf(';') < 0, "prefix: %s (expected not to have a ';')", prefix);
+ checkArgument(exactPath.indexOf(';') < 0, "exactPath: %s (expected not to have a ';')", exactPath);
+ }
this.prefix = prefix;
this.exactPath = ensureAbsolutePath(exactPath, "exactPath");
paths = ImmutableList.of(exactPath, exactPath);
diff --git a/core/src/main/java/com/linecorp/armeria/server/ParameterizedPathMapping.java b/core/src/main/java/com/linecorp/armeria/server/ParameterizedPathMapping.java
index 4ea9d8e52be..347cbe11320 100644
--- a/core/src/main/java/com/linecorp/armeria/server/ParameterizedPathMapping.java
+++ b/core/src/main/java/com/linecorp/armeria/server/ParameterizedPathMapping.java
@@ -16,6 +16,7 @@
package com.linecorp.armeria.server;
+import static com.google.common.base.Preconditions.checkArgument;
import static com.linecorp.armeria.internal.common.ArmeriaHttpUtil.concatPaths;
import static java.util.Objects.requireNonNull;
@@ -32,6 +33,7 @@
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
+import com.linecorp.armeria.common.Flags;
import com.linecorp.armeria.common.annotation.Nullable;
/**
@@ -105,6 +107,11 @@ final class ParameterizedPathMapping extends AbstractPathMapping {
}
private ParameterizedPathMapping(String prefix, String pathPattern) {
+ if (!Flags.allowSemicolonInPathComponent()) {
+ checkArgument(prefix.indexOf(';') < 0, "prefix: %s (expected not to have a ';')", prefix);
+ checkArgument(pathPattern.indexOf(';') < 0,
+ "pathPattern: %s (expected not to have a ';')", pathPattern);
+ }
this.prefix = prefix;
requireNonNull(pathPattern, "pathPattern");
diff --git a/core/src/main/java/com/linecorp/armeria/server/PrefixPathMapping.java b/core/src/main/java/com/linecorp/armeria/server/PrefixPathMapping.java
index d8f631a646c..65754d3b8a2 100644
--- a/core/src/main/java/com/linecorp/armeria/server/PrefixPathMapping.java
+++ b/core/src/main/java/com/linecorp/armeria/server/PrefixPathMapping.java
@@ -16,6 +16,7 @@
package com.linecorp.armeria.server;
+import static com.google.common.base.Preconditions.checkArgument;
import static com.linecorp.armeria.internal.common.ArmeriaHttpUtil.concatPaths;
import static com.linecorp.armeria.internal.server.RouteUtil.PREFIX;
import static com.linecorp.armeria.internal.server.RouteUtil.ensureAbsolutePath;
@@ -26,6 +27,7 @@
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
+import com.linecorp.armeria.common.Flags;
import com.linecorp.armeria.common.annotation.Nullable;
final class PrefixPathMapping extends AbstractPathMapping {
@@ -37,6 +39,8 @@ final class PrefixPathMapping extends AbstractPathMapping {
private final String strVal;
PrefixPathMapping(String prefix, boolean stripPrefix) {
+ checkArgument(Flags.allowSemicolonInPathComponent() || prefix.indexOf(';') < 0,
+ "prefix: %s (expected not to have a ';')", prefix);
prefix = ensureAbsolutePath(prefix, "prefix");
if (!prefix.endsWith("/")) {
prefix += '/';
diff --git a/core/src/main/java/com/linecorp/armeria/server/RoutingContext.java b/core/src/main/java/com/linecorp/armeria/server/RoutingContext.java
index eb08575a197..55207d01068 100644
--- a/core/src/main/java/com/linecorp/armeria/server/RoutingContext.java
+++ b/core/src/main/java/com/linecorp/armeria/server/RoutingContext.java
@@ -16,6 +16,7 @@
package com.linecorp.armeria.server;
+import static com.linecorp.armeria.internal.common.DefaultRequestTarget.removeMatrixVariables;
import static java.util.Objects.requireNonNull;
import java.util.List;
@@ -146,6 +147,7 @@ default RoutingContext withPath(String path) {
oldReqTarget.form(),
oldReqTarget.scheme(),
oldReqTarget.authority(),
+ removeMatrixVariables(path),
path,
oldReqTarget.query(),
oldReqTarget.fragment());
diff --git a/core/src/test/java/com/linecorp/armeria/internal/common/DefaultRequestTargetTest.java b/core/src/test/java/com/linecorp/armeria/internal/common/DefaultRequestTargetTest.java
index abfbb5a02f4..dc9aa7bed7e 100644
--- a/core/src/test/java/com/linecorp/armeria/internal/common/DefaultRequestTargetTest.java
+++ b/core/src/test/java/com/linecorp/armeria/internal/common/DefaultRequestTargetTest.java
@@ -16,9 +16,11 @@
package com.linecorp.armeria.internal.common;
import static com.google.common.base.Strings.emptyToNull;
+import static com.linecorp.armeria.internal.common.DefaultRequestTarget.removeMatrixVariables;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import java.net.URISyntaxException;
import java.util.Set;
import java.util.stream.Stream;
@@ -342,12 +344,14 @@ void shouldNormalizeAmpersand(Mode mode) {
assertAccepted(parse(mode, "/%26?a=1%26a=2&b=3"), "/&", "a=1%26a=2&b=3");
}
- @ParameterizedTest
- @EnumSource(Mode.class)
- void shouldNormalizeSemicolon(Mode mode) {
- assertAccepted(parse(mode, "/;?a=b;c=d"), "/;", "a=b;c=d");
- // '%3B' in a query string should never be decoded into ';'.
- assertAccepted(parse(mode, "/%3b?a=b%3Bc=d"), "/;", "a=b%3Bc=d");
+ @Test
+ void serverShouldRemoveMatrixVariablesWhenNotAllowed() {
+ // Not allowed
+ assertAccepted(forServer("/;a=b?c=d;e=f"), "/", "c=d;e=f");
+ // Allowed.
+ assertAccepted(forServer("/;a=b?c=d;e=f", true), "/;a=b", "c=d;e=f");
+ // '%3B' should never be decoded into ';'.
+ assertAccepted(forServer("/%3B?a=b%3Bc=d"), "/%3B", "a=b%3Bc=d");
}
@ParameterizedTest
@@ -359,12 +363,25 @@ void shouldNormalizeEqualSign(Mode mode) {
}
@Test
- void shouldReserveQuestionMark() {
+ void shouldReserveQuestionMark() throws URISyntaxException {
// '%3F' must not be decoded into '?'.
assertAccepted(forServer("/abc%3F.json?a=%3F"), "/abc%3F.json", "a=%3F");
assertAccepted(forClient("/abc%3F.json?a=%3F"), "/abc%3F.json", "a=%3F");
}
+ @Test
+ void reserveSemicolonWhenAllowed() {
+ // '%3B' is decoded into ';' when allowSemicolonInPathComponent is true.
+ assertAccepted(forServer("/abc%3B?a=%3B", true), "/abc;", "a=%3B");
+ assertAccepted(forServer("/abc%3B?a=%3B"), "/abc%3B", "a=%3B");
+
+ assertAccepted(forServer("/abc%3B", true), "/abc;");
+ assertAccepted(forServer("/abc%3B"), "/abc%3B");
+
+ // Client always decodes '%3B' into ';'.
+ assertAccepted(forClient("/abc%3B?a=%3B"), "/abc;", "a=%3B");
+ }
+
@Test
void serverShouldNormalizePoundSign() {
// '#' must be encoded into '%23'.
@@ -386,12 +403,12 @@ void clientShouldTreatPoundSignAsFragment() {
@Test
void serverShouldHandleReservedCharacters() {
- assertAccepted(forServer("/#/:@!$&'()*+,;=?a=/#/:[]@!$&'()*+,;="),
- "/%23/:@!$&'()*+,;=",
- "a=/%23/:[]@!$&'()*+,;=");
+ assertAccepted(forServer("/#/:@!$&'()*+,=?a=/#/:[]@!$&'()*+,="),
+ "/%23/:@!$&'()*+,=",
+ "a=/%23/:[]@!$&'()*+,=");
assertAccepted(forServer("/%23%2F%3A%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"),
- "/%23%2F:@!$&'()*+,;=%3F",
+ "/%23%2F:@!$&'()*+,%3B=%3F",
"a=%23%2F%3A%5B%5D%40%21%24%26%27%28%29%2A%2B%2C%3B%3D%3F");
}
@@ -418,9 +435,9 @@ void shouldHandleDoubleQuote(Mode mode) {
@ParameterizedTest
@EnumSource(Mode.class)
void shouldHandleSquareBracketsInPath(Mode mode) {
- assertAccepted(parse(mode, "/@/:[]!$&'()*+,;="), "/@/:%5B%5D!$&'()*+,;=");
- assertAccepted(parse(mode, "/%40%2F%3A%5B%5D%21%24%26%27%28%29%2A%2B%2C%3B%3D%3F"),
- "/@%2F:%5B%5D!$&'()*+,;=%3F");
+ assertAccepted(parse(mode, "/@/:[]!$&'()*+,="), "/@/:%5B%5D!$&'()*+,=");
+ assertAccepted(parse(mode, "/%40%2F%3A%5B%5D%21%24%26%27%28%29%2A%2B%2C%3D%3F"),
+ "/@%2F:%5B%5D!$&'()*+,=%3F");
}
@ParameterizedTest
@@ -496,6 +513,35 @@ void testToString(Mode mode) {
}
}
+ @Test
+ void testRemoveMatrixVariables() {
+ assertThat(removeMatrixVariables("/foo")).isEqualTo("/foo");
+ assertThat(removeMatrixVariables("/foo;")).isEqualTo("/foo");
+ assertThat(removeMatrixVariables("/foo/")).isEqualTo("/foo/");
+ assertThat(removeMatrixVariables("/foo/bar")).isEqualTo("/foo/bar");
+ assertThat(removeMatrixVariables("/foo/bar;")).isEqualTo("/foo/bar");
+ assertThat(removeMatrixVariables("/foo/bar/")).isEqualTo("/foo/bar/");
+ assertThat(removeMatrixVariables("/foo;/bar")).isEqualTo("/foo/bar");
+ assertThat(removeMatrixVariables("/foo;/bar;")).isEqualTo("/foo/bar");
+ assertThat(removeMatrixVariables("/foo;/bar/")).isEqualTo("/foo/bar/");
+ assertThat(removeMatrixVariables("/foo;a=b/bar")).isEqualTo("/foo/bar");
+ assertThat(removeMatrixVariables("/foo;a=b/bar;")).isEqualTo("/foo/bar");
+ assertThat(removeMatrixVariables("/foo;a=b/bar/")).isEqualTo("/foo/bar/");
+ assertThat(removeMatrixVariables("/foo;a=b/bar/baz")).isEqualTo("/foo/bar/baz");
+ assertThat(removeMatrixVariables("/foo;a=b/bar/baz;")).isEqualTo("/foo/bar/baz");
+ assertThat(removeMatrixVariables("/foo;a=b/bar/baz/")).isEqualTo("/foo/bar/baz/");
+ assertThat(removeMatrixVariables("/foo;a=b/bar;/baz")).isEqualTo("/foo/bar/baz");
+ assertThat(removeMatrixVariables("/foo;a=b/bar;/baz;")).isEqualTo("/foo/bar/baz");
+ assertThat(removeMatrixVariables("/foo;a=b/bar;/baz/")).isEqualTo("/foo/bar/baz/");
+ assertThat(removeMatrixVariables("/foo;a=b/bar;c=d/baz")).isEqualTo("/foo/bar/baz");
+ assertThat(removeMatrixVariables("/foo;a=b/bar;c=d/baz;")).isEqualTo("/foo/bar/baz");
+ assertThat(removeMatrixVariables("/foo;a=b/bar;c=d/baz/")).isEqualTo("/foo/bar/baz/");
+
+ // Invalid
+ assertThat(removeMatrixVariables("/;a=b")).isNull();
+ assertThat(removeMatrixVariables("/prefix/;a=b")).isNull();
+ }
+
private static void assertAccepted(@Nullable RequestTarget res, String expectedPath) {
assertAccepted(res, expectedPath, null, null);
}
@@ -538,8 +584,8 @@ private static RequestTarget forServer(String rawPath) {
}
@Nullable
- private static RequestTarget forServer(String rawPath, boolean allowDoubleDotsInQueryString) {
- final RequestTarget res = DefaultRequestTarget.forServer(rawPath, allowDoubleDotsInQueryString);
+ private static RequestTarget forServer(String rawPath, boolean allowSemicolonInPathComponent) {
+ final RequestTarget res = DefaultRequestTarget.forServer(rawPath, allowSemicolonInPathComponent, false);
if (res != null) {
logger.info("forServer({}) => path: {}, query: {}", rawPath, res.path(), res.query());
} else {
diff --git a/core/src/test/java/com/linecorp/armeria/server/MatrixVariablesTest.java b/core/src/test/java/com/linecorp/armeria/server/MatrixVariablesTest.java
new file mode 100644
index 00000000000..e2efcdd231b
--- /dev/null
+++ b/core/src/test/java/com/linecorp/armeria/server/MatrixVariablesTest.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2023 LINE Corporation
+ *
+ * LINE Corporation licenses this file to you under the Apache License,
+ * version 2.0 (the "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at:
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.linecorp.armeria.server;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import com.linecorp.armeria.common.AggregatedHttpResponse;
+import com.linecorp.armeria.common.HttpResponse;
+import com.linecorp.armeria.common.HttpStatus;
+import com.linecorp.armeria.testing.junit5.server.ServerExtension;
+import com.linecorp.armeria.testing.server.ServiceRequestContextCaptor;
+
+class MatrixVariablesTest {
+ @RegisterExtension
+ static final ServerExtension server = new ServerExtension() {
+ @Override
+ protected void configure(ServerBuilder sb) throws Exception {
+ sb.service("/foo", (ctx, req) -> HttpResponse.of(200));
+ }
+ };
+
+ @Test
+ void stripMatrixVariables() throws InterruptedException {
+ final AggregatedHttpResponse response = server.blockingWebClient().get("/foo;a=b");
+ assertThat(response.headers().status()).isSameAs(HttpStatus.OK);
+ final ServiceRequestContextCaptor captor = server.requestContextCaptor();
+ final ServiceRequestContext sctx = captor.poll();
+ assertThat(sctx.path()).isEqualTo("/foo");
+ assertThat(sctx.routingContext().requestTarget().maybePathWithMatrixVariables())
+ .isEqualTo("/foo;a=b");
+ }
+}
diff --git a/core/src/test/java/com/linecorp/armeria/server/RouteTest.java b/core/src/test/java/com/linecorp/armeria/server/RouteTest.java
index 1798e03cd0e..e50c78d00cd 100644
--- a/core/src/test/java/com/linecorp/armeria/server/RouteTest.java
+++ b/core/src/test/java/com/linecorp/armeria/server/RouteTest.java
@@ -176,6 +176,20 @@ void invalidRoutePath() {
assertThatThrownBy(() -> Route.builder().path("foo:/bar")).isInstanceOf(IllegalArgumentException.class);
}
+ @Test
+ void notAllowSemicolon() {
+ assertThatThrownBy(() -> Route.builder().path("/foo;")).isInstanceOf(
+ IllegalArgumentException.class);
+ assertThatThrownBy(() -> Route.builder().path("/foo/{bar};")).isInstanceOf(
+ IllegalArgumentException.class);
+ assertThatThrownBy(() -> Route.builder().path("/bar/:baz;")).isInstanceOf(
+ IllegalArgumentException.class);
+ assertThatThrownBy(() -> Route.builder().path("exact:/:foo/bar;")).isInstanceOf(
+ IllegalArgumentException.class);
+ assertThatThrownBy(() -> Route.builder().path("prefix:/bar/baz;")).isInstanceOf(
+ IllegalArgumentException.class);
+ }
+
@Test
void testHeader() {
final Route route = Route.builder()
diff --git a/it/spring/boot3-jetty11/build.gradle b/it/spring/boot3-jetty11/build.gradle
new file mode 100644
index 00000000000..552a270ba07
--- /dev/null
+++ b/it/spring/boot3-jetty11/build.gradle
@@ -0,0 +1,12 @@
+dependencies {
+ implementation project(':spring:boot3-starter')
+ implementation project(':spring:boot3-actuator-starter')
+ implementation project(':jetty11')
+ implementation libs.slf4j2.api
+ implementation libs.spring.boot3.starter.jetty
+ implementation(libs.spring.boot3.starter.web) {
+ exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat'
+ }
+ implementation libs.spring6.web
+ testImplementation libs.spring.boot3.starter.test
+}
diff --git a/it/spring/boot3-jetty11/src/main/java/com/linecorp/armeria/spring/jetty/ErrorHandlingController.java b/it/spring/boot3-jetty11/src/main/java/com/linecorp/armeria/spring/jetty/ErrorHandlingController.java
new file mode 100644
index 00000000000..458e555acc5
--- /dev/null
+++ b/it/spring/boot3-jetty11/src/main/java/com/linecorp/armeria/spring/jetty/ErrorHandlingController.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2021 LINE Corporation
+ *
+ * LINE Corporation licenses this file to you under the Apache License,
+ * version 2.0 (the "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at:
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.linecorp.armeria.spring.jetty;
+
+import java.util.Map;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.google.common.collect.ImmutableMap;
+
+@RestController
+@RequestMapping("/error-handling")
+public class ErrorHandlingController {
+
+ @GetMapping("/runtime-exception")
+ public void runtimeException() {
+ throw new RuntimeException("runtime exception");
+ }
+
+ @GetMapping("/custom-exception")
+ public void customException() {
+ throw new CustomException();
+ }
+
+ @GetMapping("/exception-handler")
+ public void exceptionHandler() {
+ throw new BaseException("exception handler");
+ }
+
+ @GetMapping("/global-exception-handler")
+ public void globalExceptionHandler() {
+ throw new GlobalBaseException("global exception handler");
+ }
+
+ @ResponseStatus(code = HttpStatus.NOT_FOUND, reason = "custom not found")
+ private static class CustomException extends RuntimeException {}
+
+ private static class BaseException extends RuntimeException {
+ BaseException(String message) {
+ super(message);
+ }
+ }
+
+ @ExceptionHandler(BaseException.class)
+ public ResponseEntity