This is an automated email from the ASF dual-hosted git repository. joerghoh pushed a commit to branch SLING-13364 in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-engine.git
commit 50f8ae73d09ad19eb80b0342324f6eea2713383c Author: Joerg Hoh <[email protected]> AuthorDate: Fri Sep 25 12:34:15 2026 +0200 SLING-13364 if the parameter parsing fails throw a statuscode 400 --- .../engine/impl/SlingRequestProcessorImpl.java | 8 ++ .../engine/impl/parameters/ParameterSupport.java | 34 +++-- .../impl/parameters/RequestPartsIterator.java | 4 +- .../parameters/SlingParameterParseException.java | 36 +++++ .../engine/impl/SlingRequestProcessorImplTest.java | 155 +++++++++++++++++++++ .../impl/parameters/ParameterSupportTest.java | 62 +++++++++ .../impl/parameters/RequestPartsIteratorTest.java | 114 +++++++++++++++ 7 files changed, 403 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/apache/sling/engine/impl/SlingRequestProcessorImpl.java b/src/main/java/org/apache/sling/engine/impl/SlingRequestProcessorImpl.java index 27451b6..48137e0 100644 --- a/src/main/java/org/apache/sling/engine/impl/SlingRequestProcessorImpl.java +++ b/src/main/java/org/apache/sling/engine/impl/SlingRequestProcessorImpl.java @@ -67,6 +67,7 @@ import org.apache.sling.engine.impl.filter.ServletFilterManager.FilterChainType; import org.apache.sling.engine.impl.filter.SlingComponentFilterChain; import org.apache.sling.engine.impl.helper.SlingServletContext; import org.apache.sling.engine.impl.parameters.ParameterSupport; +import org.apache.sling.engine.impl.parameters.SlingParameterParseException; import org.apache.sling.engine.impl.request.ContentData; import org.apache.sling.engine.impl.request.DispatchingInfo; import org.apache.sling.engine.impl.request.RequestData; @@ -311,6 +312,13 @@ public class SlingRequestProcessorImpl implements SlingRequestProcessor { log.debug("service: Resource {} not found", rnfe.getResource()); handleError(HttpServletResponse.SC_NOT_FOUND, rnfe.getMessage(), request, response); + } catch (final SlingParameterParseException sppe) { + // the request parameters could not be parsed completely/within + // configured limits; the request itself is malformed, not the + // server, so send this exception as a 400 status + log.debug("service: Failed parsing request parameters", sppe); + handleError(HttpServletResponse.SC_BAD_REQUEST, sppe.getMessage(), request, response); + } catch (final SlingException se) { // send this exception as is (albeit unwrapping and wrapped // exception. diff --git a/src/main/java/org/apache/sling/engine/impl/parameters/ParameterSupport.java b/src/main/java/org/apache/sling/engine/impl/parameters/ParameterSupport.java index 98bed3c..a4f0631 100644 --- a/src/main/java/org/apache/sling/engine/impl/parameters/ParameterSupport.java +++ b/src/main/java/org/apache/sling/engine/impl/parameters/ParameterSupport.java @@ -123,6 +123,12 @@ public class ParameterSupport { private boolean requestDataUsed; + /** + * Set when parsing the request parameters failed. The failure is cached + * and rethrown on every subsequent parameter access. + */ + private SlingParameterParseException parseFailure; + /** * Returns the {@code ParameterSupport} instance supporting request * parameter for the give {@code request}. For a single request only a @@ -240,6 +246,9 @@ public class ParameterSupport { } private ParameterMap getRequestParameterMapInternal() { + if (this.parseFailure != null) { + throw this.parseFailure; + } if (this.postParameterMap == null) { // SLING-508 Try to force servlet container to decode parameters @@ -272,11 +281,11 @@ public class ParameterSupport { Util.parseQueryString(input, Util.ENCODING_DIRECT, parameters, false); addContainerParameters = checkForAdditionalParameters; } catch (IllegalArgumentException e) { - this.log.error("getRequestParameterMapInternal: Error parsing request", e); + throw failParse("Error parsing query string", e); } catch (UnsupportedEncodingException e) { throw new SlingUnsupportedEncodingException(e); } catch (IOException e) { - this.log.error("getRequestParameterMapInternal: Error parsing request", e); + throw failParse("Error parsing query string", e); } useFallback = false; } else { @@ -294,11 +303,11 @@ public class ParameterSupport { Util.parseQueryString(input, encoding, parameters, false); addContainerParameters = checkForAdditionalParameters; } catch (IllegalArgumentException e) { - this.log.error("getRequestParameterMapInternal: Error parsing request", e); + throw failParse("Error parsing request body", e); } catch (UnsupportedEncodingException e) { throw new SlingUnsupportedEncodingException(e); } catch (IOException e) { - this.log.error("getRequestParameterMapInternal: Error parsing request", e); + throw failParse("Error parsing request body", e); } this.requestDataUsed = true; useFallback = false; @@ -316,8 +325,7 @@ public class ParameterSupport { this.log.debug( "getRequestParameterMapInternal: Iterator<javax.servlet.http.Part> available as request attribute named request-parts-iterator"); } catch (final FileUploadException | IOException e) { - this.log.error( - "getRequestParameterMapInternal: Error parsing multipart streamed request", e); + throw failParse("Error parsing multipart streamed request", e); } // The request data has been passed to the RequestPartsIterator, hence from a RequestParameter // pov its been used, and must not be used again. @@ -347,6 +355,16 @@ public class ParameterSupport { return this.postParameterMap; } + /** + * Records a request-parameter parse failure and returns the exception to + * throw. Parse errors must be request-fatal. + */ + private SlingParameterParseException failParse(final String message, final Exception cause) { + this.log.error("getRequestParameterMapInternal: {}", message, cause); + this.parseFailure = new SlingParameterParseException(message, cause); + return this.parseFailure; + } + /** * Checks to see if there is an upload mode header or uploadmode parameter indicating the request is * to be streamed from the client to the server. @@ -431,12 +449,12 @@ public class ParameterSupport { upload.setFileCountMax(ParameterSupport.maxFileCount); final RequestContext rc = this.getMultiPartContext(); - // Parse the request + // Parse the request. A FileUploadException is request-fatal. List<?> /* FileItem */ items = null; try { items = upload.parseRequest(rc); } catch (FileUploadException fue) { - this.log.error("parseMultiPartPost: Error parsing request", fue); + throw failParse("Error parsing multipart request", fue); } if (items != null && items.size() > 0) { diff --git a/src/main/java/org/apache/sling/engine/impl/parameters/RequestPartsIterator.java b/src/main/java/org/apache/sling/engine/impl/parameters/RequestPartsIterator.java index f018b63..ffad79f 100644 --- a/src/main/java/org/apache/sling/engine/impl/parameters/RequestPartsIterator.java +++ b/src/main/java/org/apache/sling/engine/impl/parameters/RequestPartsIterator.java @@ -63,8 +63,8 @@ public class RequestPartsIterator implements Iterator<Part> { return itemIterator.hasNext(); } catch (final FileUploadException | IOException e) { LOG.error("hasNext Item failed cause:" + e.getMessage(), e); + throw new SlingParameterParseException("Error reading next part from the request stream", e); } - return false; } @Override @@ -73,8 +73,8 @@ public class RequestPartsIterator implements Iterator<Part> { return new StreamedRequestPart(itemIterator.next()); } catch (final FileUploadException | IOException e) { LOG.error("next Item failed cause:" + e.getMessage(), e); + throw new SlingParameterParseException("Error reading next part from the request stream", e); } - return null; } @Override diff --git a/src/main/java/org/apache/sling/engine/impl/parameters/SlingParameterParseException.java b/src/main/java/org/apache/sling/engine/impl/parameters/SlingParameterParseException.java new file mode 100644 index 0000000..6315fee --- /dev/null +++ b/src/main/java/org/apache/sling/engine/impl/parameters/SlingParameterParseException.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * http://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 org.apache.sling.engine.impl.parameters; + +/** + * Unchecked exception indicating that the request parameters could not be + * parsed completely and within the configured limits. Request processing must + * not continue with a silently truncated parameter map. + * <p> + * Deliberately not an {@code IllegalArgumentException} so it is never + * swallowed by lenient parse-error handling. + */ +public class SlingParameterParseException extends IllegalStateException { + + private static final long serialVersionUID = 1L; + + public SlingParameterParseException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/src/test/java/org/apache/sling/engine/impl/SlingRequestProcessorImplTest.java b/src/test/java/org/apache/sling/engine/impl/SlingRequestProcessorImplTest.java new file mode 100644 index 0000000..2a4a160 --- /dev/null +++ b/src/test/java/org/apache/sling/engine/impl/SlingRequestProcessorImplTest.java @@ -0,0 +1,155 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * http://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 org.apache.sling.engine.impl; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.lang.reflect.Field; + +import jakarta.servlet.Servlet; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.sling.api.SlingJakartaHttpServletRequest; +import org.apache.sling.api.SlingJakartaHttpServletResponse; +import org.apache.sling.api.request.RequestProgressTracker; +import org.apache.sling.api.resource.Resource; +import org.apache.sling.api.resource.ResourceMetadata; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.servlets.ServletResolver; +import org.apache.sling.engine.impl.filter.FilterHandle; +import org.apache.sling.engine.impl.filter.ServletFilterManager; +import org.apache.sling.engine.impl.filter.ServletFilterManager.FilterChainType; +import org.apache.sling.engine.impl.parameters.SlingParameterParseException; +import org.jetbrains.annotations.NotNull; +import org.junit.Before; +import org.junit.Test; + +import static jakarta.servlet.http.HttpServletResponse.SC_BAD_REQUEST; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link SlingRequestProcessorImpl}, in particular the + * {@code SlingParameterParseException} to HTTP 400 mapping performed in + * {@code doProcessRequest}. + */ +public class SlingRequestProcessorImplTest { + + private SlingRequestProcessorImpl processor; + private ServletFilterManager filterManager; + private SlingJakartaHttpServletRequest request; + private SlingJakartaHttpServletResponse response; + private StringWriter responseBody; + + @Before + public void setup() throws Exception { + processor = new SlingRequestProcessorImpl(); + + filterManager = mock(ServletFilterManager.class); + when(filterManager.getFilters(FilterChainType.ERROR)).thenReturn(new FilterHandle[0]); + setField(processor, "filterManager", filterManager); + + request = mock(SlingJakartaHttpServletRequest.class); + when(request.getRequestProgressTracker()).thenReturn(mock(RequestProgressTracker.class)); + when(request.getRequestURI()).thenReturn("/test"); + + response = mock(SlingJakartaHttpServletResponse.class); + responseBody = new StringWriter(); + when(response.getWriter()).thenReturn(new PrintWriter(responseBody)); + } + + private static void setField(final Object target, final String name, final Object value) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + + @Test + public void testHandleErrorWithNullMessageStillSetsStatus() throws Exception { + processor.handleError(SC_BAD_REQUEST, null, request, response); + + verify(response).setStatus(SC_BAD_REQUEST); + responseBody.flush(); + assertTrue(responseBody.toString().contains(String.valueOf(SC_BAD_REQUEST))); + } + + /** + * End-to-end regression test for SLING-13364: a + * {@link SlingParameterParseException} raised while servicing a request + * (here simulated by the resolved servlet, standing in for the parameter + * parsing that {@code RequestData.service} triggers indirectly) must be + * caught by {@code doProcessRequest} and mapped to a 400 response, + * instead of propagating as a server error or being swallowed. + */ + @Test + public void testDoProcessRequestMapsParameterParseExceptionToBadRequest() throws Exception { + final Servlet servlet = mock(Servlet.class); + doThrow(new SlingParameterParseException("Error parsing query string", new IllegalArgumentException("bad"))) + .when(servlet) + .service(any(ServletRequest.class), any(ServletResponse.class)); + + final Resource resource = getMockedResource("/content/test"); + + final ResourceResolver resourceResolver = mock(ResourceResolver.class); + when(resourceResolver.resolve(any(HttpServletRequest.class), anyString())) + .thenReturn(resource); + + final ServletResolver servletResolver = mock(ServletResolver.class); + when(servletResolver.resolve(any(SlingJakartaHttpServletRequest.class))).thenReturn(servlet); + setField(processor, "servletResolver", servletResolver); + + // no request/component filters: the chain falls straight through to + // the resolved servlet, whose service() call raises the exception + when(filterManager.getFilters(FilterChainType.REQUEST)).thenReturn(new FilterHandle[0]); + when(filterManager.getFilters(FilterChainType.COMPONENT)).thenReturn(new FilterHandle[0]); + + final HttpServletRequest httpServletRequest = mock(HttpServletRequest.class); + when(httpServletRequest.getRequestURI()).thenReturn("/content/test"); + when(httpServletRequest.getRequestURL()).thenReturn(new StringBuffer("http://localhost/content/test")); + when(httpServletRequest.getContextPath()).thenReturn(""); + when(httpServletRequest.getServletPath()).thenReturn(""); + when(httpServletRequest.getMethod()).thenReturn("GET"); + + final HttpServletResponse httpServletResponse = mock(HttpServletResponse.class); + final StringWriter writer = new StringWriter(); + when(httpServletResponse.getWriter()).thenReturn(new PrintWriter(writer)); + + processor.doProcessRequest(httpServletRequest, httpServletResponse, resourceResolver); + + verify(httpServletResponse).setStatus(SC_BAD_REQUEST); + writer.flush(); + assertTrue(writer.toString().contains("Error parsing query string")); + } + + private static @NotNull Resource getMockedResource(final @NotNull String path) { + final Resource resource = mock(Resource.class); + when(resource.getPath()).thenReturn(path); + final ResourceMetadata resourceMetadata = mock(ResourceMetadata.class); + when(resource.getResourceMetadata()).thenReturn(resourceMetadata); + when(resourceMetadata.getResolutionPathInfo()).thenReturn(path); + return resource; + } +} diff --git a/src/test/java/org/apache/sling/engine/impl/parameters/ParameterSupportTest.java b/src/test/java/org/apache/sling/engine/impl/parameters/ParameterSupportTest.java index 3a7b241..3a290ba 100644 --- a/src/test/java/org/apache/sling/engine/impl/parameters/ParameterSupportTest.java +++ b/src/test/java/org/apache/sling/engine/impl/parameters/ParameterSupportTest.java @@ -30,6 +30,7 @@ import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +import static org.junit.Assert.fail; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -99,6 +100,67 @@ public class ParameterSupportTest { assertNull(parameterSupport.getParameter("a")); } + @Test(expected = SlingParameterParseException.class) + public void testMalformedQueryStringIsRejected() { + final HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getMethod()).thenReturn("GET"); + // a malformed escape aborts the parse after 'a' was already added; + // 'c' must not be silently dropped while processing continues + when(request.getQueryString()).thenReturn("a=1&b=%zz&c=3"); + + final ParameterSupport support = ParameterSupport.getInstance(request); + support.getParameter("c"); + } + + @Test(expected = SlingParameterParseException.class) + public void testMalformedQueryStringFailureIsCachedAcrossAccessors() { + final HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getMethod()).thenReturn("GET"); + when(request.getQueryString()).thenReturn("a=1&b=%zz&c=3"); + + final ParameterSupport support = ParameterSupport.getInstance(request); + try { + support.getParameter("c"); + fail("Expected the first access to throw SlingParameterParseException"); + } catch (SlingParameterParseException expected) { + // expected on first access; the point of this test is that the + // failure must be cached and rethrown on the *next* access too - + // even via a different accessor method - instead of silently + // continuing with a partial map + } + support.getRequestParameterMap(); + } + + @Test(expected = SlingParameterParseException.class) + public void testMalformedFormEncodedBodyIsRejected() throws IOException { + final HttpServletRequest request = postRequest("application/x-www-form-urlencoded", "a=1&b=%zz&c=3"); + + final ParameterSupport support = ParameterSupport.getInstance(request); + support.getParameter("c"); + } + + @Test(expected = SlingParameterParseException.class) + public void testMalformedMultipartBodyIsRejected() throws IOException { + // "multipart/form-data" without a boundary parameter is structurally + // malformed and rejected outright by commons-fileupload + final HttpServletRequest request = postRequest("multipart/form-data", "irrelevant body content"); + + final ParameterSupport support = ParameterSupport.getInstance(request); + support.getParameter("anything"); + } + + @Test(expected = SlingParameterParseException.class) + public void testMalformedStreamedMultipartBodyIsRejected() throws IOException { + // same malformed multipart body, but with streamed upload mode + // requested - the RequestPartsIterator construction itself must fail + // closed instead of silently producing an empty part sequence + final HttpServletRequest request = postRequest("multipart/form-data", "irrelevant body content"); + when(request.getHeader(ParameterSupport.SLING_UPLOADMODE_HEADER)).thenReturn(ParameterSupport.STREAM_UPLOAD); + + final ParameterSupport support = ParameterSupport.getInstance(request); + support.getParameter("anything"); + } + private static HttpServletRequest postRequest(final String contentType, final String body) throws IOException { final HttpServletRequest request = mock(HttpServletRequest.class); when(request.getMethod()).thenReturn("POST"); diff --git a/src/test/java/org/apache/sling/engine/impl/parameters/RequestPartsIteratorTest.java b/src/test/java/org/apache/sling/engine/impl/parameters/RequestPartsIteratorTest.java new file mode 100644 index 0000000..8a05730 --- /dev/null +++ b/src/test/java/org/apache/sling/engine/impl/parameters/RequestPartsIteratorTest.java @@ -0,0 +1,114 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * http://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 org.apache.sling.engine.impl.parameters; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.lang.reflect.Field; + +import org.apache.commons.fileupload.FileItemIterator; +import org.apache.commons.fileupload.FileUploadException; +import org.apache.commons.fileupload.RequestContext; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Regression tests for SLING-13364: a stream turning malformed + * mid-body must surface as an error from {@link RequestPartsIterator} + * instead of silently truncating the part sequence. + */ +public class RequestPartsIteratorTest { + + @Test(expected = SlingParameterParseException.class) + public void testHasNextIsRejectedOnFileUploadException() throws Exception { + final RequestPartsIterator iterator = newIterator(); + final FileItemIterator delegate = mock(FileItemIterator.class); + when(delegate.hasNext()).thenThrow(new FileUploadException("boom")); + injectDelegate(iterator, delegate); + + iterator.hasNext(); + } + + @Test(expected = SlingParameterParseException.class) + public void testHasNextIsRejectedOnIOException() throws Exception { + final RequestPartsIterator iterator = newIterator(); + final FileItemIterator delegate = mock(FileItemIterator.class); + when(delegate.hasNext()).thenThrow(new IOException("connection reset")); + injectDelegate(iterator, delegate); + + iterator.hasNext(); + } + + @Test(expected = SlingParameterParseException.class) + public void testNextIsRejectedOnFileUploadException() throws Exception { + final RequestPartsIterator iterator = newIterator(); + final FileItemIterator delegate = mock(FileItemIterator.class); + when(delegate.next()).thenThrow(new FileUploadException("boom")); + injectDelegate(iterator, delegate); + + iterator.next(); + } + + @Test(expected = SlingParameterParseException.class) + public void testNextIsRejectedOnIOException() throws Exception { + final RequestPartsIterator iterator = newIterator(); + final FileItemIterator delegate = mock(FileItemIterator.class); + when(delegate.next()).thenThrow(new IOException("connection reset")); + injectDelegate(iterator, delegate); + + iterator.next(); + } + + @Test + public void testHasNextPassesThroughCleanEndOfStream() throws Exception { + // sanity check: a well-formed, empty multipart body must not trigger + // the reject-on-error path at all + final RequestPartsIterator iterator = newIterator(); + + assertFalse(iterator.hasNext()); + } + + /** + * Builds a real {@link RequestPartsIterator} over a trivial, well-formed, + * empty multipart body so that construction itself succeeds; the + * commons-fileupload delegate is then swapped out via reflection to + * simulate a stream failing mid-read, which is otherwise very hard to + * trigger deterministically with a hand-crafted byte stream. + */ + private static RequestPartsIterator newIterator() throws FileUploadException, IOException { + final String boundary = "X"; + final String body = "--" + boundary + "--\r\n"; + final RequestContext context = mock(RequestContext.class); + when(context.getContentType()).thenReturn("multipart/form-data; boundary=" + boundary); + when(context.getCharacterEncoding()).thenReturn("UTF-8"); + when(context.getContentLength()).thenReturn(body.length()); + when(context.getInputStream()).thenReturn(new ByteArrayInputStream(body.getBytes(Util.ENCODING_DIRECT))); + return new RequestPartsIterator(context); + } + + private static void injectDelegate(final RequestPartsIterator iterator, final FileItemIterator delegate) + throws Exception { + final Field field = RequestPartsIterator.class.getDeclaredField("itemIterator"); + field.setAccessible(true); + field.set(iterator, delegate); + } +}
