From f119e58767530f22f8656dc69dc702dc87b83117 Mon Sep 17 00:00:00 2001 From: Tiziano Basile Date: Sat, 1 Aug 2026 13:13:55 +0200 Subject: [PATCH] Default use-relative-redirects to true Spring Boot unconditionally set useRelativeRedirects on the Tomcat Context, defaulting it to false. That overrode Tomcat's own default and forced absolute Location headers on every sendRedirect. Keep server.tomcat.use-relative-redirects a simple boolean, but default it to true so that relative Location headers are used out of the box. The property can still be set to false to opt back into absolute redirects. Smoke tests that asserted a port-qualified absolute Location are updated to the relative form, and the proxy tip in the reference documentation is qualified since the context root redirect no longer carries a scheme. Signed-off-by: Tiziano Basile See gh-51173 --- .../docs/antora/modules/how-to/pages/webserver.adoc | 3 ++- .../autoconfigure/TomcatServerProperties.java | 5 +++-- .../TomcatServletWebServerFactoryCustomizer.java | 1 + .../autoconfigure/TomcatServerPropertiesTests.java | 5 +++-- ...omcatServletWebServerFactoryCustomizerTests.java | 13 +++++++++++-- ...leOAuth2AuthorizationServerApplicationTests.java | 4 ++-- .../client/SampleOAuth2ClientApplicationTests.java | 2 +- .../SampleSaml2RelyingPartyApplicationTests.java | 2 +- .../SampleGroovyTemplateApplicationTests.java | 6 +----- .../SampleMethodSecurityApplicationTests.java | 2 +- .../SampleWebSecureCustomApplicationTests.java | 4 ++-- .../jdbc/SampleWebSecureJdbcApplicationTests.java | 4 ++-- .../web/secure/SampleWebSecureApplicationTests.java | 4 ++-- .../web/thymeleaf/SampleWebUiApplicationTests.java | 6 +----- 14 files changed, 33 insertions(+), 28 deletions(-) diff --git a/documentation/spring-boot-docs/src/docs/antora/modules/how-to/pages/webserver.adoc b/documentation/spring-boot-docs/src/docs/antora/modules/how-to/pages/webserver.adoc index b17e9b697f4..e012ac6ddcb 100644 --- a/documentation/spring-boot-docs/src/docs/antora/modules/how-to/pages/webserver.adoc +++ b/documentation/spring-boot-docs/src/docs/antora/modules/how-to/pages/webserver.adoc @@ -562,8 +562,9 @@ server: NOTE: You can trust all proxies by setting the `internal-proxies` to empty (but do not do so in production). -TIP: If you are using Tomcat and terminating SSL at the proxy, configprop:server.tomcat.redirect-context-root[] should be set to `false`. +TIP: If you are using Tomcat, terminating SSL at the proxy, and have set configprop:server.tomcat.use-relative-redirects[] to `false`, then configprop:server.tomcat.redirect-context-root[] should also be set to `false`. This allows the `X-Forwarded-Proto` header to be honored before any redirects are performed. +When relative redirects are in use, which is Tomcat's default, the context root redirect carries no scheme so there is nothing for the header to correct. You can take complete control of the configuration of Tomcat's javadoc:org.apache.catalina.valves.RemoteIpValve[] by switching the automatic one off (to do so, set `server.forward-headers-strategy=NONE`) and adding a new valve instance using a javadoc:org.springframework.boot.web.server.WebServerFactoryCustomizer[] bean. diff --git a/module/spring-boot-tomcat/src/main/java/org/springframework/boot/tomcat/autoconfigure/TomcatServerProperties.java b/module/spring-boot-tomcat/src/main/java/org/springframework/boot/tomcat/autoconfigure/TomcatServerProperties.java index 90c6e551820..1ca76eee73e 100644 --- a/module/spring-boot-tomcat/src/main/java/org/springframework/boot/tomcat/autoconfigure/TomcatServerProperties.java +++ b/module/spring-boot-tomcat/src/main/java/org/springframework/boot/tomcat/autoconfigure/TomcatServerProperties.java @@ -56,6 +56,7 @@ import org.springframework.util.unit.DataSize; * @author Florian Storz * @author Michael Weidmann * @author Lasse Wulff + * @author Tiziano Basile * @since 4.0.0 */ @ConfigurationProperties("server.tomcat") @@ -103,9 +104,9 @@ public class TomcatServerProperties { /** * Whether HTTP 1.1 and later location headers generated by a call to sendRedirect - * will use relative or absolute redirects. + * will use relative or absolute redirects. Has no effect on a reactive web server. */ - private boolean useRelativeRedirects; + private boolean useRelativeRedirects = true; /** * Character encoding to use to decode the URI. diff --git a/module/spring-boot-tomcat/src/main/java/org/springframework/boot/tomcat/autoconfigure/servlet/TomcatServletWebServerFactoryCustomizer.java b/module/spring-boot-tomcat/src/main/java/org/springframework/boot/tomcat/autoconfigure/servlet/TomcatServletWebServerFactoryCustomizer.java index 7af0d96e6d3..b780a80a210 100644 --- a/module/spring-boot-tomcat/src/main/java/org/springframework/boot/tomcat/autoconfigure/servlet/TomcatServletWebServerFactoryCustomizer.java +++ b/module/spring-boot-tomcat/src/main/java/org/springframework/boot/tomcat/autoconfigure/servlet/TomcatServletWebServerFactoryCustomizer.java @@ -29,6 +29,7 @@ import org.springframework.util.ObjectUtils; * * @author Brian Clozel * @author Phillip Webb + * @author Tiziano Basile */ class TomcatServletWebServerFactoryCustomizer implements WebServerFactoryCustomizer, Ordered { diff --git a/module/spring-boot-tomcat/src/test/java/org/springframework/boot/tomcat/autoconfigure/TomcatServerPropertiesTests.java b/module/spring-boot-tomcat/src/test/java/org/springframework/boot/tomcat/autoconfigure/TomcatServerPropertiesTests.java index e9280187a80..00d5fddb4f0 100644 --- a/module/spring-boot-tomcat/src/test/java/org/springframework/boot/tomcat/autoconfigure/TomcatServerPropertiesTests.java +++ b/module/spring-boot-tomcat/src/test/java/org/springframework/boot/tomcat/autoconfigure/TomcatServerPropertiesTests.java @@ -45,6 +45,7 @@ import static org.assertj.core.api.Assertions.assertThat; * Tests for {@link TomcatServerProperties}. * * @author Andy Wilkinson + * @author Tiziano Basile */ class TomcatServerPropertiesTests { @@ -235,8 +236,8 @@ class TomcatServerPropertiesTests { } @Test - void tomcatUseRelativeRedirectsDefaultsToFalse() { - assertThat(this.properties.isUseRelativeRedirects()).isFalse(); + void tomcatUseRelativeRedirectsDefaultsToTrue() { + assertThat(this.properties.isUseRelativeRedirects()).isTrue(); } @Test diff --git a/module/spring-boot-tomcat/src/test/java/org/springframework/boot/tomcat/autoconfigure/servlet/TomcatServletWebServerFactoryCustomizerTests.java b/module/spring-boot-tomcat/src/test/java/org/springframework/boot/tomcat/autoconfigure/servlet/TomcatServletWebServerFactoryCustomizerTests.java index 2a39dc33b44..a1ee752561a 100644 --- a/module/spring-boot-tomcat/src/test/java/org/springframework/boot/tomcat/autoconfigure/servlet/TomcatServletWebServerFactoryCustomizerTests.java +++ b/module/spring-boot-tomcat/src/test/java/org/springframework/boot/tomcat/autoconfigure/servlet/TomcatServletWebServerFactoryCustomizerTests.java @@ -35,6 +35,7 @@ import static org.assertj.core.api.Assertions.assertThat; * Tests for {@link TomcatServletWebServerFactoryCustomizer}. * * @author Phillip Webb + * @author Tiziano Basile */ class TomcatServletWebServerFactoryCustomizerTests { @@ -80,14 +81,22 @@ class TomcatServletWebServerFactoryCustomizerTests { } @Test - void useRelativeRedirectsCanBeConfigured() { - bind("server.tomcat.use-relative-redirects=true"); + void useRelativeRedirectsDefaultsToTrue() { assertThat(this.tomcatProperties.isUseRelativeRedirects()).isTrue(); TomcatWebServer server = customizeAndGetServer(); Context context = (Context) server.getTomcat().getHost().findChildren()[0]; assertThat(context.getUseRelativeRedirects()).isTrue(); } + @Test + void useRelativeRedirectsCanBeDisabled() { + bind("server.tomcat.use-relative-redirects=false"); + assertThat(this.tomcatProperties.isUseRelativeRedirects()).isFalse(); + TomcatWebServer server = customizeAndGetServer(); + Context context = (Context) server.getTomcat().getHost().findChildren()[0]; + assertThat(context.getUseRelativeRedirects()).isFalse(); + } + private void bind(String... inlinedProperties) { TestPropertySourceUtils.addInlinedPropertiesToEnvironment(this.environment, inlinedProperties); new Binder(ConfigurationPropertySources.get(this.environment)).bind("server.tomcat", diff --git a/smoke-test/spring-boot-smoke-test-oauth2-authorization-server/src/test/java/smoketest/oauth2/server/SampleOAuth2AuthorizationServerApplicationTests.java b/smoke-test/spring-boot-smoke-test-oauth2-authorization-server/src/test/java/smoketest/oauth2/server/SampleOAuth2AuthorizationServerApplicationTests.java index 3407774ccd9..3f196f9db8c 100644 --- a/smoke-test/spring-boot-smoke-test-oauth2-authorization-server/src/test/java/smoketest/oauth2/server/SampleOAuth2AuthorizationServerApplicationTests.java +++ b/smoke-test/spring-boot-smoke-test-oauth2-authorization-server/src/test/java/smoketest/oauth2/server/SampleOAuth2AuthorizationServerApplicationTests.java @@ -113,7 +113,7 @@ class SampleOAuth2AuthorizationServerApplicationTests { void anonymousShouldRedirectToLogin() { RestTestClient.ResponseSpec response = nonFollowingRedirect().get().uri("/").exchange(); response.expectStatus().isFound(); - response.expectHeader().location("http://localhost:" + this.port + "/login"); + response.expectHeader().location("/login"); } @Test @@ -182,7 +182,7 @@ class SampleOAuth2AuthorizationServerApplicationTests { .body(body) .exchange(); response.expectStatus().isFound(); - response.expectHeader().location("http://localhost:" + this.port + "/login"); + response.expectHeader().location("/login"); } } diff --git a/smoke-test/spring-boot-smoke-test-oauth2-client/src/test/java/smoketest/oauth2/client/SampleOAuth2ClientApplicationTests.java b/smoke-test/spring-boot-smoke-test-oauth2-client/src/test/java/smoketest/oauth2/client/SampleOAuth2ClientApplicationTests.java index 8d7d8ce62fe..866b15d5641 100644 --- a/smoke-test/spring-boot-smoke-test-oauth2-client/src/test/java/smoketest/oauth2/client/SampleOAuth2ClientApplicationTests.java +++ b/smoke-test/spring-boot-smoke-test-oauth2-client/src/test/java/smoketest/oauth2/client/SampleOAuth2ClientApplicationTests.java @@ -54,7 +54,7 @@ class SampleOAuth2ClientApplicationTests { void everythingShouldRedirectToLogin() { RestTestClient.ResponseSpec response = nonFollowingRedirect().get().uri("/").exchange(); response.expectStatus().isFound(); - response.expectHeader().location("http://localhost:" + this.port + "/login"); + response.expectHeader().location("/login"); } @Test diff --git a/smoke-test/spring-boot-smoke-test-saml2-service-provider/src/test/java/smoketest/saml2/serviceprovider/SampleSaml2RelyingPartyApplicationTests.java b/smoke-test/spring-boot-smoke-test-saml2-service-provider/src/test/java/smoketest/saml2/serviceprovider/SampleSaml2RelyingPartyApplicationTests.java index 837a8af8a02..054d4647287 100644 --- a/smoke-test/spring-boot-smoke-test-saml2-service-provider/src/test/java/smoketest/saml2/serviceprovider/SampleSaml2RelyingPartyApplicationTests.java +++ b/smoke-test/spring-boot-smoke-test-saml2-service-provider/src/test/java/smoketest/saml2/serviceprovider/SampleSaml2RelyingPartyApplicationTests.java @@ -52,7 +52,7 @@ class SampleSaml2RelyingPartyApplicationTests { void everythingShouldRedirectToLogin() { RestTestClient.ResponseSpec response = nonFollowingRedirect().get().uri("/").exchange(); response.expectStatus().isFound(); - response.expectHeader().location("http://localhost:" + this.port + "/login"); + response.expectHeader().location("/login"); } @Test diff --git a/smoke-test/spring-boot-smoke-test-web-groovy-templates/src/test/java/smoketest/groovytemplates/SampleGroovyTemplateApplicationTests.java b/smoke-test/spring-boot-smoke-test-web-groovy-templates/src/test/java/smoketest/groovytemplates/SampleGroovyTemplateApplicationTests.java index 141b4983855..ac26a57d60e 100644 --- a/smoke-test/spring-boot-smoke-test-web-groovy-templates/src/test/java/smoketest/groovytemplates/SampleGroovyTemplateApplicationTests.java +++ b/smoke-test/spring-boot-smoke-test-web-groovy-templates/src/test/java/smoketest/groovytemplates/SampleGroovyTemplateApplicationTests.java @@ -22,7 +22,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; -import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.test.web.servlet.client.RestTestClient; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; @@ -38,9 +37,6 @@ import static org.assertj.core.api.Assertions.assertThat; @AutoConfigureRestTestClient class SampleGroovyTemplateApplicationTests { - @LocalServerPort - private int port; - @Autowired private RestTestClient restTestClient; @@ -62,7 +58,7 @@ class SampleGroovyTemplateApplicationTests { .body(map) .exchange() .expectHeader() - .value("Location", (location) -> assertThat(location).contains("localhost:" + this.port)); + .value("Location", (location) -> assertThat(location).matches("/\\d+(;jsessionid=[\\w.]+)?")); } @Test diff --git a/smoke-test/spring-boot-smoke-test-web-method-security/src/test/java/smoketest/security/method/SampleMethodSecurityApplicationTests.java b/smoke-test/spring-boot-smoke-test-web-method-security/src/test/java/smoketest/security/method/SampleMethodSecurityApplicationTests.java index 552182ae9b0..873bb201250 100644 --- a/smoke-test/spring-boot-smoke-test-web-method-security/src/test/java/smoketest/security/method/SampleMethodSecurityApplicationTests.java +++ b/smoke-test/spring-boot-smoke-test-web-method-security/src/test/java/smoketest/security/method/SampleMethodSecurityApplicationTests.java @@ -91,7 +91,7 @@ class SampleMethodSecurityApplicationTests { assertThat(result.getStatus()).isEqualTo(HttpStatus.FOUND); URI location = result.getResponseHeaders().getLocation(); assertThat(location).isNotNull(); - assertThat(location.toString()).endsWith(this.port + "/"); + assertThat(location.toString()).isEqualTo("/"); } @Test diff --git a/smoke-test/spring-boot-smoke-test-web-secure-custom/src/test/java/smoketest/web/secure/custom/SampleWebSecureCustomApplicationTests.java b/smoke-test/spring-boot-smoke-test-web-secure-custom/src/test/java/smoketest/web/secure/custom/SampleWebSecureCustomApplicationTests.java index b16c34ab450..565a71cd1cc 100644 --- a/smoke-test/spring-boot-smoke-test-web-secure-custom/src/test/java/smoketest/web/secure/custom/SampleWebSecureCustomApplicationTests.java +++ b/smoke-test/spring-boot-smoke-test-web-secure-custom/src/test/java/smoketest/web/secure/custom/SampleWebSecureCustomApplicationTests.java @@ -71,7 +71,7 @@ class SampleWebSecureCustomApplicationTests { assertThat(result.getStatus()).isEqualTo(HttpStatus.FOUND); URI location = result.getResponseHeaders().getLocation(); assertThat(location).isNotNull(); - assertThat(location.toString()).endsWith(this.port + "/login"); + assertThat(location.toString()).isEqualTo("/login"); } @Test @@ -99,7 +99,7 @@ class SampleWebSecureCustomApplicationTests { assertThat(result.getStatus()).isEqualTo(HttpStatus.FOUND); URI location = result.getResponseHeaders().getLocation(); assertThat(location).isNotNull(); - assertThat(location.toString()).endsWith(this.port + "/"); + assertThat(location.toString()).isEqualTo("/"); } } diff --git a/smoke-test/spring-boot-smoke-test-web-secure-jdbc/src/test/java/smoketest/web/secure/jdbc/SampleWebSecureJdbcApplicationTests.java b/smoke-test/spring-boot-smoke-test-web-secure-jdbc/src/test/java/smoketest/web/secure/jdbc/SampleWebSecureJdbcApplicationTests.java index 82ba02cf198..799aa10a703 100644 --- a/smoke-test/spring-boot-smoke-test-web-secure-jdbc/src/test/java/smoketest/web/secure/jdbc/SampleWebSecureJdbcApplicationTests.java +++ b/smoke-test/spring-boot-smoke-test-web-secure-jdbc/src/test/java/smoketest/web/secure/jdbc/SampleWebSecureJdbcApplicationTests.java @@ -71,7 +71,7 @@ class SampleWebSecureJdbcApplicationTests { assertThat(result.getStatus()).isEqualTo(HttpStatus.FOUND); URI location = result.getResponseHeaders().getLocation(); assertThat(location).isNotNull(); - assertThat(location.toString()).endsWith(this.port + "/login"); + assertThat(location.toString()).isEqualTo("/login"); } @Test @@ -99,7 +99,7 @@ class SampleWebSecureJdbcApplicationTests { assertThat(result.getStatus()).isEqualTo(HttpStatus.FOUND); URI location = result.getResponseHeaders().getLocation(); assertThat(location).isNotNull(); - assertThat(location.toString()).endsWith(this.port + "/"); + assertThat(location.toString()).isEqualTo("/"); } } diff --git a/smoke-test/spring-boot-smoke-test-web-secure/src/test/java/smoketest/web/secure/SampleWebSecureApplicationTests.java b/smoke-test/spring-boot-smoke-test-web-secure/src/test/java/smoketest/web/secure/SampleWebSecureApplicationTests.java index 781d02fed73..c31a55fa650 100644 --- a/smoke-test/spring-boot-smoke-test-web-secure/src/test/java/smoketest/web/secure/SampleWebSecureApplicationTests.java +++ b/smoke-test/spring-boot-smoke-test-web-secure/src/test/java/smoketest/web/secure/SampleWebSecureApplicationTests.java @@ -78,7 +78,7 @@ class SampleWebSecureApplicationTests { assertThat(result.getStatus()).isEqualTo(HttpStatus.FOUND); URI location = result.getResponseHeaders().getLocation(); assertThat(location).isNotNull(); - assertThat(location.toString()).endsWith(this.port + "/login"); + assertThat(location.toString()).isEqualTo("/login"); } @Test @@ -106,7 +106,7 @@ class SampleWebSecureApplicationTests { assertThat(result.getStatus()).isEqualTo(HttpStatus.FOUND); URI location = result.getResponseHeaders().getLocation(); assertThat(location).isNotNull(); - assertThat(location.toString()).endsWith(this.port + "/"); + assertThat(location.toString()).isEqualTo("/"); } @org.springframework.boot.test.context.TestConfiguration(proxyBeanMethods = false) diff --git a/smoke-test/spring-boot-smoke-test-web-thymeleaf/src/test/java/smoketest/web/thymeleaf/SampleWebUiApplicationTests.java b/smoke-test/spring-boot-smoke-test-web-thymeleaf/src/test/java/smoketest/web/thymeleaf/SampleWebUiApplicationTests.java index 8c21af9d78a..64f8f8e5714 100644 --- a/smoke-test/spring-boot-smoke-test-web-thymeleaf/src/test/java/smoketest/web/thymeleaf/SampleWebUiApplicationTests.java +++ b/smoke-test/spring-boot-smoke-test-web-thymeleaf/src/test/java/smoketest/web/thymeleaf/SampleWebUiApplicationTests.java @@ -24,7 +24,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; -import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.test.web.servlet.client.RestTestClient; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; @@ -43,9 +42,6 @@ class SampleWebUiApplicationTests { @Autowired private RestTestClient restTestClient; - @LocalServerPort - private int port; - @Test void testHome() { this.restTestClient.get().uri("/").exchangeSuccessfully().expectBody(String.class).value((body) -> { @@ -67,7 +63,7 @@ class SampleWebUiApplicationTests { .getResponseHeaders() .getLocation(); assertThat(location).isNotNull(); - assertThat(location.toString()).contains("localhost:" + this.port); + assertThat(location.toString()).matches("/\\d+(;jsessionid=[\\w.]+)?"); } }