fix #547: secure VM deployment behind HTTPS proxy

This commit is contained in:
devmrko
2026-06-28 23:03:32 +09:00
parent e63b3af653
commit 8dc77f87ab
12 changed files with 818 additions and 3 deletions

View File

@@ -11,7 +11,7 @@ public record BackofficeProperties(
Ai ai
) {
public record Security(String adminUser, String adminPassword) {
public record Security(String adminUser, String adminPassword, boolean requireHttps) {
}
public record Token(int maxDays) {

View File

@@ -14,9 +14,19 @@ import org.springframework.security.web.SecurityFilterChain;
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
SecurityFilterChain securityFilterChain(
HttpSecurity http,
BackofficeProperties properties
) throws Exception {
if (properties.security().requireHttps()) {
http.requiresChannel(channel -> channel.anyRequest().requiresSecure());
}
return http
.csrf(csrf -> csrf.ignoringRequestMatchers("/mcp/messages", "/mcp/*/messages"))
.headers(headers -> headers.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31_536_000)))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/css/**", "/js/**", "/webjars/**").permitAll()
.anyRequest().authenticated())

View File

@@ -22,12 +22,21 @@ mybatis:
map-underscore-to-camel-case: true
server:
address: ${BACKOFFICE_BIND_ADDRESS:0.0.0.0}
port: ${BACKOFFICE_PORT:8080}
forward-headers-strategy: ${BACKOFFICE_FORWARD_HEADERS_STRATEGY:none}
servlet:
session:
cookie:
secure: ${BACKOFFICE_SESSION_COOKIE_SECURE:false}
http-only: true
same-site: lax
backoffice:
security:
admin-user: ${BACKOFFICE_ADMIN_USER:admin}
admin-password: ${BACKOFFICE_ADMIN_PASSWORD:admin}
require-https: ${BACKOFFICE_REQUIRE_HTTPS:false}
token:
max-days: ${BACKOFFICE_TOKEN_MAX_DAYS:365}
ords:

View File

@@ -0,0 +1,142 @@
package com.cloudhandson.vpdbackoffice.config;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.Map;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.boot.test.util.TestPropertyValues;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockServletContext;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
import org.springframework.web.filter.ForwardedHeaderFilter;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
class TransportSecurityTest {
private AnnotationConfigWebApplicationContext context;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
context = new AnnotationConfigWebApplicationContext();
context.setServletContext(new MockServletContext());
TestPropertyValues.of(
"backoffice.security.admin-user=admin",
"backoffice.security.admin-password=test-password",
"backoffice.security.require-https=true",
"backoffice.token.max-days=365",
"backoffice.ords.base-url=https://ords.example.test",
"backoffice.ords.timeout=10s",
"backoffice.ai.enabled=false",
"backoffice.ai.timeout=30s"
).applyTo(context);
context.register(TestWebConfig.class);
context.refresh();
mockMvc = MockMvcBuilders.webAppContextSetup(context)
.addFilters(new ForwardedHeaderFilter())
.apply(springSecurity())
.build();
}
@AfterEach
void tearDown() {
context.close();
}
@Test
void directHttpRequestRedirectsToHttps() throws Exception {
mockMvc.perform(get("/login"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("https://localhost/login"));
}
@Test
void forwardedHttpsRequestIsAcceptedAndReturnsHsts() throws Exception {
mockMvc.perform(get("/login")
.header("X-Forwarded-Proto", "https")
.header("X-Forwarded-Host", "admin.example.test"))
.andExpect(status().isOk())
.andExpect(header().string(
"Strict-Transport-Security",
containsString("max-age=31536000")
));
}
@Test
void forwardedHostIsUsedForAuthenticationRedirect() throws Exception {
mockMvc.perform(get("/")
.accept(MediaType.TEXT_HTML)
.header("X-Forwarded-Proto", "https")
.header("X-Forwarded-Host", "admin.example.test"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("https://admin.example.test/login"));
}
@Test
void productionSessionCookieSettingsAreBound() throws Exception {
var environment = new StandardEnvironment();
environment.getPropertySources().addFirst(new MapPropertySource("test-overrides", Map.of(
"BACKOFFICE_FORWARD_HEADERS_STRATEGY", "framework",
"BACKOFFICE_SESSION_COOKIE_SECURE", "true"
)));
var loader = new YamlPropertySourceLoader();
for (var source : loader.load("application", new ClassPathResource("application.yml"))) {
environment.getPropertySources().addLast(source);
}
ServerProperties properties = Binder.get(environment)
.bind("server", ServerProperties.class)
.orElseThrow(() -> new AssertionError("server properties were not bound"));
var cookie = properties.getServlet().getSession().getCookie();
assertThat(cookie.getSecure()).isTrue();
assertThat(cookie.getHttpOnly()).isTrue();
assertThat(cookie.getSameSite().attributeValue()).isEqualTo("Lax");
assertThat(properties.getForwardHeadersStrategy().name()).isEqualTo("FRAMEWORK");
}
@Configuration(proxyBeanMethods = false)
@EnableWebMvc
@EnableWebSecurity
@EnableConfigurationProperties(BackofficeProperties.class)
@Import({SecurityConfig.class, TestController.class})
static class TestWebConfig {
}
@RestController
static class TestController {
@GetMapping("/login")
String login() {
return "login";
}
@GetMapping("/")
String home() {
return "home";
}
}
}