Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,9 @@ public String getMethod() {

@Override
public String getPathInfo() {
String pathInfo = cleanUri(request.getRawPath());
return decodeRequestPath(pathInfo, LambdaContainerHandler.getContainerConfig());
// Must be the same canonical form filter matching uses, otherwise a path can be spelled so that filter
// selection and servlet resolution disagree about which resource is being requested.
return canonicalizePath(request.getRawPath());
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import jakarta.ws.rs.core.MediaType;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
Expand All @@ -47,6 +48,9 @@
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayDeque;
import java.util.Deque;


/**
Expand Down Expand Up @@ -757,17 +761,137 @@ protected Locale parseLanguageTag(String languageTag) {
return new Locale(language, country);
}

static String decodeRequestPath(String requestPath, ContainerConfig config) {

/**
* The request URI with the context path removed, exactly as it arrived: not decoded, not normalized. The context
* path is configured rather than client-supplied, so it is excluded from anything that inspects what the client
* actually sent.
* @param request The incoming request
* @return The raw request path relative to the context
*/
public static String contextRelativeRequestUri(HttpServletRequest request) {
String uri = request.getRequestURI();
String contextPath = request.getContextPath();
if (uri != null && contextPath != null && !contextPath.isEmpty() && uri.startsWith(contextPath)) {
return uri.substring(contextPath.length());
}
return (uri == null || uri.isEmpty() ? "/" : uri);
}


/**
* Produces the canonical form of a request path: percent-decoded exactly once and then normalized. This is the
* form every routing and authorization decision must use, so that no spelling of a path can make two decisions
* disagree about which resource is being requested.
*
* Decoding is deliberately not delegated to <code>URLDecoder</code>, which implements form encoding and would
* turn a literal "+" in a path segment into a space.
* @param path A request path
* @return The decoded, normalized path, always starting with "/" and never ending with one
*/
public static String canonicalizePath(final String path) {
if (path == null || path.isEmpty()) {
return "/";
}
return normalizePathSegments(decodePath(path));
}


/**
* Percent-decodes a path exactly once, leaving dot segments in place. Code that rejects suspicious input needs
* this form rather than the canonical one: <code>canonicalizePath</code> resolves "." and ".." away, which hides
* a traversal attempt from any validator looking for it.
*
* Escape sequences are gathered and decoded as a group using the configured URI encoding, so a multi-byte
* character spanning several escapes decodes correctly. Literal characters are copied across untouched, which
* also means a surrogate pair is never split.
* @param path A request path
* @return The path with escape sequences decoded, dot segments untouched
*/
public static String decodePath(final String path) {
if (path == null || path.indexOf('%') < 0) {
return path;
}

Charset charset = uriCharset();
StringBuilder decoded = new StringBuilder(path.length());
ByteArrayOutputStream pending = new ByteArrayOutputStream();
for (int i = 0; i < path.length(); i++) {
char current = path.charAt(i);
if (current == '%' && i + 2 < path.length()) {
int high = Character.digit(path.charAt(i + 1), 16);
int low = Character.digit(path.charAt(i + 2), 16);
if (high >= 0 && low >= 0) {
pending.write((high << 4) + low);
i += 2;
continue;
}
}
flushDecodedBytes(pending, decoded, charset);
decoded.append(current);
}
flushDecodedBytes(pending, decoded, charset);

return decoded.toString();
}


private static void flushDecodedBytes(ByteArrayOutputStream pending, StringBuilder out, Charset charset) {
if (pending.size() > 0) {
out.append(new String(pending.toByteArray(), charset));
pending.reset();
}
}


/**
* The charset configured for decoding request URIs, falling back to UTF-8 if it is unset or not supported.
* Never throws, because this runs on every request including malformed ones.
*/
private static Charset uriCharset() {
ContainerConfig config = LambdaContainerHandler.getContainerConfig();
String configured = (config == null ? null : config.getUriEncoding());
if (configured == null || configured.isEmpty()) {
return StandardCharsets.UTF_8;
}
try {
return URLDecoder.decode(requestPath, config.getUriEncoding());
} catch (UnsupportedEncodingException ex) {
log.error("Could not URL decode the request path, configured encoding not supported: {}", SecurityUtils.encode(config.getUriEncoding()));
// we do not fail at this.
return requestPath;
return Charset.forName(configured);
} catch (Exception e) {
log.warn("Configured uriEncoding is not supported, falling back to UTF-8");
return StandardCharsets.UTF_8;
}
}


/**
* Collapses empty segments and resolves "." and ".." segments. Traversal above the root is contained rather than
* rejected, so that a path can never normalize to something outside the application.
*/
private static String normalizePathSegments(final String path) {
Deque<String> segments = new ArrayDeque<>();
for (String segment : path.split("/", -1)) {
if (segment.isEmpty() || ".".equals(segment)) {
continue;
}
if ("..".equals(segment)) {
segments.pollLast();
continue;
}
segments.addLast(segment);
}

if (segments.isEmpty()) {
return "/";
}

StringBuilder normalized = new StringBuilder();
for (String segment : segments) {
normalized.append("/").append(segment);
}
return normalized.toString();
}


static String cleanUri(String uri) {
String finalUri = (uri == null ? "/" : uri);
if (finalUri.equals("/")) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import java.util.Map;

import static com.amazonaws.serverless.proxy.internal.servlet.AwsProxyHttpServletRequest.cleanUri;
import static com.amazonaws.serverless.proxy.internal.servlet.AwsHttpServletRequest.canonicalizePath;

public class AwsHttpServletRequestWrapper implements HttpServletRequest {
private HttpServletRequest originalRequest;
Expand Down Expand Up @@ -86,8 +87,9 @@ public String getMethod() {

@Override
public String getPathInfo() {
String pathInfo = cleanUri(newPath);
return AwsHttpServletRequest.decodeRequestPath(pathInfo, LambdaContainerHandler.getContainerConfig());
// Same canonical form the wrapped request and filter matching use. This is reached on async dispatch, where
// AwsProxyRequestDispatcher resolves the servlet from getPathInfo, so it has to agree with filter selection.
return canonicalizePath(newPath);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,9 @@ public String getMethod() {

@Override
public String getPathInfo() {
String pathInfo = cleanUri(request.getPath());
return decodeRequestPath(pathInfo, LambdaContainerHandler.getContainerConfig());
// Must be the same canonical form filter matching uses, otherwise a path can be spelled so that filter
// selection and servlet resolution disagree about which resource is being requested.
return canonicalizePath(request.getPath());
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,18 +223,30 @@ public RequestDispatcher getNamedDispatcher(String s) {
}

public Servlet getServletForPath(String path) {
String[] pathParts = path.split("/");
// getPathInfo() is null whenever the servlet path covered the whole request, so callers can legitimately
// pass null here. Treat it as the root rather than dereferencing it.
String targetPath = (path == null ? "/" : path);
String[] pathParts = targetPath.split("/");
for (AwsServletRegistration reg : servletRegistrations.values()) {
for (String p : reg.getMappings()) {
if ("".equals(p) || "/".equals(p) || "/*".equals(p)) {
return reg.getServlet();
}
// if I have no path and I haven't matched something now I'll just move on to the next
if ("".equals(path) || "/".equals(path)) {
if ("".equals(targetPath) || "/".equals(targetPath)) {
continue;
}
String[] regParts = p.split("/");
for (int i = 0; i < regParts.length; i++) {
if (i >= pathParts.length) {
// The request has fewer segments than this mapping. A trailing wildcard still matches the
// empty remainder - the servlet spec has "/a/*" match "/a" - but anything else cannot, and
// walking further would read past the end of the request path.
if ("*".equals(regParts[i])) {
return reg.getServlet();
}
break;
}
if (!regParts[i].equals(pathParts[i]) && !"*".equals(regParts[i])) {
break;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,25 @@ public abstract class FilterChainManager<ServletContextType extends ServletConte
* @return A <code>FilterChainHolder</code> object that can be used to apply the filters to the request
*/
FilterChainHolder getFilterChain(final HttpServletRequest request, Servlet servlet) {
String targetPath = request.getRequestURI();
// Filter selection and servlet resolution used to read the path from different sources, which is the bypass
// this fixes. They cannot just be pointed at one source, because consumers downstream disagree about which
// form they route on, so a filter applies if its url-pattern matches under either of two spellings.
//
// The canonical path is the one servlet resolution here uses, since getPathInfo() is decoded and normalized.
// The decoded-but-not-normalized path is needed because Spring MVC matches on the undecoded request URI and
// leaves dot segments to a servlet container that does not exist in Lambda. "/admin/../public/info" still
// reaches an "/admin/**" handler even though its canonical form is "/public/info", so matching on the
// canonical path alone leaves that request reaching an admin handler with its filter skipped.
//
// Matching under either spelling over-selects, so a filter may run for a request that is ultimately routed
// elsewhere. Under-selecting is the authorization bypass, so that is the direction chosen.
String rawPath = AwsHttpServletRequest.contextRelativeRequestUri(request);
String decodedPath = AwsHttpServletRequest.decodePath(rawPath);
String canonicalPath = request.getPathInfo();
if (canonicalPath == null || canonicalPath.isEmpty()) {
canonicalPath = AwsHttpServletRequest.canonicalizePath(rawPath);
}
String targetPath = canonicalPath + "|" + decodedPath;
DispatcherType type = request.getDispatcherType();

// only return the cached result if the filter list hasn't changed in the meanwhile
Expand Down Expand Up @@ -119,8 +137,9 @@ FilterChainHolder getFilterChain(final HttpServletRequest request, Servlet servl
continue;
}
for (String path : holder.getRegistration().getUrlPatternMappings()) {
if (pathMatches(targetPath, path)) {
if (pathMatches(canonicalPath, path) || pathMatches(decodedPath, path)) {
chainHolder.addFilter(holder);
break;
}
}

Expand Down Expand Up @@ -205,19 +224,24 @@ private void putFilterChainCache(final DispatcherType type, final String targetP
* @return true if the given mapping path can apply to the target, false otherwise.
*/
boolean pathMatches(final String target, final String mapping) {
// Matching is case-insensitive throughout. The exact-equality check below always lowercased both sides while
// the segment comparison further down was case-sensitive, so the two halves of this method disagreed and a
// differently-cased path could skip its filter. Over-selecting a filter is fail-safe; under-selecting one is
// an authorization bypass, so both paths now compare case-insensitively.
String finalTarget = target.toLowerCase(Locale.ENGLISH);
String finalMapping = mapping.toLowerCase(Locale.ENGLISH);

// easiest case, they are exactly the same
if (target.toLowerCase(Locale.ENGLISH).equals(mapping.toLowerCase(Locale.ENGLISH))) {
if (finalTarget.equals(finalMapping)) {
return true;
}

String finalTarget = target;
String finalMapping = mapping;
// strip first slash
if (target.startsWith("/")) {
finalTarget = target.replaceFirst("/", "");
if (finalTarget.startsWith("/")) {
finalTarget = finalTarget.replaceFirst("/", "");
}
if (mapping.startsWith("/")) {
finalMapping = mapping.replaceFirst("/", "");
if (finalMapping.startsWith("/")) {
finalMapping = finalMapping.replaceFirst("/", "");
}

String[] targetParts = finalTarget.split(PATH_PART_SEPARATOR);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import com.amazonaws.serverless.proxy.internal.servlet.AwsHttpServletRequest;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
Expand Down Expand Up @@ -72,8 +73,12 @@ public void init(FilterConfig filterConfig) throws ServletException {

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
// the getPathInfo method of the AwsProxyHttpServletRequest returns the request path with the correct base path stripped
String path = ((HttpServletRequest)servletRequest).getPathInfo();
// This filter has to see the path decoded but NOT normalized. getPathInfo resolves dot segments, which
// would hide a traversal attempt from the checks below, and the raw URI leaves escapes encoded, so
// "/%2e%2e/x" would not register as "..". The context path is excluded because it contributes slashes
// without contributing dot segments, which loosens the ratio check further down.
HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
String path = AwsHttpServletRequest.decodePath(stripContextPath(httpRequest));
if (path == null) {
setErrorResponse(servletResponse);
return;
Expand Down Expand Up @@ -139,4 +144,18 @@ private int countStrings(String needle, String haystack) {
}
return stringCount;
}

/**
* The request URI with the context path removed, still encoded. The context path is configured rather than
* client-supplied, so including it would only dilute the checks applied to the part the client controls.
*/
private static String stripContextPath(HttpServletRequest request) {
String uri = request.getRequestURI();
String contextPath = request.getContextPath();
if (uri != null && contextPath != null && !contextPath.isEmpty() && uri.startsWith(contextPath)) {
return uri.substring(contextPath.length());
}
return uri;
}

}
Loading
Loading