Skip to content

Commit

Permalink
Fix handling of forwarded headers and servlet context path for swagge…
Browse files Browse the repository at this point in the history
…r ui

In order for the swagger ui to automatically respect the
`X-Forwarded-Prefix` header, the config property
`server.forward-headers-strategy` must be `framework` when using Tomcat.

Additionally, the spring-doc swagger-ui won't respect the servlet
context path (i.e. `/acl`) when building URLs and the
`X-Forwarded-Prefix` is received (and handled by
`org.springframework.web.filter.ForwardedHeaderFilter` as result of
`server.forward-headers-strategy=framework`.

This patch handles the servlet context suffixing at
`SpringDocHomeRedirectController` and `SpringDocAutoConfiguration`.
  • Loading branch information
groldan committed Mar 29, 2024
1 parent 3adba7a commit cb3f987
Show file tree
Hide file tree
Showing 8 changed files with 94 additions and 110 deletions.
3 changes: 3 additions & 0 deletions compose/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,16 @@ services:
- PG_USER=acl
- PG_PASSWORD=acls3cr3t
- SPRING_PROFILES_ACTIVE=logging_debug_requests
# uncomment for remote debugging
#- JAVA_OPTS=-Xdebug -agentlib:jdwp=transport=dt_socket,address=*:15005,server=y,suspend=n
depends_on:
acldb:
condition: service_healthy
required: true
ports:
- 8080:8080
- 8081:8081
- 15005:15005
deploy:
resources:
limits:
Expand Down
2 changes: 1 addition & 1 deletion compose/gateway-service.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
geoserver.base-path: ${geoserver_base_path:}

targets.acl: http://10.0.0.71:8080
targets.acl: http://acl:8080

server:
forward-headers-strategy: framework
Expand Down
14 changes: 0 additions & 14 deletions src/artifacts/api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -56,21 +56,7 @@
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<!--
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
-->
</dependency>
<!--
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-undertow</artifactId>
</dependency>
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import org.springdoc.core.providers.SpringWebProvider;
import org.springdoc.webmvc.ui.SwaggerConfig;
import org.springdoc.webmvc.ui.SwaggerWelcomeWebMvc;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.web.context.request.NativeWebRequest;
Expand All @@ -27,128 +28,82 @@
@Slf4j(topic = "org.geoserver.acl.autoconfigure.springdoc")
public class SpringDocAutoConfiguration {

private @Value("${server.servlet.context-path:/}") String servletContextPath;

@Bean
SpringDocHomeRedirectController homeRedirectController(NativeWebRequest req) {
return new SpringDocHomeRedirectController(req);
return new SpringDocHomeRedirectController(req, servletContextPath);
}

@Bean
ServerBaseUrlCustomizer xForwardedPrefixAwareServerBaseUrlCustomizer(NativeWebRequest req) {
return new XForwardedPrefixBaseUrlCustomizer(req);
ServerBaseUrlCustomizer xForwardedPrefixAwareServerBaseUrlCustomizer() {
return new ServletContextSuffixingBaseUrlCustomizer(servletContextPath);
}

/**
* Override the one defined in {@link SwaggerConfig} to apply the{@literal X-Forwarded-Prefix}
* request header prefix to the swagger ui config urls
* Override the one defined in {@link SwaggerConfig} to append the servlet-context path suffix
* to URLs if they don't have it
*/
@Bean
SwaggerWelcomeWebMvc xForwardedPrefixAwareSwaggerWelcome(
SwaggerUiConfigProperties swaggerUiConfig,
SpringDocConfigProperties springDocConfigProperties,
SwaggerUiConfigParameters swaggerUiConfigParameters,
SpringWebProvider springWebProvider,
NativeWebRequest nativeWebRequest) {
return new XForwardedPrefixAwareSwaggerWelcomeWebMvc(
SpringWebProvider springWebProvider) {
return new ServletContextSuffixingSwaggerWelcomeWebMvc(
swaggerUiConfig,
springDocConfigProperties,
swaggerUiConfigParameters,
springWebProvider,
nativeWebRequest);
servletContextPath);
}

/**
* Springdoc {@link ServerBaseUrlCustomizer} to apply the {@literal X-Forwarded-Prefix} request
* header prefix to the base server url presented in the swagger-
*/
@RequiredArgsConstructor
static class XForwardedPrefixBaseUrlCustomizer implements ServerBaseUrlCustomizer {
private final @NonNull NativeWebRequest req;
static class ServletContextSuffixingBaseUrlCustomizer implements ServerBaseUrlCustomizer {
private final @NonNull String servletContextPath;

@Override
public String customize(String serverBaseUrl) {
return customizeUrl(serverBaseUrl, req);
String url = serverBaseUrl;
String path = URI.create(serverBaseUrl).getPath();
if (path.endsWith("/")) path = path.substring(0, path.length() - 1);
if (!path.endsWith(servletContextPath)) {
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(serverBaseUrl);
builder.path(servletContextPath);
url = builder.build().toString();
}

return url;
}
}

static class XForwardedPrefixAwareSwaggerWelcomeWebMvc extends SwaggerWelcomeWebMvc {
static class ServletContextSuffixingSwaggerWelcomeWebMvc extends SwaggerWelcomeWebMvc {

private final NativeWebRequest nativeWebRequest;
private final @NonNull String servletContextPath;

public XForwardedPrefixAwareSwaggerWelcomeWebMvc(
public ServletContextSuffixingSwaggerWelcomeWebMvc(
SwaggerUiConfigProperties swaggerUiConfig,
SpringDocConfigProperties springDocConfigProperties,
SwaggerUiConfigParameters swaggerUiConfigParameters,
SpringWebProvider springWebProvider,
NativeWebRequest nativeWebRequest) {
String contextPath) {
super(
swaggerUiConfig,
springDocConfigProperties,
swaggerUiConfigParameters,
springWebProvider);
this.nativeWebRequest = nativeWebRequest;
}

@Override
protected String buildApiDocUrl() {
var url = super.buildApiDocUrl();
url = applyForwardedPrefix(url, nativeWebRequest);
log.debug("buildApiDocUrl: {}", url);
return url;
}

@Override
protected String buildSwaggerConfigUrl() {
var url = super.buildSwaggerConfigUrl();
url = applyForwardedPrefix(url, nativeWebRequest);
log.debug("buildSwaggerConfigUrl: {}", url);
return url;
this.servletContextPath = contextPath;
}

@Override
protected String buildUrl(String contextPath, final String docsUrl) {
var url = super.buildUrl(contextPath, docsUrl);
url = applyForwardedPrefix(url, nativeWebRequest);
log.debug("buildUrl({}, {}): {}", contextPath, docsUrl, url);
return url;
}
String realContextPath = contextPath;

@Override
protected String buildUrlWithContextPath(String swaggerUiUrl) {
var url = super.buildUrlWithContextPath(swaggerUiUrl);
url = applyForwardedPrefix(url, nativeWebRequest);
log.debug("buildUrlWithContextPath({}): {}", swaggerUiUrl, url);
if (!realContextPath.endsWith(this.servletContextPath))
realContextPath += this.servletContextPath;
var url = super.buildUrl(realContextPath, docsUrl);
log.debug("buildUrl({}, {}): {}", contextPath, docsUrl, url);
return url;
}
}

private static String applyForwardedPrefix(String path, NativeWebRequest req) {
String prefix = getFirstHeader(req, "X-Forwarded-Prefix");
if (null != prefix && !path.startsWith(prefix)) {
return prefix + path;
}
return path;
}

private static String getFirstHeader(NativeWebRequest req, String headerName) {
String[] headerValues = req.getHeaderValues(headerName);
final String value;
if (null != headerValues && headerValues.length > 0) {
value = headerValues[0];
} else {
value = null;
}
return value;
}

/**
* Applies the {@literal X-Forwarded-Prefix} header prefix to a full URL, if provided in the
* request
*/
static String customizeUrl(String url, NativeWebRequest req) {
String path = URI.create(url).getPath();
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url);
String prefixedPath = applyForwardedPrefix(path, req);
builder.replacePath(prefixedPath);
return builder.build().toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.util.UriComponentsBuilder;

import java.net.URI;

import javax.servlet.http.HttpServletRequest;

Expand All @@ -19,14 +20,19 @@
class SpringDocHomeRedirectController {

private final @NonNull NativeWebRequest req;
private final @NonNull String servletContextPath;

@GetMapping(value = "/")
@GetMapping(value = {"", "/"})
public String redirectToSwaggerUI() {
String url = ((HttpServletRequest) req.getNativeRequest()).getRequestURL().toString();
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url);
builder.path("openapi/swagger-ui/index.html");
String fullUrl = builder.build().toString();
String xForwardedPrefixUrl = SpringDocAutoConfiguration.customizeUrl(fullUrl, req);
return "redirect:" + xForwardedPrefixUrl;
var target = "/openapi/swagger-ui/index.html";
URI url =
URI.create(
((HttpServletRequest) req.getNativeRequest()).getRequestURL().toString());
var path = url.getPath();
if (path != null) {
if (path.endsWith("/")) path = path.substring(0, path.length() - 1);
if (!path.endsWith(servletContextPath)) target = servletContextPath + target;
}
return "redirect:%s".formatted(target);
}
}
3 changes: 2 additions & 1 deletion src/artifacts/api/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ server:
port: 8080
servlet.context-path: /acl
# Let spring-boot's ForwardedHeaderFilter take care of reflecting the client-originated protocol and address in the HttpServletRequest
forward-headers-strategy: native
forward-headers-strategy: framework
error:
# one of never, always, on_trace_param (deprecated), on_param
include-stacktrace: on-param
Expand All @@ -25,6 +25,7 @@ server:
- application/json
- application/x-jackson-smile
tomcat:
use-relative-redirects: true
# Maximum number of connections that the server accepts and processes at any given time.
# Once the limit has been reached, the operating system may still accept connections based on the "acceptCount" property.
max-connections: ${tomcat.max.connections:8192}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,17 @@ protected void login(String user, String pwd) {
client = client.withBasicAuth(user, pwd);
}

protected <T> ResponseEntity<T> get(String url, Class<T> responseType) {
HttpHeaders headers = new HttpHeaders();
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
protected <T> ResponseEntity<T> get(String path, Class<T> responseType) {
return get(path, responseType, new HttpHeaders());
}

protected <T> ResponseEntity<T> get(String path, Class<T> responseType, HttpHeaders headers) {
if (headers.getAccept().isEmpty()) {
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
}
HttpEntity<String> entity = new HttpEntity<>(headers);

var url = fullUrl(path);
return client.exchange(url, HttpMethod.GET, entity, responseType);
}

Expand All @@ -122,13 +128,20 @@ private ResponseEntity<Rule> createRule(String json) {
}

protected <T> ResponseEntity<T> post(
String url, String requestBodyJson, Class<T> responseType, Object... urlVariables) {
String path, String requestBodyJson, Class<T> responseType, Object... urlVariables) {

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
HttpEntity<String> entity = new HttpEntity<>(requestBodyJson, headers);

var url = fullUrl(path);
return client.postForEntity(url, entity, responseType, urlVariables);
}

private String fullUrl(String path) {
String rootUri = client.getRootUri();
assertThat(rootUri).endsWith("/acl");
return rootUri + path;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,37 @@
*/
package org.geoserver.acl.app;

import static org.junit.jupiter.api.Assertions.*;
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.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.ActiveProfiles;

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@ActiveProfiles("dev")
class AccesControlListApplicationTest extends AbstractAccesControlListApplicationTest {

@BeforeEach
void setUp() throws Exception {}
@Test
void rootRedirectsToSwaggerUI() {
String expected = "/acl/openapi/swagger-ui/index.html";

ResponseEntity<String> response = get("/", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FOUND);
assertThat(response.getHeaders().get("Location")).containsExactly(expected);
}

@Test
void rootRedirectsToSwaggerUIWithXForwardedHeaders() {
var headers = new HttpHeaders();
headers.add("X-Forwarded-Prefix", "/geoserver/cloud");

String expected = "/geoserver/cloud/acl/openapi/swagger-ui/index.html";
ResponseEntity<String> response = get("/", String.class, headers);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SEE_OTHER);
assertThat(response.getHeaders().get("Location")).containsExactly(expected);
}
}

0 comments on commit cb3f987

Please sign in to comment.