Paths contain matrix variables bypass decorators
Description
Armeria is a microservice framework Spring supports Matrix variables. When Spring integration is used, Armeria calls Spring controllers via TomcatService or JettyService with the path that may contain matrix variables. Prior to version 1.24.3, the Armeria decorators might not invoked because of the matrix variables. If an attacker sends a specially crafted request, the request may bypass the authorizer. Version 1.24.3 contains a patch for this issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
com.linecorp.armeria:armeriaMaven | < 1.24.3 | 1.24.3 |
Affected products
1Patches
2039db50bbfc8Merge pull request from GHSA-wvp2-9ppw-337j
44 files changed · +1291 −72
core/src/main/java/com/linecorp/armeria/common/AbstractRequestContextBuilder.java+11 −4 modified@@ -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()); } }
core/src/main/java/com/linecorp/armeria/common/DefaultFlagsProvider.java+5 −0 modified@@ -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") +
core/src/main/java/com/linecorp/armeria/common/Flags.java+24 −0 modified@@ -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: + * <pre>{@code + * Server server = + * Server.builder() + * .service("/foo;bar", ...) + * .build(); + * }</pre> + * Note that this flag has no effect on the client-side. + * + * <p>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
core/src/main/java/com/linecorp/armeria/common/FlagsProvider.java+22 −0 modified@@ -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: + * <pre>{@code + * Server server = + * Server.builder() + * .service("/foo;bar", ...) + * .build(); + * }</pre> + * Note that this flag has no effect on the client-side. + * + * <p>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.
core/src/main/java/com/linecorp/armeria/common/RequestTarget.java+12 −1 modified@@ -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 <a href="https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-methods/matrix-variables.html"> + * Matrix Variables</a> + */ + String maybePathWithMatrixVariables(); + /** * Returns the query of this {@link RequestTarget}. */
core/src/main/java/com/linecorp/armeria/common/SystemPropertyFlagsProvider.java+5 −0 modified@@ -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);
core/src/main/java/com/linecorp/armeria/internal/common/DefaultRequestTarget.java+80 −34 modified@@ -123,20 +123,22 @@ boolean mustPreserveEncoding(int cp) { null, null, "*", + "*", null, null); /** * 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,14 +196,16 @@ public static RequestTarget createWithoutValidation( @Nullable private final String authority; private final String path; + private final String maybePathWithMatrixVariables; @Nullable private final String query; @Nullable private final String fragment; 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,27 +501,27 @@ 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; } if (fragmentPos >= 0) { // 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;
core/src/main/java/com/linecorp/armeria/internal/common/util/MappedPathUtil.java+54 −0 added@@ -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() {} +}
core/src/main/java/com/linecorp/armeria/server/ExactPathMapping.java+6 −0 modified@@ -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);
core/src/main/java/com/linecorp/armeria/server/ParameterizedPathMapping.java+7 −0 modified@@ -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");
core/src/main/java/com/linecorp/armeria/server/PrefixPathMapping.java+4 −0 modified@@ -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 += '/';
core/src/main/java/com/linecorp/armeria/server/RoutingContext.java+2 −0 modified@@ -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());
core/src/test/java/com/linecorp/armeria/internal/common/DefaultRequestTargetTest.java+62 −16 modified@@ -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 {
core/src/test/java/com/linecorp/armeria/server/MatrixVariablesTest.java+49 −0 added@@ -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"); + } +}
core/src/test/java/com/linecorp/armeria/server/RouteTest.java+14 −0 modified@@ -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()
it/spring/boot3-jetty11/build.gradle+12 −0 added@@ -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 +}
it/spring/boot3-jetty11/src/main/java/com/linecorp/armeria/spring/jetty/ErrorHandlingController.java+72 −0 added@@ -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<Map<String, Object>> onBaseException(Throwable t) { + final Map<String, Object> body = ImmutableMap.<String, Object>builder() + .put("status", HttpStatus.INTERNAL_SERVER_ERROR.value()) + .put("message", t.getMessage()) + .build(); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body); + } +}
it/spring/boot3-jetty11/src/main/java/com/linecorp/armeria/spring/jetty/GlobalBaseException.java+23 −0 added@@ -0,0 +1,23 @@ +/* + * 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; + +public class GlobalBaseException extends RuntimeException { + GlobalBaseException(String message) { + super(message); + } +}
it/spring/boot3-jetty11/src/main/java/com/linecorp/armeria/spring/jetty/GlobalExceptionHandler.java+38 −0 added@@ -0,0 +1,38 @@ +/* + * 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.RestControllerAdvice; + +import com.google.common.collect.ImmutableMap; + +@RestControllerAdvice +class GlobalExceptionHandler { + + @ExceptionHandler(GlobalBaseException.class) + public ResponseEntity<Map<String, Object>> onGlobalBaseException(Throwable t) { + final String message = t.getMessage(); + final Map<String, Object> body = ImmutableMap.of("status", HttpStatus.INTERNAL_SERVER_ERROR.value(), + "message", message); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body); + } +}
it/spring/boot3-jetty11/src/main/java/com/linecorp/armeria/spring/jetty/GreetingController.java+40 −0 added@@ -0,0 +1,40 @@ +/* + * Copyright 2018 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 org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/greeting") +public class GreetingController { + private static final String template = "Hello, %s!"; + + /** + * Greeting endpoint. + * @param name name to greet. + * @return response the ResponseEntity. + */ + @GetMapping + public ResponseEntity<Greeting> greetingSync( + @RequestParam(value = "name", defaultValue = "World") String name) { + return ResponseEntity.ok(new Greeting(String.format(template, name))); + } +}
it/spring/boot3-jetty11/src/main/java/com/linecorp/armeria/spring/jetty/Greeting.java+34 −0 added@@ -0,0 +1,34 @@ +/* + * Copyright 2018 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; + +public class Greeting { + + private final String content; + + /** + * Greeting model. + * @param content the content. + */ + public Greeting(String content) { + this.content = content; + } + + public String getContent() { + return content; + } +}
it/spring/boot3-jetty11/src/main/java/com/linecorp/armeria/spring/jetty/MatrixVariablesController.java+39 −0 added@@ -0,0 +1,39 @@ +/* + * 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.spring.jetty; + +import java.util.List; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.MatrixVariable; +import org.springframework.web.bind.annotation.RestController; + +import com.google.common.collect.ImmutableList; + +@RestController +public final class MatrixVariablesController { + + // GET /owners/42;q=11/pets/21;q=22 + // q1 = 11, q2 = 22 + + @GetMapping("/owners/{ownerId}/pets/{petId}") + List<Integer> findPet( + @MatrixVariable(name = "q", pathVar = "ownerId") int q1, + @MatrixVariable(name = "q", pathVar = "petId") int q2) { + return ImmutableList.of(q1, q2); + } +}
it/spring/boot3-jetty11/src/main/java/com/linecorp/armeria/spring/jetty/package-info.java+20 −0 added@@ -0,0 +1,20 @@ +/* + * Copyright 2018 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. + */ + +@NonNullByDefault +package com.linecorp.armeria.spring.jetty; + +import com.linecorp.armeria.common.annotation.NonNullByDefault;
it/spring/boot3-jetty11/src/main/java/com/linecorp/armeria/spring/jetty/SpringJettyApplication.java+85 −0 added@@ -0,0 +1,85 @@ +/* + * Copyright 2018 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 org.eclipse.jetty.server.Server; +import org.eclipse.jetty.util.Loader; +import org.eclipse.jetty.webapp.WebAppContext; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.web.context.WebServerApplicationContext; +import org.springframework.boot.web.embedded.jetty.JettyServerCustomizer; +import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory; +import org.springframework.boot.web.embedded.jetty.JettyWebServer; +import org.springframework.boot.web.server.WebServer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.linecorp.armeria.server.jetty.JettyService; +import com.linecorp.armeria.spring.ArmeriaServerConfigurator; + +import jakarta.servlet.Servlet; + +@SpringBootApplication +public class SpringJettyApplication { + + /** + * Bean to configure Armeria Jetty service. + */ + @Bean + public ArmeriaServerConfigurator armeriaTomcat(WebServerApplicationContext applicationContext) { + final WebServer webServer = applicationContext.getWebServer(); + if (webServer instanceof JettyWebServer) { + final Server jettyServer = ((JettyWebServer) webServer).getServer(); + + return serverBuilder -> serverBuilder.service("prefix:/jetty/api/rest/v1", + JettyService.of(jettyServer)); + } + return serverBuilder -> {}; + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass({ Servlet.class, Server.class, Loader.class, WebAppContext.class }) + static class EmbeddedJetty { + + @Bean + JettyServletWebServerFactory jettyServletWebServerFactory( + ObjectProvider<JettyServerCustomizer> serverCustomizers) { + final JettyServletWebServerFactory factory = new ArmeriaJettyServletWebServerFactory(); + factory.getServerCustomizers().addAll(serverCustomizers.orderedStream().toList()); + return factory; + } + } + + static final class ArmeriaJettyServletWebServerFactory extends JettyServletWebServerFactory { + + @Override + protected JettyWebServer getJettyWebServer(Server server) { + return new JettyWebServer(server, true); + } + } + + /** + * Main method. + * @param args program args. + */ + public static void main(String[] args) { + SpringApplication.run(SpringJettyApplication.class, args); + } +}
it/spring/boot3-jetty11/src/main/resources/application.properties+0 −0 addedit/spring/boot3-jetty11/src/main/resources/config/application.yml+7 −0 added@@ -0,0 +1,7 @@ +armeria: + ports: + - port: 0 + protocols: HTTP +server: + error: + include-message: always
it/spring/boot3-jetty11/src/test/java/com/linecorp/armeria/spring/jetty/ActuatorAutoConfigurationHealthGroupTest.java+98 −0 added@@ -0,0 +1,98 @@ +/* + * Copyright 2022 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 static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.autoconfigure.actuate.metrics.AutoConfigureMetrics; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalManagementPort; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.spring.LocalArmeriaPort; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@ActiveProfiles({ "local", "healthGroupTest" }) +@DirtiesContext +@AutoConfigureMetrics +@EnableAutoConfiguration +class ActuatorAutoConfigurationHealthGroupTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final TypeReference<Map<String, Object>> JSON_MAP = new TypeReference<>() {}; + + @LocalManagementPort + private int managementPort; + @LocalArmeriaPort + private int armeriaPort; + + private WebClient managementClient; + private WebClient armeriaClient; + + @BeforeEach + void setUp() { + managementClient = WebClient.builder("http://127.0.0.1:" + managementPort).build(); + armeriaClient = WebClient.builder("http://127.0.0.1:" + armeriaPort).build(); + } + + @Test + void testHealth() throws Exception { + final AggregatedHttpResponse res = managementClient.get("/internal/actuator/health").aggregate().join(); + assertUpStatus(res); + } + + @Test + void additionalPath() throws Exception { + String path = "/internal/actuator/health/foo"; + assertUpStatus(managementClient.get(path).aggregate().join()); + assertThat(armeriaClient.get(path).aggregate().join().status()).isSameAs(HttpStatus.NOT_FOUND); + + path = "/internal/actuator/health/bar"; + assertUpStatus(managementClient.get(path).aggregate().join()); + assertThat(armeriaClient.get(path).aggregate().join().status()).isSameAs(HttpStatus.NOT_FOUND); + + path = "/foohealth"; + assertUpStatus(managementClient.get(path).aggregate().join()); + assertThat(armeriaClient.get(path).aggregate().join().status()).isSameAs(HttpStatus.NOT_FOUND); + + // barhealth is bound to Armeria port. + path = "/barhealth"; + assertThat(managementClient.get(path).aggregate().join().status()).isSameAs(HttpStatus.NOT_FOUND); + assertUpStatus(armeriaClient.get(path).aggregate().join()); + } + + private static void assertUpStatus(AggregatedHttpResponse res) throws IOException { + assertThat(res.status()).isEqualTo(HttpStatus.OK); + assertThat(res.contentType().toString()).isEqualTo("application/vnd.spring-boot.actuator.v3+json"); + + final Map<String, Object> values = OBJECT_MAPPER.readValue(res.content().array(), JSON_MAP); + assertThat(values).containsEntry("status", "UP"); + } +}
it/spring/boot3-jetty11/src/test/java/com/linecorp/armeria/spring/jetty/ErrorHandlingTest.java+63 −0 added@@ -0,0 +1,63 @@ +/* + * 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 static com.linecorp.armeria.spring.jetty.MatrixVariablesTest.JETTY_BASE_PATH; +import static net.javacrumbs.jsonunit.fluent.JsonFluentAssert.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import com.linecorp.armeria.spring.LocalArmeriaPort; + +import jakarta.inject.Inject; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class ErrorHandlingTest { + + @LocalArmeriaPort + private int port; + + @Inject + private TestRestTemplate restTemplate; + + private static String jettyBaseUrlPath(int port) { + return "http://localhost:" + port + JETTY_BASE_PATH; + } + + @ParameterizedTest + @CsvSource({ + "/error-handling/runtime-exception, 500, jakarta.servlet.ServletException: " + + "Request processing failed: java.lang.RuntimeException: runtime exception", + "/error-handling/custom-exception, 404, custom not found", + "/error-handling/exception-handler, 500, exception handler", + "/error-handling/global-exception-handler, 500, global exception handler" + }) + void shouldReturnFormattedMessage(String path, int status, String message) throws Exception { + final ResponseEntity<String> response = + restTemplate.getForEntity(jettyBaseUrlPath(port) + path, String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.valueOf(status)); + assertThatJson(response.getBody()).node("status").isEqualTo(status); + assertThatJson(response.getBody()).node("message").isEqualTo(message); + } +}
it/spring/boot3-jetty11/src/test/java/com/linecorp/armeria/spring/jetty/MatrixVariablesTest.java+60 −0 added@@ -0,0 +1,60 @@ +/* + * 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.spring.jetty; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.test.context.ActiveProfiles; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.spring.LocalArmeriaPort; + +/** + * Integration test for <a href="https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-methods/matrix-variables.html">Matrix Variables</a>. + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@ActiveProfiles("testbed") +class MatrixVariablesTest { + + static final String JETTY_BASE_PATH = "/jetty/api/rest/v1"; + + @LocalArmeriaPort + int port; + + @Test + void matrixVariablesPreserved() throws Exception { + final WebClient client = WebClient.of("http://127.0.0.1:" + port); + final AggregatedHttpResponse response = client.blocking().get( + JETTY_BASE_PATH + "/owners/42;q=11/pets/21;q=22"); + assertThat(response.contentUtf8()).isEqualTo("[11,22]"); + } + + @Test + void wrongMatrixVariables() throws Exception { + final WebClient client = WebClient.of("http://127.0.0.1:" + port); + AggregatedHttpResponse response = client.blocking().get( + JETTY_BASE_PATH + ";/owners/42;q=11/pets/21;q=22"); + assertThat(response.status()).isSameAs(HttpStatus.BAD_REQUEST); + + response = client.blocking().get("/jetty;wrong=place/api/rest/v1/owners/42;q=11/pets/21;q=22"); + assertThat(response.status()).isSameAs(HttpStatus.BAD_REQUEST); + } +}
it/spring/boot3-jetty11/src/test/java/com/linecorp/armeria/spring/jetty/SpringJettyApplicationItTest.java+86 −0 added@@ -0,0 +1,86 @@ +/* + * Copyright 2018 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 static com.linecorp.armeria.spring.jetty.MatrixVariablesTest.JETTY_BASE_PATH; +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.ActiveProfiles; + +import com.linecorp.armeria.server.Server; +import com.linecorp.armeria.server.ServerPort; + +import jakarta.inject.Inject; + +@ActiveProfiles("testbed") +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class SpringJettyApplicationItTest { + @Inject + private ApplicationContext applicationContext; + @Inject + private Server server; + private int httpPort; + @Inject + private TestRestTemplate restTemplate; + @Inject + private GreetingController greetingController; + + @BeforeEach + public void init() throws Exception { + httpPort = server.activePorts() + .values() + .stream() + .filter(ServerPort::hasHttp) + .findAny() + .get() + .localAddress() + .getPort(); + } + + @Test + void contextLoads() { + assertThat(greetingController).isNotNull(); + } + + @Test + void greetingShouldReturnDefaultMessage() throws Exception { + assertThat(restTemplate.getForObject("http://localhost:" + httpPort + JETTY_BASE_PATH + "/greeting", + String.class)) + .contains("Hello, World!"); + } + + @Test + void greetingShouldReturnUsersMessage() throws Exception { + assertThat(restTemplate.getForObject("http://localhost:" + httpPort + + JETTY_BASE_PATH + "/greeting?name=Armeria", + String.class)) + .contains("Hello, Armeria!"); + } + + @Test + void greetingShouldReturn404() throws Exception { + assertThat(restTemplate.getForEntity("http://localhost:" + httpPort + JETTY_BASE_PATH + "/greet", + Void.class) + .getStatusCode().value()).isEqualByComparingTo(404); + } +}
it/spring/boot3-jetty11/src/test/resources/application-healthGroupTest.yml+23 −0 added@@ -0,0 +1,23 @@ +armeria: + ports: + - port: 0 + protocols: HTTP + +management: + server: + port: 0 + endpoints: + web: + exposure: + include: health, prometheus + base-path: /internal/actuator + endpoint: + health: + group: + foo: + include: ping + additional-path: "management:/foohealth" + bar: + include: ping + additional-path: "server:/barhealth" +
it/spring/boot3-jetty11/src/test/resources/application-testbed.yml+7 −0 added@@ -0,0 +1,7 @@ +# This currently doesn't work. See https://github.com/line/armeria/issues/5039 +server.port: -1 +--- +armeria: + ports: + - port: 0 + protocols: HTTP
it/spring/boot3-tomcat10/src/main/java/com/linecorp/armeria/spring/tomcat/MatrixVariablesController.java+39 −0 added@@ -0,0 +1,39 @@ +/* + * 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.spring.tomcat; + +import java.util.List; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.MatrixVariable; +import org.springframework.web.bind.annotation.RestController; + +import com.google.common.collect.ImmutableList; + +@RestController +public final class MatrixVariablesController { + + // GET /owners/42;q=11/pets/21;q=22 + // q1 = 11, q2 = 22 + + @GetMapping("/owners/{ownerId}/pets/{petId}") + List<Integer> findPet( + @MatrixVariable(name = "q", pathVar = "ownerId") int q1, + @MatrixVariable(name = "q", pathVar = "petId") int q2) { + return ImmutableList.of(q1, q2); + } +}
it/spring/boot3-tomcat10/src/test/java/com/linecorp/armeria/spring/tomcat/ActuatorAutoConfigurationHealthGroupTest.java+1 −2 modified@@ -46,8 +46,7 @@ class ActuatorAutoConfigurationHealthGroupTest { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private static final TypeReference<Map<String, Object>> JSON_MAP = - new TypeReference<Map<String, Object>>() {}; + private static final TypeReference<Map<String, Object>> JSON_MAP = new TypeReference<>() {}; @LocalManagementPort private int managementPort;
it/spring/boot3-tomcat10/src/test/java/com/linecorp/armeria/spring/tomcat/ErrorHandlingTest.java+1 −2 modified@@ -16,6 +16,7 @@ package com.linecorp.armeria.spring.tomcat; +import static com.linecorp.armeria.spring.tomcat.MatrixVariablesTest.TOMCAT_BASE_PATH; import static net.javacrumbs.jsonunit.fluent.JsonFluentAssert.assertThatJson; import static org.assertj.core.api.Assertions.assertThat; @@ -34,8 +35,6 @@ @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) class ErrorHandlingTest { - private static final String TOMCAT_BASE_PATH = "/tomcat/api/rest/v1"; - @LocalArmeriaPort private int port;
it/spring/boot3-tomcat10/src/test/java/com/linecorp/armeria/spring/tomcat/MatrixVariablesTest.java+60 −0 added@@ -0,0 +1,60 @@ +/* + * 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.spring.tomcat; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.test.context.ActiveProfiles; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.spring.LocalArmeriaPort; + +/** + * Integration test for <a href="https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-methods/matrix-variables.html">Matrix Variables</a>. + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@ActiveProfiles("testbed") +class MatrixVariablesTest { + + static final String TOMCAT_BASE_PATH = "/tomcat/api/rest/v1"; + + @LocalArmeriaPort + int port; + + @Test + void matrixVariablesPreserved() throws Exception { + final WebClient client = WebClient.of("http://127.0.0.1:" + port); + final AggregatedHttpResponse response = client.blocking().get( + TOMCAT_BASE_PATH + "/owners/42;q=11/pets/21;q=22"); + assertThat(response.contentUtf8()).isEqualTo("[11,22]"); + } + + @Test + void wrongMatrixVariables() throws Exception { + final WebClient client = WebClient.of("http://127.0.0.1:" + port); + AggregatedHttpResponse response = client.blocking().get( + TOMCAT_BASE_PATH + ";/owners/42;q=11/pets/21;q=22"); + assertThat(response.status()).isSameAs(HttpStatus.BAD_REQUEST); + + response = client.blocking().get("/tomcat;wrong=place/api/rest/v1/owners/42;q=11/pets/21;q=22"); + assertThat(response.status()).isSameAs(HttpStatus.BAD_REQUEST); + } +}
it/spring/boot3-tomcat10/src/test/resources/application-testbed.yml+7 −0 added@@ -0,0 +1,7 @@ +# Prevent the embedded Tomcat from opening a TCP/IP port. +server.port: -1 +--- +armeria: + ports: + - port: 0 + protocols: HTTP
jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/JettyService.java+22 −6 modified@@ -16,6 +16,7 @@ package com.linecorp.armeria.server.jetty; +import static com.linecorp.armeria.internal.common.util.MappedPathUtil.mappedPath; import static java.util.Objects.requireNonNull; import java.nio.ByteBuffer; @@ -58,7 +59,9 @@ import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.HttpResponseWriter; import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.MediaType; import com.linecorp.armeria.common.RequestHeaders; +import com.linecorp.armeria.common.RequestTarget; import com.linecorp.armeria.common.ResponseHeaders; import com.linecorp.armeria.common.ResponseHeadersBuilder; import com.linecorp.armeria.common.annotation.Nullable; @@ -211,6 +214,13 @@ void stop() { @Override public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) { + final String mappedPath = mappedPath(ctx); + if (mappedPath == null) { + return HttpResponse.of(HttpStatus.BAD_REQUEST, MediaType.PLAIN_TEXT_UTF_8, + "Invalid matrix variable: " + + ctx.routingContext().requestTarget().maybePathWithMatrixVariables()); + } + final ArmeriaConnector connector = this.connector; assert connector != null; @@ -242,7 +252,7 @@ public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) { }); final Request jReq = httpChannel.getRequest(); - fillRequest(ctx, aReq, jReq); + fillRequest(ctx, aReq, jReq, mappedPath); final SSLSession sslSession = ctx.sslSession(); final boolean needsReverseDnsLookup; if (sslSession != null) { @@ -284,11 +294,11 @@ public ExchangeType exchangeType(RoutingContext routingContext) { } private static void fillRequest( - ServiceRequestContext ctx, AggregatedHttpRequest aReq, Request jReq) { + ServiceRequestContext ctx, AggregatedHttpRequest aReq, Request jReq, String mappedPath) { DispatcherTypeUtil.setRequestType(jReq); jReq.setAsyncSupported(true, null); jReq.setSecure(ctx.sessionProtocol().isTls()); - jReq.setMetaData(toRequestMetadata(ctx, aReq)); + jReq.setMetaData(toRequestMetadata(ctx, aReq, mappedPath)); final HttpHeaders trailers = aReq.trailers(); if (!trailers.isEmpty()) { final HttpField[] httpFields = trailers.stream() @@ -298,17 +308,23 @@ private static void fillRequest( } } - private static MetaData.Request toRequestMetadata(ServiceRequestContext ctx, AggregatedHttpRequest aReq) { + private static MetaData.Request toRequestMetadata(ServiceRequestContext ctx, AggregatedHttpRequest aReq, + String mappedPath) { // Construct the HttpURI final StringBuilder uriBuf = new StringBuilder(); final RequestHeaders aHeaders = aReq.headers(); uriBuf.append(ctx.sessionProtocol().isTls() ? "https" : "http"); uriBuf.append("://"); uriBuf.append(aHeaders.authority()); - uriBuf.append(aHeaders.path()); - final HttpURI uri = HttpURI.build(HttpURI.build(uriBuf.toString()).path(ctx.mappedPath())) + final RequestTarget requestTarget = ctx.routingContext().requestTarget(); + if (requestTarget.query() != null) { + mappedPath = mappedPath + '?' + requestTarget.query(); + } + uriBuf.append(mappedPath); + + final HttpURI uri = HttpURI.build(HttpURI.build(uriBuf.toString())) .asImmutable(); final HttpField[] fields = aHeaders.stream().map(header -> { final AsciiString k = header.getKey();
jetty/jetty9/src/main/java/com/linecorp/armeria/server/jetty/JettyService.java+8 −0 modified@@ -17,6 +17,7 @@ package com.linecorp.armeria.server.jetty; import static com.linecorp.armeria.internal.common.ArmeriaHttpUtil.toHttp1Headers; +import static com.linecorp.armeria.internal.common.util.MappedPathUtil.mappedPath; import static java.util.Objects.requireNonNull; import java.lang.invoke.MethodHandle; @@ -54,6 +55,7 @@ import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.HttpResponseWriter; import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.MediaType; import com.linecorp.armeria.common.RequestHeaders; import com.linecorp.armeria.common.ResponseHeaders; import com.linecorp.armeria.common.ResponseHeadersBuilder; @@ -256,6 +258,12 @@ void stop() { @Override public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) { + final String mappedPath = mappedPath(ctx); + if (mappedPath == null) { + return HttpResponse.of(HttpStatus.BAD_REQUEST, MediaType.PLAIN_TEXT_UTF_8, + "Invalid matrix variable: " + + ctx.routingContext().requestTarget().maybePathWithMatrixVariables()); + } final ArmeriaConnector connector = this.connector; assert connector != null;
settings.gradle+1 −0 modified@@ -153,6 +153,7 @@ includeWithFlags ':it:nio', 'java', 'relocate includeWithFlags ':it:okhttp', 'java', 'relocate' includeWithFlags ':it:resilience4j', 'java17', 'relocate' includeWithFlags ':it:server', 'java', 'relocate' +includeWithFlags ':it:spring:boot3-jetty11', 'java17', 'relocate' includeWithFlags ':it:spring:boot3-kotlin', 'java17', 'relocate', 'kotlin' includeWithFlags ':it:spring:boot3-mixed', 'java17', 'relocate' includeWithFlags ':it:spring:boot3-mixed-tomcat10', 'java17', 'relocate'
spring/boot3-webflux-autoconfigure/build.gradle+1 −0 modified@@ -29,6 +29,7 @@ dependencies { testImplementation project(':spring:boot3-actuator-autoconfigure') testImplementation project(':thrift0.18') testImplementation libs.spring.boot3.starter.test + testImplementation libs.spring6.web // Added for sharing test suites with boot2 testImplementation libs.javax.inject }
spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpRequest.java+9 −3 modified@@ -41,6 +41,7 @@ import com.linecorp.armeria.common.HttpHeaderNames; import com.linecorp.armeria.common.HttpRequest; import com.linecorp.armeria.common.RequestHeaders; +import com.linecorp.armeria.common.RequestTarget; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.server.ServiceRequestContext; @@ -60,7 +61,7 @@ final class ArmeriaServerHttpRequest extends AbstractServerHttpRequest { ArmeriaServerHttpRequest(ServiceRequestContext ctx, HttpRequest req, DataBufferFactoryWrapper<?> factoryWrapper) { - super(uri(req), null, springHeaders(req.headers())); + super(uri(ctx, req), null, springHeaders(req.headers())); this.ctx = requireNonNull(ctx, "ctx"); this.req = req; @@ -76,13 +77,18 @@ private static HttpHeaders springHeaders(RequestHeaders headers) { return springHeaders; } - private static URI uri(HttpRequest req) { + private static URI uri(ServiceRequestContext ctx, HttpRequest req) { final String scheme = req.scheme(); final String authority = req.authority(); // Server side Armeria HTTP request always has the scheme and authority. assert scheme != null; assert authority != null; - return URI.create(scheme + "://" + authority + req.path()); + final RequestTarget requestTarget = ctx.routingContext().requestTarget(); + String path = requestTarget.maybePathWithMatrixVariables(); + if (requestTarget.query() != null) { + path = path + '?' + requestTarget.query(); + } + return URI.create(scheme + "://" + authority + path); } @Override
spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/MatrixVariablesTest.java+68 −0 added@@ -0,0 +1,68 @@ +/* + * 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.spring.web.reactive; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.MatrixVariable; +import org.springframework.web.bind.annotation.RestController; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; + +import reactor.core.publisher.Flux; + +/** + * Integration test for <a href="https://docs.spring.io/spring-framework/reference/web/webflux/controller/ann-methods/matrix-variables.html">Matrix Variables</a>. + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class MatrixVariablesTest { + + @SpringBootApplication + @Configuration + static class TestConfiguration { + @RestController + static class TestController { + + // GET /owners/42;q=11/pets/21;q=22 + // q1 = 11, q2 = 22 + + @GetMapping("/owners/{ownerId}/pets/{petId}") + Flux<Integer> findPet( + @MatrixVariable(name = "q", pathVar = "ownerId") int q1, + @MatrixVariable(name = "q", pathVar = "petId") int q2) { + return Flux.just(q1, q2); + } + } + } + + @LocalServerPort + int port; + + @Test + void foo() throws Exception { + final WebClient client = WebClient.of("http://127.0.0.1:" + port); + final AggregatedHttpResponse response = client.blocking().get("/owners/42;q=11/pets/21;q=22"); + assertThat(response.contentUtf8()).isEqualTo("[11,22]"); + } +}
tomcat10/src/main/java/com/linecorp/armeria/server/tomcat/TomcatService.java+10 −4 modified@@ -17,6 +17,7 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.linecorp.armeria.internal.common.ArmeriaHttpUtil.toHttp1Headers; +import static com.linecorp.armeria.internal.common.util.MappedPathUtil.mappedPath; import static java.util.Objects.requireNonNull; import java.io.File; @@ -378,6 +379,13 @@ public final HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) thro return HttpResponse.of(HttpStatus.SERVICE_UNAVAILABLE); } + final String mappedPath = mappedPath(ctx); + if (mappedPath == null) { + return HttpResponse.of(HttpStatus.BAD_REQUEST, MediaType.PLAIN_TEXT_UTF_8, + "Invalid matrix variable: " + + ctx.routingContext().requestTarget().maybePathWithMatrixVariables()); + } + final HttpResponseWriter res = HttpResponse.streaming(); req.aggregate().handle((aReq, cause) -> { try { @@ -396,7 +404,7 @@ public final HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) thro } final ArmeriaProcessor processor = createProcessor(coyoteAdapter); - final Request coyoteReq = convertRequest(ctx, aReq, processor.getRequest()); + final Request coyoteReq = convertRequest(ctx, mappedPath, aReq, processor.getRequest()); if (coyoteReq == null) { if (res.tryWrite(INVALID_AUTHORITY_HEADERS)) { if (res.tryWrite(INVALID_AUTHORITY_DATA)) { @@ -471,10 +479,8 @@ private static ArmeriaProcessor createProcessor(Adapter coyoteAdapter) throws Th } @Nullable - private Request convertRequest(ServiceRequestContext ctx, AggregatedHttpRequest req, + private Request convertRequest(ServiceRequestContext ctx, String mappedPath, AggregatedHttpRequest req, Request coyoteReq) throws Throwable { - final String mappedPath = ctx.mappedPath(); - coyoteReq.scheme().setString(req.scheme()); // Set the start time which is used by Tomcat access logging
49e04ef231adMerge pull request from GHSA-wvp2-9ppw-337j
44 files changed · +1291 −72
core/src/main/java/com/linecorp/armeria/common/AbstractRequestContextBuilder.java+11 −4 modified@@ -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()); } }
core/src/main/java/com/linecorp/armeria/common/DefaultFlagsProvider.java+5 −0 modified@@ -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") +
core/src/main/java/com/linecorp/armeria/common/Flags.java+24 −0 modified@@ -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: + * <pre>{@code + * Server server = + * Server.builder() + * .service("/foo;bar", ...) + * .build(); + * }</pre> + * Note that this flag has no effect on the client-side. + * + * <p>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
core/src/main/java/com/linecorp/armeria/common/FlagsProvider.java+22 −0 modified@@ -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: + * <pre>{@code + * Server server = + * Server.builder() + * .service("/foo;bar", ...) + * .build(); + * }</pre> + * Note that this flag has no effect on the client-side. + * + * <p>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.
core/src/main/java/com/linecorp/armeria/common/RequestTarget.java+12 −1 modified@@ -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 <a href="https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-methods/matrix-variables.html"> + * Matrix Variables</a> + */ + String maybePathWithMatrixVariables(); + /** * Returns the query of this {@link RequestTarget}. */
core/src/main/java/com/linecorp/armeria/common/SystemPropertyFlagsProvider.java+5 −0 modified@@ -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);
core/src/main/java/com/linecorp/armeria/internal/common/DefaultRequestTarget.java+80 −34 modified@@ -123,20 +123,22 @@ boolean mustPreserveEncoding(int cp) { null, null, "*", + "*", null, null); /** * 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,14 +196,16 @@ public static RequestTarget createWithoutValidation( @Nullable private final String authority; private final String path; + private final String maybePathWithMatrixVariables; @Nullable private final String query; @Nullable private final String fragment; 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,27 +501,27 @@ 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; } if (fragmentPos >= 0) { // 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;
core/src/main/java/com/linecorp/armeria/internal/common/util/MappedPathUtil.java+54 −0 added@@ -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() {} +}
core/src/main/java/com/linecorp/armeria/server/ExactPathMapping.java+6 −0 modified@@ -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);
core/src/main/java/com/linecorp/armeria/server/ParameterizedPathMapping.java+7 −0 modified@@ -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");
core/src/main/java/com/linecorp/armeria/server/PrefixPathMapping.java+4 −0 modified@@ -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 += '/';
core/src/main/java/com/linecorp/armeria/server/RoutingContext.java+2 −0 modified@@ -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());
core/src/test/java/com/linecorp/armeria/internal/common/DefaultRequestTargetTest.java+62 −16 modified@@ -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 {
core/src/test/java/com/linecorp/armeria/server/MatrixVariablesTest.java+49 −0 added@@ -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"); + } +}
core/src/test/java/com/linecorp/armeria/server/RouteTest.java+14 −0 modified@@ -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()
it/spring/boot3-jetty11/build.gradle+12 −0 added@@ -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 +}
it/spring/boot3-jetty11/src/main/java/com/linecorp/armeria/spring/jetty/ErrorHandlingController.java+72 −0 added@@ -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<Map<String, Object>> onBaseException(Throwable t) { + final Map<String, Object> body = ImmutableMap.<String, Object>builder() + .put("status", HttpStatus.INTERNAL_SERVER_ERROR.value()) + .put("message", t.getMessage()) + .build(); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body); + } +}
it/spring/boot3-jetty11/src/main/java/com/linecorp/armeria/spring/jetty/GlobalBaseException.java+23 −0 added@@ -0,0 +1,23 @@ +/* + * 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; + +public class GlobalBaseException extends RuntimeException { + GlobalBaseException(String message) { + super(message); + } +}
it/spring/boot3-jetty11/src/main/java/com/linecorp/armeria/spring/jetty/GlobalExceptionHandler.java+38 −0 added@@ -0,0 +1,38 @@ +/* + * 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.RestControllerAdvice; + +import com.google.common.collect.ImmutableMap; + +@RestControllerAdvice +class GlobalExceptionHandler { + + @ExceptionHandler(GlobalBaseException.class) + public ResponseEntity<Map<String, Object>> onGlobalBaseException(Throwable t) { + final String message = t.getMessage(); + final Map<String, Object> body = ImmutableMap.of("status", HttpStatus.INTERNAL_SERVER_ERROR.value(), + "message", message); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body); + } +}
it/spring/boot3-jetty11/src/main/java/com/linecorp/armeria/spring/jetty/GreetingController.java+40 −0 added@@ -0,0 +1,40 @@ +/* + * Copyright 2018 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 org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/greeting") +public class GreetingController { + private static final String template = "Hello, %s!"; + + /** + * Greeting endpoint. + * @param name name to greet. + * @return response the ResponseEntity. + */ + @GetMapping + public ResponseEntity<Greeting> greetingSync( + @RequestParam(value = "name", defaultValue = "World") String name) { + return ResponseEntity.ok(new Greeting(String.format(template, name))); + } +}
it/spring/boot3-jetty11/src/main/java/com/linecorp/armeria/spring/jetty/Greeting.java+34 −0 added@@ -0,0 +1,34 @@ +/* + * Copyright 2018 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; + +public class Greeting { + + private final String content; + + /** + * Greeting model. + * @param content the content. + */ + public Greeting(String content) { + this.content = content; + } + + public String getContent() { + return content; + } +}
it/spring/boot3-jetty11/src/main/java/com/linecorp/armeria/spring/jetty/MatrixVariablesController.java+39 −0 added@@ -0,0 +1,39 @@ +/* + * 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.spring.jetty; + +import java.util.List; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.MatrixVariable; +import org.springframework.web.bind.annotation.RestController; + +import com.google.common.collect.ImmutableList; + +@RestController +public final class MatrixVariablesController { + + // GET /owners/42;q=11/pets/21;q=22 + // q1 = 11, q2 = 22 + + @GetMapping("/owners/{ownerId}/pets/{petId}") + List<Integer> findPet( + @MatrixVariable(name = "q", pathVar = "ownerId") int q1, + @MatrixVariable(name = "q", pathVar = "petId") int q2) { + return ImmutableList.of(q1, q2); + } +}
it/spring/boot3-jetty11/src/main/java/com/linecorp/armeria/spring/jetty/package-info.java+20 −0 added@@ -0,0 +1,20 @@ +/* + * Copyright 2018 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. + */ + +@NonNullByDefault +package com.linecorp.armeria.spring.jetty; + +import com.linecorp.armeria.common.annotation.NonNullByDefault;
it/spring/boot3-jetty11/src/main/java/com/linecorp/armeria/spring/jetty/SpringJettyApplication.java+85 −0 added@@ -0,0 +1,85 @@ +/* + * Copyright 2018 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 org.eclipse.jetty.server.Server; +import org.eclipse.jetty.util.Loader; +import org.eclipse.jetty.webapp.WebAppContext; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.web.context.WebServerApplicationContext; +import org.springframework.boot.web.embedded.jetty.JettyServerCustomizer; +import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory; +import org.springframework.boot.web.embedded.jetty.JettyWebServer; +import org.springframework.boot.web.server.WebServer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.linecorp.armeria.server.jetty.JettyService; +import com.linecorp.armeria.spring.ArmeriaServerConfigurator; + +import jakarta.servlet.Servlet; + +@SpringBootApplication +public class SpringJettyApplication { + + /** + * Bean to configure Armeria Jetty service. + */ + @Bean + public ArmeriaServerConfigurator armeriaTomcat(WebServerApplicationContext applicationContext) { + final WebServer webServer = applicationContext.getWebServer(); + if (webServer instanceof JettyWebServer) { + final Server jettyServer = ((JettyWebServer) webServer).getServer(); + + return serverBuilder -> serverBuilder.service("prefix:/jetty/api/rest/v1", + JettyService.of(jettyServer)); + } + return serverBuilder -> {}; + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass({ Servlet.class, Server.class, Loader.class, WebAppContext.class }) + static class EmbeddedJetty { + + @Bean + JettyServletWebServerFactory jettyServletWebServerFactory( + ObjectProvider<JettyServerCustomizer> serverCustomizers) { + final JettyServletWebServerFactory factory = new ArmeriaJettyServletWebServerFactory(); + factory.getServerCustomizers().addAll(serverCustomizers.orderedStream().toList()); + return factory; + } + } + + static final class ArmeriaJettyServletWebServerFactory extends JettyServletWebServerFactory { + + @Override + protected JettyWebServer getJettyWebServer(Server server) { + return new JettyWebServer(server, true); + } + } + + /** + * Main method. + * @param args program args. + */ + public static void main(String[] args) { + SpringApplication.run(SpringJettyApplication.class, args); + } +}
it/spring/boot3-jetty11/src/main/resources/application.properties+0 −0 addedit/spring/boot3-jetty11/src/main/resources/config/application.yml+7 −0 added@@ -0,0 +1,7 @@ +armeria: + ports: + - port: 0 + protocols: HTTP +server: + error: + include-message: always
it/spring/boot3-jetty11/src/test/java/com/linecorp/armeria/spring/jetty/ActuatorAutoConfigurationHealthGroupTest.java+98 −0 added@@ -0,0 +1,98 @@ +/* + * Copyright 2022 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 static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.autoconfigure.actuate.metrics.AutoConfigureMetrics; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalManagementPort; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.spring.LocalArmeriaPort; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@ActiveProfiles({ "local", "healthGroupTest" }) +@DirtiesContext +@AutoConfigureMetrics +@EnableAutoConfiguration +class ActuatorAutoConfigurationHealthGroupTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final TypeReference<Map<String, Object>> JSON_MAP = new TypeReference<>() {}; + + @LocalManagementPort + private int managementPort; + @LocalArmeriaPort + private int armeriaPort; + + private WebClient managementClient; + private WebClient armeriaClient; + + @BeforeEach + void setUp() { + managementClient = WebClient.builder("http://127.0.0.1:" + managementPort).build(); + armeriaClient = WebClient.builder("http://127.0.0.1:" + armeriaPort).build(); + } + + @Test + void testHealth() throws Exception { + final AggregatedHttpResponse res = managementClient.get("/internal/actuator/health").aggregate().join(); + assertUpStatus(res); + } + + @Test + void additionalPath() throws Exception { + String path = "/internal/actuator/health/foo"; + assertUpStatus(managementClient.get(path).aggregate().join()); + assertThat(armeriaClient.get(path).aggregate().join().status()).isSameAs(HttpStatus.NOT_FOUND); + + path = "/internal/actuator/health/bar"; + assertUpStatus(managementClient.get(path).aggregate().join()); + assertThat(armeriaClient.get(path).aggregate().join().status()).isSameAs(HttpStatus.NOT_FOUND); + + path = "/foohealth"; + assertUpStatus(managementClient.get(path).aggregate().join()); + assertThat(armeriaClient.get(path).aggregate().join().status()).isSameAs(HttpStatus.NOT_FOUND); + + // barhealth is bound to Armeria port. + path = "/barhealth"; + assertThat(managementClient.get(path).aggregate().join().status()).isSameAs(HttpStatus.NOT_FOUND); + assertUpStatus(armeriaClient.get(path).aggregate().join()); + } + + private static void assertUpStatus(AggregatedHttpResponse res) throws IOException { + assertThat(res.status()).isEqualTo(HttpStatus.OK); + assertThat(res.contentType().toString()).isEqualTo("application/vnd.spring-boot.actuator.v3+json"); + + final Map<String, Object> values = OBJECT_MAPPER.readValue(res.content().array(), JSON_MAP); + assertThat(values).containsEntry("status", "UP"); + } +}
it/spring/boot3-jetty11/src/test/java/com/linecorp/armeria/spring/jetty/ErrorHandlingTest.java+63 −0 added@@ -0,0 +1,63 @@ +/* + * 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 static com.linecorp.armeria.spring.jetty.MatrixVariablesTest.JETTY_BASE_PATH; +import static net.javacrumbs.jsonunit.fluent.JsonFluentAssert.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import com.linecorp.armeria.spring.LocalArmeriaPort; + +import jakarta.inject.Inject; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class ErrorHandlingTest { + + @LocalArmeriaPort + private int port; + + @Inject + private TestRestTemplate restTemplate; + + private static String jettyBaseUrlPath(int port) { + return "http://localhost:" + port + JETTY_BASE_PATH; + } + + @ParameterizedTest + @CsvSource({ + "/error-handling/runtime-exception, 500, jakarta.servlet.ServletException: " + + "Request processing failed: java.lang.RuntimeException: runtime exception", + "/error-handling/custom-exception, 404, custom not found", + "/error-handling/exception-handler, 500, exception handler", + "/error-handling/global-exception-handler, 500, global exception handler" + }) + void shouldReturnFormattedMessage(String path, int status, String message) throws Exception { + final ResponseEntity<String> response = + restTemplate.getForEntity(jettyBaseUrlPath(port) + path, String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.valueOf(status)); + assertThatJson(response.getBody()).node("status").isEqualTo(status); + assertThatJson(response.getBody()).node("message").isEqualTo(message); + } +}
it/spring/boot3-jetty11/src/test/java/com/linecorp/armeria/spring/jetty/MatrixVariablesTest.java+60 −0 added@@ -0,0 +1,60 @@ +/* + * 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.spring.jetty; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.test.context.ActiveProfiles; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.spring.LocalArmeriaPort; + +/** + * Integration test for <a href="https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-methods/matrix-variables.html">Matrix Variables</a>. + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@ActiveProfiles("testbed") +class MatrixVariablesTest { + + static final String JETTY_BASE_PATH = "/jetty/api/rest/v1"; + + @LocalArmeriaPort + int port; + + @Test + void matrixVariablesPreserved() throws Exception { + final WebClient client = WebClient.of("http://127.0.0.1:" + port); + final AggregatedHttpResponse response = client.blocking().get( + JETTY_BASE_PATH + "/owners/42;q=11/pets/21;q=22"); + assertThat(response.contentUtf8()).isEqualTo("[11,22]"); + } + + @Test + void wrongMatrixVariables() throws Exception { + final WebClient client = WebClient.of("http://127.0.0.1:" + port); + AggregatedHttpResponse response = client.blocking().get( + JETTY_BASE_PATH + ";/owners/42;q=11/pets/21;q=22"); + assertThat(response.status()).isSameAs(HttpStatus.BAD_REQUEST); + + response = client.blocking().get("/jetty;wrong=place/api/rest/v1/owners/42;q=11/pets/21;q=22"); + assertThat(response.status()).isSameAs(HttpStatus.BAD_REQUEST); + } +}
it/spring/boot3-jetty11/src/test/java/com/linecorp/armeria/spring/jetty/SpringJettyApplicationItTest.java+86 −0 added@@ -0,0 +1,86 @@ +/* + * Copyright 2018 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 static com.linecorp.armeria.spring.jetty.MatrixVariablesTest.JETTY_BASE_PATH; +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.ActiveProfiles; + +import com.linecorp.armeria.server.Server; +import com.linecorp.armeria.server.ServerPort; + +import jakarta.inject.Inject; + +@ActiveProfiles("testbed") +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class SpringJettyApplicationItTest { + @Inject + private ApplicationContext applicationContext; + @Inject + private Server server; + private int httpPort; + @Inject + private TestRestTemplate restTemplate; + @Inject + private GreetingController greetingController; + + @BeforeEach + public void init() throws Exception { + httpPort = server.activePorts() + .values() + .stream() + .filter(ServerPort::hasHttp) + .findAny() + .get() + .localAddress() + .getPort(); + } + + @Test + void contextLoads() { + assertThat(greetingController).isNotNull(); + } + + @Test + void greetingShouldReturnDefaultMessage() throws Exception { + assertThat(restTemplate.getForObject("http://localhost:" + httpPort + JETTY_BASE_PATH + "/greeting", + String.class)) + .contains("Hello, World!"); + } + + @Test + void greetingShouldReturnUsersMessage() throws Exception { + assertThat(restTemplate.getForObject("http://localhost:" + httpPort + + JETTY_BASE_PATH + "/greeting?name=Armeria", + String.class)) + .contains("Hello, Armeria!"); + } + + @Test + void greetingShouldReturn404() throws Exception { + assertThat(restTemplate.getForEntity("http://localhost:" + httpPort + JETTY_BASE_PATH + "/greet", + Void.class) + .getStatusCode().value()).isEqualByComparingTo(404); + } +}
it/spring/boot3-jetty11/src/test/resources/application-healthGroupTest.yml+23 −0 added@@ -0,0 +1,23 @@ +armeria: + ports: + - port: 0 + protocols: HTTP + +management: + server: + port: 0 + endpoints: + web: + exposure: + include: health, prometheus + base-path: /internal/actuator + endpoint: + health: + group: + foo: + include: ping + additional-path: "management:/foohealth" + bar: + include: ping + additional-path: "server:/barhealth" +
it/spring/boot3-jetty11/src/test/resources/application-testbed.yml+7 −0 added@@ -0,0 +1,7 @@ +# This currently doesn't work. See https://github.com/line/armeria/issues/5039 +server.port: -1 +--- +armeria: + ports: + - port: 0 + protocols: HTTP
it/spring/boot3-tomcat10/src/main/java/com/linecorp/armeria/spring/tomcat/MatrixVariablesController.java+39 −0 added@@ -0,0 +1,39 @@ +/* + * 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.spring.tomcat; + +import java.util.List; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.MatrixVariable; +import org.springframework.web.bind.annotation.RestController; + +import com.google.common.collect.ImmutableList; + +@RestController +public final class MatrixVariablesController { + + // GET /owners/42;q=11/pets/21;q=22 + // q1 = 11, q2 = 22 + + @GetMapping("/owners/{ownerId}/pets/{petId}") + List<Integer> findPet( + @MatrixVariable(name = "q", pathVar = "ownerId") int q1, + @MatrixVariable(name = "q", pathVar = "petId") int q2) { + return ImmutableList.of(q1, q2); + } +}
it/spring/boot3-tomcat10/src/test/java/com/linecorp/armeria/spring/tomcat/ActuatorAutoConfigurationHealthGroupTest.java+1 −2 modified@@ -46,8 +46,7 @@ class ActuatorAutoConfigurationHealthGroupTest { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private static final TypeReference<Map<String, Object>> JSON_MAP = - new TypeReference<Map<String, Object>>() {}; + private static final TypeReference<Map<String, Object>> JSON_MAP = new TypeReference<>() {}; @LocalManagementPort private int managementPort;
it/spring/boot3-tomcat10/src/test/java/com/linecorp/armeria/spring/tomcat/ErrorHandlingTest.java+1 −2 modified@@ -16,6 +16,7 @@ package com.linecorp.armeria.spring.tomcat; +import static com.linecorp.armeria.spring.tomcat.MatrixVariablesTest.TOMCAT_BASE_PATH; import static net.javacrumbs.jsonunit.fluent.JsonFluentAssert.assertThatJson; import static org.assertj.core.api.Assertions.assertThat; @@ -34,8 +35,6 @@ @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) class ErrorHandlingTest { - private static final String TOMCAT_BASE_PATH = "/tomcat/api/rest/v1"; - @LocalArmeriaPort private int port;
it/spring/boot3-tomcat10/src/test/java/com/linecorp/armeria/spring/tomcat/MatrixVariablesTest.java+60 −0 added@@ -0,0 +1,60 @@ +/* + * 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.spring.tomcat; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.test.context.ActiveProfiles; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.spring.LocalArmeriaPort; + +/** + * Integration test for <a href="https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-methods/matrix-variables.html">Matrix Variables</a>. + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@ActiveProfiles("testbed") +class MatrixVariablesTest { + + static final String TOMCAT_BASE_PATH = "/tomcat/api/rest/v1"; + + @LocalArmeriaPort + int port; + + @Test + void matrixVariablesPreserved() throws Exception { + final WebClient client = WebClient.of("http://127.0.0.1:" + port); + final AggregatedHttpResponse response = client.blocking().get( + TOMCAT_BASE_PATH + "/owners/42;q=11/pets/21;q=22"); + assertThat(response.contentUtf8()).isEqualTo("[11,22]"); + } + + @Test + void wrongMatrixVariables() throws Exception { + final WebClient client = WebClient.of("http://127.0.0.1:" + port); + AggregatedHttpResponse response = client.blocking().get( + TOMCAT_BASE_PATH + ";/owners/42;q=11/pets/21;q=22"); + assertThat(response.status()).isSameAs(HttpStatus.BAD_REQUEST); + + response = client.blocking().get("/tomcat;wrong=place/api/rest/v1/owners/42;q=11/pets/21;q=22"); + assertThat(response.status()).isSameAs(HttpStatus.BAD_REQUEST); + } +}
it/spring/boot3-tomcat10/src/test/resources/application-testbed.yml+7 −0 added@@ -0,0 +1,7 @@ +# Prevent the embedded Tomcat from opening a TCP/IP port. +server.port: -1 +--- +armeria: + ports: + - port: 0 + protocols: HTTP
jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/JettyService.java+22 −6 modified@@ -16,6 +16,7 @@ package com.linecorp.armeria.server.jetty; +import static com.linecorp.armeria.internal.common.util.MappedPathUtil.mappedPath; import static java.util.Objects.requireNonNull; import java.nio.ByteBuffer; @@ -58,7 +59,9 @@ import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.HttpResponseWriter; import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.MediaType; import com.linecorp.armeria.common.RequestHeaders; +import com.linecorp.armeria.common.RequestTarget; import com.linecorp.armeria.common.ResponseHeaders; import com.linecorp.armeria.common.ResponseHeadersBuilder; import com.linecorp.armeria.common.annotation.Nullable; @@ -211,6 +214,13 @@ void stop() { @Override public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) { + final String mappedPath = mappedPath(ctx); + if (mappedPath == null) { + return HttpResponse.of(HttpStatus.BAD_REQUEST, MediaType.PLAIN_TEXT_UTF_8, + "Invalid matrix variable: " + + ctx.routingContext().requestTarget().maybePathWithMatrixVariables()); + } + final ArmeriaConnector connector = this.connector; assert connector != null; @@ -242,7 +252,7 @@ public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) { }); final Request jReq = httpChannel.getRequest(); - fillRequest(ctx, aReq, jReq); + fillRequest(ctx, aReq, jReq, mappedPath); final SSLSession sslSession = ctx.sslSession(); final boolean needsReverseDnsLookup; if (sslSession != null) { @@ -284,11 +294,11 @@ public ExchangeType exchangeType(RoutingContext routingContext) { } private static void fillRequest( - ServiceRequestContext ctx, AggregatedHttpRequest aReq, Request jReq) { + ServiceRequestContext ctx, AggregatedHttpRequest aReq, Request jReq, String mappedPath) { DispatcherTypeUtil.setRequestType(jReq); jReq.setAsyncSupported(true, null); jReq.setSecure(ctx.sessionProtocol().isTls()); - jReq.setMetaData(toRequestMetadata(ctx, aReq)); + jReq.setMetaData(toRequestMetadata(ctx, aReq, mappedPath)); final HttpHeaders trailers = aReq.trailers(); if (!trailers.isEmpty()) { final HttpField[] httpFields = trailers.stream() @@ -298,17 +308,23 @@ private static void fillRequest( } } - private static MetaData.Request toRequestMetadata(ServiceRequestContext ctx, AggregatedHttpRequest aReq) { + private static MetaData.Request toRequestMetadata(ServiceRequestContext ctx, AggregatedHttpRequest aReq, + String mappedPath) { // Construct the HttpURI final StringBuilder uriBuf = new StringBuilder(); final RequestHeaders aHeaders = aReq.headers(); uriBuf.append(ctx.sessionProtocol().isTls() ? "https" : "http"); uriBuf.append("://"); uriBuf.append(aHeaders.authority()); - uriBuf.append(aHeaders.path()); - final HttpURI uri = HttpURI.build(HttpURI.build(uriBuf.toString()).path(ctx.mappedPath())) + final RequestTarget requestTarget = ctx.routingContext().requestTarget(); + if (requestTarget.query() != null) { + mappedPath = mappedPath + '?' + requestTarget.query(); + } + uriBuf.append(mappedPath); + + final HttpURI uri = HttpURI.build(HttpURI.build(uriBuf.toString())) .asImmutable(); final HttpField[] fields = aHeaders.stream().map(header -> { final AsciiString k = header.getKey();
jetty/jetty9/src/main/java/com/linecorp/armeria/server/jetty/JettyService.java+8 −0 modified@@ -17,6 +17,7 @@ package com.linecorp.armeria.server.jetty; import static com.linecorp.armeria.internal.common.ArmeriaHttpUtil.toHttp1Headers; +import static com.linecorp.armeria.internal.common.util.MappedPathUtil.mappedPath; import static java.util.Objects.requireNonNull; import java.lang.invoke.MethodHandle; @@ -54,6 +55,7 @@ import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.HttpResponseWriter; import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.MediaType; import com.linecorp.armeria.common.RequestHeaders; import com.linecorp.armeria.common.ResponseHeaders; import com.linecorp.armeria.common.ResponseHeadersBuilder; @@ -256,6 +258,12 @@ void stop() { @Override public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) { + final String mappedPath = mappedPath(ctx); + if (mappedPath == null) { + return HttpResponse.of(HttpStatus.BAD_REQUEST, MediaType.PLAIN_TEXT_UTF_8, + "Invalid matrix variable: " + + ctx.routingContext().requestTarget().maybePathWithMatrixVariables()); + } final ArmeriaConnector connector = this.connector; assert connector != null;
settings.gradle+1 −0 modified@@ -144,6 +144,7 @@ includeWithFlags ':it:nio', 'java', 'relocate includeWithFlags ':it:okhttp', 'java', 'relocate' includeWithFlags ':it:resilience4j', 'java17', 'relocate' includeWithFlags ':it:server', 'java', 'relocate' +includeWithFlags ':it:spring:boot3-jetty11', 'java17', 'relocate' includeWithFlags ':it:spring:boot3-kotlin', 'java17', 'relocate', 'kotlin' includeWithFlags ':it:spring:boot3-mixed', 'java17', 'relocate' includeWithFlags ':it:spring:boot3-mixed-tomcat10', 'java17', 'relocate'
spring/boot3-webflux-autoconfigure/build.gradle+1 −0 modified@@ -29,6 +29,7 @@ dependencies { testImplementation project(':spring:boot3-actuator-autoconfigure') testImplementation project(':thrift0.18') testImplementation libs.spring.boot3.starter.test + testImplementation libs.spring6.web // Added for sharing test suites with boot2 testImplementation libs.javax.inject }
spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpRequest.java+9 −3 modified@@ -41,6 +41,7 @@ import com.linecorp.armeria.common.HttpHeaderNames; import com.linecorp.armeria.common.HttpRequest; import com.linecorp.armeria.common.RequestHeaders; +import com.linecorp.armeria.common.RequestTarget; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.server.ServiceRequestContext; @@ -60,7 +61,7 @@ final class ArmeriaServerHttpRequest extends AbstractServerHttpRequest { ArmeriaServerHttpRequest(ServiceRequestContext ctx, HttpRequest req, DataBufferFactoryWrapper<?> factoryWrapper) { - super(uri(req), null, springHeaders(req.headers())); + super(uri(ctx, req), null, springHeaders(req.headers())); this.ctx = requireNonNull(ctx, "ctx"); this.req = req; @@ -76,13 +77,18 @@ private static HttpHeaders springHeaders(RequestHeaders headers) { return springHeaders; } - private static URI uri(HttpRequest req) { + private static URI uri(ServiceRequestContext ctx, HttpRequest req) { final String scheme = req.scheme(); final String authority = req.authority(); // Server side Armeria HTTP request always has the scheme and authority. assert scheme != null; assert authority != null; - return URI.create(scheme + "://" + authority + req.path()); + final RequestTarget requestTarget = ctx.routingContext().requestTarget(); + String path = requestTarget.maybePathWithMatrixVariables(); + if (requestTarget.query() != null) { + path = path + '?' + requestTarget.query(); + } + return URI.create(scheme + "://" + authority + path); } @Override
spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/MatrixVariablesTest.java+68 −0 added@@ -0,0 +1,68 @@ +/* + * 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.spring.web.reactive; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.MatrixVariable; +import org.springframework.web.bind.annotation.RestController; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; + +import reactor.core.publisher.Flux; + +/** + * Integration test for <a href="https://docs.spring.io/spring-framework/reference/web/webflux/controller/ann-methods/matrix-variables.html">Matrix Variables</a>. + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class MatrixVariablesTest { + + @SpringBootApplication + @Configuration + static class TestConfiguration { + @RestController + static class TestController { + + // GET /owners/42;q=11/pets/21;q=22 + // q1 = 11, q2 = 22 + + @GetMapping("/owners/{ownerId}/pets/{petId}") + Flux<Integer> findPet( + @MatrixVariable(name = "q", pathVar = "ownerId") int q1, + @MatrixVariable(name = "q", pathVar = "petId") int q2) { + return Flux.just(q1, q2); + } + } + } + + @LocalServerPort + int port; + + @Test + void foo() throws Exception { + final WebClient client = WebClient.of("http://127.0.0.1:" + port); + final AggregatedHttpResponse response = client.blocking().get("/owners/42;q=11/pets/21;q=22"); + assertThat(response.contentUtf8()).isEqualTo("[11,22]"); + } +}
tomcat10/src/main/java/com/linecorp/armeria/server/tomcat/TomcatService.java+10 −4 modified@@ -17,6 +17,7 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.linecorp.armeria.internal.common.ArmeriaHttpUtil.toHttp1Headers; +import static com.linecorp.armeria.internal.common.util.MappedPathUtil.mappedPath; import static java.util.Objects.requireNonNull; import java.io.File; @@ -378,6 +379,13 @@ public final HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) thro return HttpResponse.of(HttpStatus.SERVICE_UNAVAILABLE); } + final String mappedPath = mappedPath(ctx); + if (mappedPath == null) { + return HttpResponse.of(HttpStatus.BAD_REQUEST, MediaType.PLAIN_TEXT_UTF_8, + "Invalid matrix variable: " + + ctx.routingContext().requestTarget().maybePathWithMatrixVariables()); + } + final HttpResponseWriter res = HttpResponse.streaming(); req.aggregate().handle((aReq, cause) -> { try { @@ -396,7 +404,7 @@ public final HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) thro } final ArmeriaProcessor processor = createProcessor(coyoteAdapter); - final Request coyoteReq = convertRequest(ctx, aReq, processor.getRequest()); + final Request coyoteReq = convertRequest(ctx, mappedPath, aReq, processor.getRequest()); if (coyoteReq == null) { if (res.tryWrite(INVALID_AUTHORITY_HEADERS)) { if (res.tryWrite(INVALID_AUTHORITY_DATA)) { @@ -471,10 +479,8 @@ private static ArmeriaProcessor createProcessor(Adapter coyoteAdapter) throws Th } @Nullable - private Request convertRequest(ServiceRequestContext ctx, AggregatedHttpRequest req, + private Request convertRequest(ServiceRequestContext ctx, String mappedPath, AggregatedHttpRequest req, Request coyoteReq) throws Throwable { - final String mappedPath = ctx.mappedPath(); - coyoteReq.scheme().setString(req.scheme()); // Set the start time which is used by Tomcat access logging
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
6- github.com/advisories/GHSA-wvp2-9ppw-337jghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2023-38493ghsaADVISORY
- docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-methods/matrix-variables.htmlghsax_refsource_MISCWEB
- github.com/line/armeria/commit/039db50bbfc88014ea8737fd1e1ddd6fd3fc4f07ghsax_refsource_MISCWEB
- github.com/line/armeria/commit/49e04ef231ad65750739529c7fa4ce946ff7588bghsaWEB
- github.com/line/armeria/security/advisories/GHSA-wvp2-9ppw-337jghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.