From ed41cdac8177da8ba10911da0406ebc4655ed997 Mon Sep 17 00:00:00 2001 From: Chinmay Kawle Date: Mon, 26 Oct 2026 21:31:34 +0000 Subject: [PATCH 1/2] fix(jersey): run servlet filters before Jersey resources Jersey was registered as a filter mapped to /* when the handler was initialized, and it ran the resource before passing the request on. Filters that an application added afterwards, through onStartup() or getServletContext().addFilter(), therefore ran only after the resource had executed and written its response, so a filter that ended the request without calling chain.doFilter() could not stop it. Jersey now runs as the servlet at the end of the filter chain, the way DispatcherServlet does in the Spring module. Every filter registered with the servlet context runs first, in registration order, whether it was added before or after initialize(). JerseyHandlerFilter no longer continues the chain once Jersey has written the response. Calling initialize() a second time no longer throws a NullPointerException. --- .../proxy/jersey/JerseyHandlerFilter.java | 14 +- .../jersey/JerseyLambdaContainerHandler.java | 68 ++- .../proxy/jersey/JerseyFilterChainTest.java | 420 ++++++++++++++++++ 3 files changed, 490 insertions(+), 12 deletions(-) create mode 100644 aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/JerseyFilterChainTest.java diff --git a/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyHandlerFilter.java b/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyHandlerFilter.java index 27e3157f0..0d21a484c 100644 --- a/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyHandlerFilter.java +++ b/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyHandlerFilter.java @@ -89,6 +89,19 @@ public void init(FilterConfig filterConfig) { @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { + // Jersey produces the response, so it ends the chain instead of passing the request on. Anything after it + // would only run once the resource had already executed. + handle(servletRequest, servletResponse); + } + + /** + * Runs the request through Jersey and waits for the response to be written. {@link JerseyLambdaContainerHandler} + * calls this from the servlet at the end of the filter chain, so the resource is only reached once every filter + * registered with the servlet context has passed the request on. + * @param servletRequest The incoming servlet request + * @param servletResponse The response Jersey writes to + */ + void handle(ServletRequest servletRequest, ServletResponse servletResponse) { Timer.start("JERSEY_FILTER_DOFILTER"); // we use a latch to make the processing inside Jersey synchronous CountDownLatch jerseyLatch = new CountDownLatch(1); @@ -106,7 +119,6 @@ public void doFilter(ServletRequest servletRequest, ServletResponse servletRespo throw new InternalServerErrorException(e); } Timer.stop("JERSEY_FILTER_DOFILTER"); - filterChain.doFilter(servletRequest, servletResponse); } @Override diff --git a/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyLambdaContainerHandler.java b/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyLambdaContainerHandler.java index fe50cd0fd..788331507 100644 --- a/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyLambdaContainerHandler.java +++ b/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyLambdaContainerHandler.java @@ -30,16 +30,16 @@ import org.glassfish.jersey.process.internal.RequestScoped; import org.glassfish.jersey.server.ResourceConfig; -import javax.servlet.DispatcherType; -import javax.servlet.FilterRegistration; import javax.servlet.Servlet; +import javax.servlet.ServletConfig; import javax.servlet.ServletContext; -import javax.servlet.http.HttpServlet; +import javax.servlet.ServletRegistration; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.ws.rs.core.Application; -import java.util.EnumSet; import java.util.concurrent.CountDownLatch; @@ -73,7 +73,10 @@ public class JerseyLambdaContainerHandler extends Aws // Variables - Private //------------------------------------------------------------- + private static final String JERSEY_SERVLET_NAME = "JerseyServlet"; + private JerseyHandlerFilter jerseyFilter; + private JerseyServlet jerseyServlet; private boolean initialized; //------------------------------------------------------------- @@ -175,6 +178,7 @@ protected void configure() { } this.jerseyFilter = new JerseyHandlerFilter(jaxRsApplication); + this.jerseyServlet = new JerseyServlet(jerseyFilter); Timer.stop("JERSEY_CONTAINER_CONSTRUCTOR"); } @@ -195,7 +199,7 @@ protected void handleRequest(HttpServletRequest httpServletRequest, AwsHttpServl ((AwsHttpServletRequest)httpServletRequest).setServletContext(getServletContext()); } - doFilter(httpServletRequest, httpServletResponse, null); + doFilter(httpServletRequest, httpServletResponse, jerseyServlet); Timer.stop("JERSEY_HANDLE_REQUEST"); } @@ -208,12 +212,13 @@ protected AwsHttpServletResponse getContainerResponse(HttpServletRequest request public void initialize() { Timer.start("JERSEY_COLD_START_INIT"); - // manually add the spark filter to the chain. This should the last one and match all uris - FilterRegistration.Dynamic jerseyFilterReg = getServletContext().addFilter("JerseyFilter", jerseyFilter); - jerseyFilterReg.addMappingForUrlPatterns( - EnumSet.of(DispatcherType.REQUEST, DispatcherType.ASYNC, DispatcherType.INCLUDE, DispatcherType.FORWARD), - true, "/*" - ); + // Jersey runs as the servlet at the end of the filter chain, the way DispatcherServlet does for Spring. Every + // filter registered with the servlet context, before or after this method, runs first and can stop the + // request before it reaches a resource. + ServletRegistration.Dynamic jerseyServletReg = getServletContext().addServlet(JERSEY_SERVLET_NAME, jerseyServlet); + jerseyServletReg.addMapping("/*"); + // the filter chain reads the servlet name from its config, so the servlet has to be initialized up front + jerseyServlet.init(((AwsServletRegistration) jerseyServletReg).getServletConfig()); Timer.stop("JERSEY_COLD_START_INIT"); initialized = true; @@ -230,4 +235,45 @@ public InjectionManager getInjectionManager() { public Servlet getServlet() { return null; } + + //------------------------------------------------------------- + // Inner Class - + //------------------------------------------------------------- + + /** + * The servlet at the end of the filter chain. It hands the request to Jersey, which writes the response. + */ + private static final class JerseyServlet implements Servlet { + private final JerseyHandlerFilter jerseyFilter; + private ServletConfig config; + + JerseyServlet(JerseyHandlerFilter jerseyFilter) { + this.jerseyFilter = jerseyFilter; + } + + @Override + public void init(ServletConfig servletConfig) { + config = servletConfig; + } + + @Override + public ServletConfig getServletConfig() { + return config; + } + + @Override + public void service(ServletRequest servletRequest, ServletResponse servletResponse) { + jerseyFilter.handle(servletRequest, servletResponse); + } + + @Override + public String getServletInfo() { + return ""; + } + + @Override + public void destroy() { + + } + } } diff --git a/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/JerseyFilterChainTest.java b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/JerseyFilterChainTest.java new file mode 100644 index 000000000..714761f7a --- /dev/null +++ b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/JerseyFilterChainTest.java @@ -0,0 +1,420 @@ +/* + * Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file 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.amazonaws.serverless.proxy.jersey; + +import com.amazonaws.serverless.proxy.AwsHttpApiV2SecurityContextWriter; +import com.amazonaws.serverless.proxy.AwsProxyExceptionHandler; +import com.amazonaws.serverless.proxy.AwsProxySecurityContextWriter; +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import com.amazonaws.serverless.proxy.internal.servlet.AwsHttpApiV2HttpServletRequestReader; +import com.amazonaws.serverless.proxy.internal.servlet.AwsProxyHttpServletRequestReader; +import com.amazonaws.serverless.proxy.internal.servlet.AwsProxyHttpServletResponseWriter; +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.model.HttpApiV2ProxyRequest; +import org.glassfish.jersey.server.ResourceConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Servlet filters registered on a Jersey handler must run before the JAX-RS resource, as they would in front of a + * servlet in a servlet container. Filters added through onStartup after one of the default factory + * methods used to run only after Jersey had already executed the resource and written its response, so a filter that + * denied the request could not stop it. + * + * Requests go through proxyStream, the entry point a Lambda function's stream handler calls, which + * deserializes the event and hands it to proxy. + */ +public class JerseyFilterChainTest { + private static final String SECRET_BODY = "TOP_SECRET"; + private static final String FORBIDDEN_BODY = "FORBIDDEN"; + private static final String FILTER_ATTRIBUTE = "com.amazonaws.serverless.jersey.test.filterRan"; + private static final String NO_ATTRIBUTE = "NO_ATTRIBUTE"; + private static final String FILTER_HEADER = "X-Filter-Header"; + + private static final AtomicInteger RESOURCE_INVOCATIONS = new AtomicInteger(0); + + private final MockLambdaContext lambdaContext = new MockLambdaContext(); + + public static Collection data() { + return Arrays.asList(new Object[]{"API_GW", "ALB", "HTTP_API"}); + } + + @BeforeEach + void resetInvocations() { + RESOURCE_INVOCATIONS.set(0); + } + + @MethodSource("data") + @ParameterizedTest + void denyingFilter_registeredAfterFactory_blocksResource(String reqType) { + AwsProxyResponse resp = get(reqType, "/filter-chain/admin/secret", + ctx -> ctx.addFilter("deny", new DenyFilter()).addMappingForUrlPatterns(null, true, "/*")); + + assertEquals(403, resp.getStatusCode()); + assertEquals(FORBIDDEN_BODY, resp.getBody()); + assertEquals(0, RESOURCE_INVOCATIONS.get()); + } + + /** Registering filters between the constructor and initialize() must keep working. */ + @MethodSource("data") + @ParameterizedTest + void denyingFilter_registeredBeforeInitialize_blocksResource(String reqType) { + AwsProxyResponse resp = getRegisteredBeforeInitialize(reqType, "/filter-chain/admin/secret", + ctx -> ctx.addFilter("deny", new DenyFilter()).addMappingForUrlPatterns(null, true, "/*")); + + assertEquals(403, resp.getStatusCode()); + assertEquals(FORBIDDEN_BODY, resp.getBody()); + assertEquals(0, RESOURCE_INVOCATIONS.get()); + } + + @MethodSource("data") + @ParameterizedTest + void denyingFilter_scopedToProtectedPath_leavesOtherPathsServed(String reqType) { + Consumer startup = ctx -> ctx.addFilter("deny", new DenyFilter()) + .addMappingForUrlPatterns(null, true, "/filter-chain/admin/*"); + + AwsProxyResponse denied = get(reqType, "/filter-chain/admin/secret", startup); + assertEquals(403, denied.getStatusCode()); + assertEquals(FORBIDDEN_BODY, denied.getBody()); + assertEquals(0, RESOURCE_INVOCATIONS.get()); + + AwsProxyResponse served = get(reqType, "/filter-chain/public/attribute", startup); + assertEquals(200, served.getStatusCode()); + assertEquals(NO_ATTRIBUTE, served.getBody()); + } + + /** + * Other ways of writing a path under /filter-chain/admin. With no filters, Jersey serves the protected resources + * for the doubled slashes, the matrix parameters, the trailing slash, the dot segments after /admin, and the + * encoded "?"; the rest currently 404. All of them are kept so the test still holds if Jersey's routing of any + * spelling changes. + */ + public static Stream protectedPathSpellings() { + String[] paths = { + "/filter-chain/%61dmin/secret", + "/filter-chain/adm%69n/secret", + "/filter-chain/public/../admin/secret", + "/filter-chain/./admin/secret", + "/filter-chain//admin/secret", + "/filter-chain/admin;x=1/secret", + "/filter-chain/admin/secret;x=1", + "/filter-chain/ADMIN/secret", + "/FILTER-CHAIN/admin/secret", + "/filter-chain/admin/secret/", + "/filter-chain/admin%2fsecret", + "/filter-chain/public/%2e%2e/admin/secret", + "/filter-chain/admin/../public/attribute", + "/filter-chain/admin;/secret", + "/filter-chain/admin%3Bx=1/secret", + "/filter-chain/admin/;x=1/secret", + "/filter-chain/admin//secret", + "/filter-chain/admin/%2e/secret", + "/filter-chain/admin/x/../secret", + "/filter-chain/admin/secret%3fx=1", + "/filter-chain/;x=1/admin/secret", + "/filter-chain/..;/admin/secret", + }; + return data().stream().flatMap(type -> Arrays.stream(paths).map(path -> Arguments.of(type, path))); + } + + @MethodSource("protectedPathSpellings") + @ParameterizedTest + void scopedDenyingFilter_otherSpellingOfProtectedPath_resourceNotReached(String reqType, String path) { + AwsProxyResponse resp = get(reqType, path, ctx -> ctx.addFilter("deny", new DenyFilter()) + .addMappingForUrlPatterns(null, true, "/filter-chain/admin/*")); + + assertEquals(0, RESOURCE_INVOCATIONS.get(), + path + " on " + reqType + " reached the resource: " + resp.getStatusCode() + " " + resp.getBody()); + } + + @MethodSource("data") + @ParameterizedTest + void passThroughFilter_registeredAfterFactory_runsBeforeResource(String reqType) { + AwsProxyResponse resp = get(reqType, "/filter-chain/public/attribute", + ctx -> ctx.addFilter("attribute", new AttributeFilter("filter")) + .addMappingForUrlPatterns(null, true, "/*")); + + assertEquals(200, resp.getStatusCode()); + assertEquals("filter", resp.getBody()); + assertEquals(1, RESOURCE_INVOCATIONS.get()); + } + + /** Filters run in the order they were registered, whether that was before or after initialize(). */ + @MethodSource("data") + @ParameterizedTest + void filtersRegisteredBeforeAndAfterInitialize_runInRegistrationOrder(String reqType) { + JerseyLambdaContainerHandler handler = uninitializedHandler(reqType); + handler.onStartup(ctx -> ctx.addFilter("first", new AttributeFilter("first")) + .addMappingForUrlPatterns(null, true, "/*")); + handler.initialize(); + handler.onStartup(ctx -> ctx.addFilter("second", new AttributeFilter("second")) + .addMappingForUrlPatterns(null, true, "/*")); + + AwsProxyResponse resp = proxyStream(reqType, handler, "/filter-chain/public/attribute"); + + assertEquals(200, resp.getStatusCode()); + assertEquals("first,second", resp.getBody()); + } + + @MethodSource("data") + @ParameterizedTest + void headerFilter_registeredAfterFactory_headerInResponse(String reqType) { + AwsProxyResponse resp = get(reqType, "/filter-chain/admin/secret", + ctx -> ctx.addFilter("header", new HeaderFilter()).addMappingForUrlPatterns(null, true, "/*")); + + assertEquals(200, resp.getStatusCode()); + assertEquals(SECRET_BODY, resp.getBody()); + assertEquals("ran", resp.getMultiValueHeaders().getFirst(FILTER_HEADER)); + } + + @MethodSource("data") + @ParameterizedTest + void forwardingFilter_registeredAfterFactory_forwardedPathServedByJersey(String reqType) { + AwsProxyResponse resp = get(reqType, "/filter-chain/forward", + ctx -> ctx.addFilter("forward", new ForwardFilter()).addMappingForUrlPatterns(null, true, "/*")); + + assertEquals(200, resp.getStatusCode()); + assertEquals(NO_ATTRIBUTE, resp.getBody()); + assertEquals(1, RESOURCE_INVOCATIONS.get()); + } + + @MethodSource("data") + @ParameterizedTest + void forwardingFilter_registeredBeforeInitialize_forwardedPathServedByJersey(String reqType) { + AwsProxyResponse resp = getRegisteredBeforeInitialize(reqType, "/filter-chain/forward", + ctx -> ctx.addFilter("forward", new ForwardFilter()).addMappingForUrlPatterns(null, true, "/*")); + + assertEquals(200, resp.getStatusCode()); + assertEquals(NO_ATTRIBUTE, resp.getBody()); + assertEquals(1, RESOURCE_INVOCATIONS.get()); + } + + /** + * A second initialize() call, which a handler built with the constructor and then passed to code + * that initializes defensively can produce. It used to throw, because the duplicate filter registration returned + * null. + */ + @MethodSource("data") + @ParameterizedTest + void initializeCalledTwice_requestStillServed(String reqType) { + JerseyLambdaContainerHandler handler = "HTTP_API".equals(reqType) + ? JerseyLambdaContainerHandler.getHttpApiV2ProxyHandler(newApp()) + : JerseyLambdaContainerHandler.getAwsProxyHandler(newApp()); + handler.initialize(); + + AwsProxyResponse resp = proxyStream(reqType, handler, "/filter-chain/admin/secret"); + + assertEquals(200, resp.getStatusCode()); + assertEquals(SECRET_BODY, resp.getBody()); + } + + @MethodSource("data") + @ParameterizedTest + void noFilters_resourceIsServed(String reqType) { + AwsProxyResponse resp = get(reqType, "/filter-chain/admin/secret", ctx -> { }); + + assertEquals(200, resp.getStatusCode()); + assertEquals(SECRET_BODY, resp.getBody()); + assertEquals(1, RESOURCE_INVOCATIONS.get()); + } + + // registers filters on a handler from one of the default factory methods, which initialize it before returning + private AwsProxyResponse get(String reqType, String path, Consumer startup) { + JerseyLambdaContainerHandler handler = "HTTP_API".equals(reqType) + ? JerseyLambdaContainerHandler.getHttpApiV2ProxyHandler(newApp()) + : JerseyLambdaContainerHandler.getAwsProxyHandler(newApp()); + handler.onStartup(startup::accept); + return proxyStream(reqType, handler, path); + } + + private AwsProxyResponse getRegisteredBeforeInitialize(String reqType, String path, Consumer startup) { + JerseyLambdaContainerHandler handler = uninitializedHandler(reqType); + handler.onStartup(startup::accept); + handler.initialize(); + return proxyStream(reqType, handler, path); + } + + // the same readers and writers the factory methods use, without the initialize() call + private static JerseyLambdaContainerHandler uninitializedHandler(String reqType) { + if ("HTTP_API".equals(reqType)) { + return new JerseyLambdaContainerHandler<>( + HttpApiV2ProxyRequest.class, + AwsProxyResponse.class, + new AwsHttpApiV2HttpServletRequestReader(), + new AwsProxyHttpServletResponseWriter(true), + new AwsHttpApiV2SecurityContextWriter(), + new AwsProxyExceptionHandler(), + newApp()); + } + return new JerseyLambdaContainerHandler<>( + AwsProxyRequest.class, + AwsProxyResponse.class, + new AwsProxyHttpServletRequestReader(), + new AwsProxyHttpServletResponseWriter(), + new AwsProxySecurityContextWriter(), + new AwsProxyExceptionHandler(), + newApp()); + } + + private AwsProxyResponse proxyStream(String reqType, JerseyLambdaContainerHandler handler, + String path) { + AwsProxyRequestBuilder request = new AwsProxyRequestBuilder(path, "GET"); + InputStream event; + switch (reqType) { + case "API_GW": + event = request.buildStream(); + break; + case "ALB": + event = request.alb().buildStream(); + break; + case "HTTP_API": + event = request.toHttpApiV2RequestStream(); + break; + default: + throw new RuntimeException("Unknown request type: " + reqType); + } + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try { + handler.proxyStream(event, output, lambdaContext); + return LambdaContainerHandler.getObjectMapper().readValue(output.toByteArray(), AwsProxyResponse.class); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + // each handler locks the ResourceConfig it is given, so every handler needs its own + private static ResourceConfig newApp() { + return new ResourceConfig().register(FilterChainResource.class); + } + + @Path("/filter-chain") + public static class FilterChainResource { + @GET + @Path("/admin/secret") + @Produces(MediaType.TEXT_PLAIN) + public String secret() { + RESOURCE_INVOCATIONS.incrementAndGet(); + return SECRET_BODY; + } + + // a catch-all under the protected prefix, so that every spelling Jersey routes below it reaches a resource + @GET + @Path("/admin/{sub: .*}") + @Produces(MediaType.TEXT_PLAIN) + public String adminCatchAll() { + RESOURCE_INVOCATIONS.incrementAndGet(); + return SECRET_BODY; + } + + @GET + @Path("/public/attribute") + @Produces(MediaType.TEXT_PLAIN) + public String attribute(@Context HttpServletRequest request) { + RESOURCE_INVOCATIONS.incrementAndGet(); + Object value = request.getAttribute(FILTER_ATTRIBUTE); + return value == null ? NO_ATTRIBUTE : value.toString(); + } + } + + /** The Servlet 3.1 Filter interface has no default init or destroy. */ + private abstract static class LifecycleFreeFilter implements Filter { + @Override + public void init(FilterConfig filterConfig) { + } + + @Override + public void destroy() { + } + } + + /** Denies every request it sees and never calls chain.doFilter. */ + private static class DenyFilter extends LifecycleFreeFilter { + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException { + HttpServletResponse httpResponse = (HttpServletResponse) response; + httpResponse.setStatus(403); + httpResponse.setContentType(MediaType.TEXT_PLAIN); + httpResponse.getWriter().write(FORBIDDEN_BODY); + } + } + + /** Appends its name to a request attribute before passing the request on, so the resource can report who ran. */ + private static class AttributeFilter extends LifecycleFreeFilter { + private final String name; + + AttributeFilter(String name) { + this.name = name; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + Object previous = request.getAttribute(FILTER_ATTRIBUTE); + request.setAttribute(FILTER_ATTRIBUTE, previous == null ? name : previous + "," + name); + chain.doFilter(request, response); + } + } + + /** Sets a response header before passing the request on, the way a CORS filter does. */ + private static class HeaderFilter extends LifecycleFreeFilter { + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + ((HttpServletResponse) response).setHeader(FILTER_HEADER, "ran"); + chain.doFilter(request, response); + } + } + + /** + * Forwards to the public resource instead of passing the request on. It is only mapped for REQUEST and ASYNC + * dispatches, so it does not run again for the forward. + */ + private static class ForwardFilter extends LifecycleFreeFilter { + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + request.getRequestDispatcher("/filter-chain/public/attribute").forward(request, response); + } + } +} From f6e697b9de24120ecf248f4b346d3dec1dd17953 Mon Sep 17 00:00:00 2001 From: Chinmay Kawle Date: Tue, 27 Oct 2026 00:25:06 +0000 Subject: [PATCH 2/2] fix(jersey): return the Jersey servlet from getServlet, deprecate doFilter Review feedback on #1723. getServlet() returned null because Jersey ran as a filter and there was no servlet to hand back. It now returns the servlet the handler routes to, which is the one registered on the servlet context. JerseyHandlerFilter is no longer registered as a filter, so its class documentation no longer describes it as one, and doFilter() is deprecated: it runs Jersey and ignores the chain, because Jersey writes the response. The Filter interface stays implemented, since the class is public. --- .../proxy/jersey/JerseyHandlerFilter.java | 21 ++++++++++++++----- .../jersey/JerseyLambdaContainerHandler.java | 7 ++++++- .../proxy/jersey/JerseyFilterChainTest.java | 19 +++++++++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyHandlerFilter.java b/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyHandlerFilter.java index 0d21a484c..0e0b2e2e6 100644 --- a/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyHandlerFilter.java +++ b/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyHandlerFilter.java @@ -52,10 +52,15 @@ import static com.amazonaws.serverless.proxy.RequestReader.LAMBDA_CONTEXT_PROPERTY; /** - * Servlet filter class that calls Jersey's ApplicationHandler. Given a Jax RS Application object, this class - * initializes a Jersey {@link ApplicationHandler} and calls its handle method. Requests are transformed - * to ContainerRequest objects by the {@link #servletRequestToContainerRequest(ServletRequest)} method. + * Calls Jersey's ApplicationHandler. Given a Jax RS Application object, this class initializes a Jersey + * {@link ApplicationHandler} and calls its handle method. Requests are transformed to + * ContainerRequest objects by the {@link #servletRequestToContainerRequest(ServletRequest)} method. * Jersey responses are written directly to the ServletResponse by the {@link JerseyServletResponseWriter}. + * + * {@link JerseyLambdaContainerHandler} invokes this class from the servlet at the end of the filter chain, so that + * application filters run before a JAX-RS resource. It is no longer registered as a servlet filter itself. The + * Filter interface is still implemented because this class is public, but {@link #doFilter} is + * deprecated: Jersey writes the response, so there is nothing for a filter after it to do. */ public class JerseyHandlerFilter implements Filter, Container { public static final String JERSEY_SERVLET_REQUEST_PROPERTY = "com.amazonaws.serverless.jersey.servletRequest"; @@ -86,11 +91,17 @@ public void init(FilterConfig filterConfig) { } + /** + * @deprecated This class is not registered as a filter any more, and nothing in the library calls this method. + * It runs Jersey and ignores the chain, because Jersey writes the response: a filter placed after it could + * only run once the resource had already executed. Register filters on the servlet context instead, through + * {@link com.amazonaws.serverless.proxy.internal.servlet.AwsLambdaServletContainerHandler#onStartup}, and + * they will run before Jersey. + */ + @Deprecated @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { - // Jersey produces the response, so it ends the chain instead of passing the request on. Anything after it - // would only run once the resource had already executed. handle(servletRequest, servletResponse); } diff --git a/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyLambdaContainerHandler.java b/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyLambdaContainerHandler.java index 788331507..b28528cd8 100644 --- a/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyLambdaContainerHandler.java +++ b/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyLambdaContainerHandler.java @@ -232,8 +232,13 @@ public InjectionManager getInjectionManager() { return jerseyFilter.getApplicationHandler().getInjectionManager(); } + /** + * The servlet Jersey runs as, which is the terminal step of the filter chain for every request this handler + * routes. This returned null while Jersey ran as a filter and there was no servlet to hand back. + * @return The Jersey servlet + */ public Servlet getServlet() { - return null; + return jerseyServlet; } //------------------------------------------------------------- diff --git a/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/JerseyFilterChainTest.java b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/JerseyFilterChainTest.java index 714761f7a..9bea25315 100644 --- a/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/JerseyFilterChainTest.java +++ b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/JerseyFilterChainTest.java @@ -19,6 +19,7 @@ import com.amazonaws.serverless.proxy.internal.servlet.AwsHttpApiV2HttpServletRequestReader; import com.amazonaws.serverless.proxy.internal.servlet.AwsProxyHttpServletRequestReader; import com.amazonaws.serverless.proxy.internal.servlet.AwsProxyHttpServletResponseWriter; +import com.amazonaws.serverless.proxy.internal.servlet.AwsServletRegistration; import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; import com.amazonaws.serverless.proxy.model.AwsProxyRequest; @@ -33,6 +34,7 @@ import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.FilterConfig; +import javax.servlet.Servlet; import javax.servlet.ServletContext; import javax.servlet.ServletException; import javax.servlet.ServletRequest; @@ -55,6 +57,8 @@ import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; /** * Servlet filters registered on a Jersey handler must run before the JAX-RS resource, as they would in front of a @@ -249,6 +253,21 @@ void initializeCalledTwice_requestStillServed(String reqType) { assertEquals(SECRET_BODY, resp.getBody()); } + /** The handler hands back the servlet it routes to, which is the one registered on the servlet context. */ + @MethodSource("data") + @ParameterizedTest + void getServlet_returnsTheServletRegisteredOnTheContext(String reqType) { + JerseyLambdaContainerHandler handler = "HTTP_API".equals(reqType) + ? JerseyLambdaContainerHandler.getHttpApiV2ProxyHandler(newApp()) + : JerseyLambdaContainerHandler.getAwsProxyHandler(newApp()); + + Servlet registered = ((AwsServletRegistration) handler.getServletContext() + .getServletRegistrations().values().iterator().next()).getServlet(); + + assertNotNull(handler.getServlet()); + assertSame(registered, handler.getServlet()); + } + @MethodSource("data") @ParameterizedTest void noFilters_resourceIsServed(String reqType) {