Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ out/
.env.edge

.DS_Store
tmp/

# BlueJ files
*.ctxt
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package au.org.aodn.oceancurrent.configuration;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.List;

/**
* Lets a trailing slash resolve to the same handler, e.g.
* {@code /metadata/latest-dates/sixDaySst-sst/} behaves like
* {@code /metadata/latest-dates/sixDaySst-sst}.
*
* <p>Spring Boot 3 dropped trailing-slash matching by default, but upstream proxies
* (e.g. AWS Amplify) can append one. We strip it by wrapping the request and
* continuing the same chain — no redirect (which could loop if the proxy re-adds
* the slash) and no deprecated path-match config. Running first means routing and
* security both see the trimmed path. Swagger, API-docs and actuator paths are left alone.
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TrailingSlashNormalizationFilter extends OncePerRequestFilter {

// Framework-managed paths whose trailing-slash handling we must not interfere with:
// springdoc UI/docs/webjars and the actuator base-path (management.endpoints.web.base-path).
private static final List<String> EXCLUDED_PREFIXES =
List.of("/swagger-ui", "/v3/api-docs", "/webjars", "/manage");

@Override
protected boolean shouldNotFilter(@NonNull HttpServletRequest request) {
String withinContext = request.getRequestURI().substring(request.getContextPath().length());
if (withinContext.length() <= 1 || !withinContext.endsWith("/")) {
return true;
}
return EXCLUDED_PREFIXES.stream().anyMatch(withinContext::startsWith);
}
Comment thread
weited marked this conversation as resolved.

@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
String trimmedUri = request.getRequestURI().substring(0, request.getRequestURI().length() - 1);
filterChain.doFilter(new HttpServletRequestWrapper(request) {
@Override
public String getRequestURI() {
return trimmedUri;
}
}, response);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;

import java.util.Collections;
import java.util.List;
Expand All @@ -36,6 +37,17 @@ public ErrorResponse handleResourceNotFoundException(ResourceNotFoundException e
);
}

@ExceptionHandler(NoResourceFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNoResourceFoundException(NoResourceFoundException ex) {
log.debug("No endpoint found for path: {}", ex.getResourcePath());

Comment thread
weited marked this conversation as resolved.
return new ErrorResponse(
HttpStatus.NOT_FOUND.getReasonPhrase(),
List.of("The requested endpoint does not exist.")
);
Comment thread
weited marked this conversation as resolved.
}

@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleArgumentNotValid(MethodArgumentNotValidException ex) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package au.org.aodn.oceancurrent.configuration;

import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;

import java.util.Objects;

import static org.junit.jupiter.api.Assertions.assertEquals;

class TrailingSlashNormalizationFilterTest {

private final TrailingSlashNormalizationFilter filter = new TrailingSlashNormalizationFilter();

@Test
void trailingSlash_isTrimmedFromWrappedRequest() throws Exception {
HttpServletRequest forwarded = filterAndCapture("/metadata/latest-dates/sixDaySst-sst/");

assertEquals("/metadata/latest-dates/sixDaySst-sst", forwarded.getRequestURI());
}

@Test
void pathWithoutTrailingSlash_isLeftUnchanged() throws Exception {
HttpServletRequest forwarded = filterAndCapture("/metadata/latest-dates/sixDaySst-sst");

assertEquals("/metadata/latest-dates/sixDaySst-sst", forwarded.getRequestURI());
}

@Test
void swaggerUiPath_isNotTrimmed() throws Exception {
HttpServletRequest forwarded = filterAndCapture("/swagger-ui/");

assertEquals("/swagger-ui/", forwarded.getRequestURI());
}

@Test
void apiDocsPath_isNotTrimmed() throws Exception {
HttpServletRequest forwarded = filterAndCapture("/v3/api-docs/");

assertEquals("/v3/api-docs/", forwarded.getRequestURI());
}

@Test
void trailingSlash_onImageListPath_isTrimmed() throws Exception {
HttpServletRequest forwarded = filterAndCapture("/metadata/image-list/sixDaySst-sst/");

assertEquals("/metadata/image-list/sixDaySst-sst", forwarded.getRequestURI());
}

@Test
void trailingSlash_withQueryString_trimsPathAndKeepsQuery() throws Exception {
MockHttpServletRequest request =
new MockHttpServletRequest("GET", "/metadata/image-list/sixDaySst-sst/");
request.setQueryString("region=NW");
request.setParameter("region", "NW");
MockFilterChain chain = new MockFilterChain();

filter.doFilter(request, new MockHttpServletResponse(), chain);

HttpServletRequest forwarded = (HttpServletRequest) Objects.requireNonNull(chain.getRequest());
assertEquals("/metadata/image-list/sixDaySst-sst", forwarded.getRequestURI());
assertEquals("region=NW", forwarded.getQueryString());
assertEquals("NW", forwarded.getParameter("region"));
}

@Test
void actuatorPath_isNotTrimmed() throws Exception {
HttpServletRequest forwarded = filterAndCapture("/manage/health/");

assertEquals("/manage/health/", forwarded.getRequestURI());
}

@Test
void rootPath_isLeftUnchanged() throws Exception {
HttpServletRequest forwarded = filterAndCapture("/");

assertEquals("/", forwarded.getRequestURI());
}

@Test
void contextRoot_withContextPath_isLeftUnchanged() throws Exception {
// With context-path /api/v1, a request to the context root (/api/v1/) must not be
// trimmed to /api/v1, since within the context that is the root path "/".
HttpServletRequest forwarded = filterAndCaptureWithContext("/api/v1", "/api/v1/");

assertEquals("/api/v1/", forwarded.getRequestURI());
}

@Test
void trailingSlash_withContextPath_isTrimmed() throws Exception {
HttpServletRequest forwarded =
filterAndCaptureWithContext("/api/v1", "/api/v1/metadata/image-list/sixDaySst-sst/");

assertEquals("/api/v1/metadata/image-list/sixDaySst-sst", forwarded.getRequestURI());
}

private HttpServletRequest filterAndCapture(String requestUri) throws Exception {
return filterAndCaptureWithContext("", requestUri);
}

private HttpServletRequest filterAndCaptureWithContext(String contextPath, String requestUri) throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri);
request.setContextPath(contextPath);
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();

filter.doFilter(request, response, chain);

return (HttpServletRequest) Objects.requireNonNull(chain.getRequest());
}
Comment thread
weited marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,12 @@ void getLatestArgoDate_ServiceException_ReturnsInternalServerError() throws Exce
mockMvc.perform(get("/metadata/latest-dates/argo"))
.andExpect(status().isInternalServerError());
}

@Test
void unmatchedPath_ReturnsNotFound() throws Exception {
// A path that matches no handler must surface as 404, not be swallowed by the
// catch-all Exception handler as 500.
mockMvc.perform(get("/metadata/no/such/endpoint"))
.andExpect(status().isNotFound());
}
}