보안 헤더·TLS·프록시 신뢰
운영에서는 ingress나 load balancer가 TLS를 종료하고 Spring 애플리케이션에는 HTTP로 전달하는 경우가 많습니다. 이때 애플리케이션이 내부 연결만 보고 원래 요청을 HTTP로 오해하면 redirect URI, secure cookie, HSTS 판단이 틀어집니다.
반대로 인터넷에서 온 Forwarded·X-Forwarded-* 헤더를 그대로 믿으면 공격자가 scheme과 client IP를 위조할 수 있습니다. 신뢰 경계는 코드 설정뿐 아니라 proxy가 외부 헤더를 제거하고 자기 값으로 다시 쓰는 구성까지 포함합니다.
보안 헤더는 배포 토폴로지를 알아야 한다
TLS edge는 인증서와 암호화를 책임지고, forwarded header는 원래 scheme·host·client 정보를 내부 애플리케이션으로 전달합니다. Spring Security의 응답 헤더는 이 복원된 요청 정보 위에서 HSTS, clickjacking 방지, MIME sniffing 방지, CSP 같은 브라우저 정책을 적용합니다.
HSTS는 HTTPS 응답에서만 의미가 있으며 한번 받은 브라우저가 일정 기간 HTTP 접속을 HTTPS로 강제합니다. preload와 includeSubDomains는 되돌리기 어려우므로 모든 하위 도메인이 HTTPS 준비가 되었을 때만 사용합니다.
Security headers와 프록시 설정을 연결한다
ch4-4에서 만든 catch-all SecurityFilterChain과 별개 chain을 추가하면 matcher와 순서가 충돌합니다. 기존 SecurityConfiguration을 아래처럼 교체해 REST·GraphQL 권한과 응답 헤더를 한 체인에서 관리합니다.
package com.andongmin.studylog.security;
import static org.springframework.security.config.Customizer.withDefaults;
import org.springframework.boot.actuate.endpoint.web.WebServerNamespace;
import org.springframework.boot.health.actuate.endpoint.HealthEndpoint;
import org.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@Configuration(proxyBeanMethods = false)
public class SecurityConfiguration {
@Bean
SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
return http
.cors(withDefaults())
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.headers(headers -> headers
.frameOptions(frame -> frame.deny())
.contentTypeOptions(withDefaults())
.contentSecurityPolicy(csp -> csp.policyDirectives(
"default-src 'self'; frame-ancestors 'none'"))
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.preload(true)
.maxAgeInSeconds(31_536_000)))
.authorizeHttpRequests(requests -> requests
.requestMatchers(EndpointRequest.to(HealthEndpoint.class)).permitAll()
.requestMatchers(EndpointRequest.toAdditionalPaths(
WebServerNamespace.SERVER, HealthEndpoint.class)).permitAll()
.requestMatchers(HttpMethod.GET, "/api/study-sessions/**")
.hasAuthority("SCOPE_study.read")
.requestMatchers("/api/study-sessions/**")
.hasAuthority("SCOPE_study.write")
.requestMatchers("/graphql").authenticated()
.anyRequest().denyAll())
.oauth2ResourceServer(resourceServer ->
resourceServer.jwt(withDefaults()))
.exceptionHandling(errors -> errors
.authenticationEntryPoint((request, response, failure) ->
response.sendError(401, "authentication required"))
.accessDeniedHandler((request, response, failure) ->
response.sendError(403, "insufficient authority")))
.build();
}
}server.forward-headers-strategy=framework
server.tomcat.redirect-context-root=false직접 접속과 proxy 경유 응답을 비교한다
서버와 요청을 두 터미널로 나눠 proxy가 HTTPS 요청을 전달한 상황을 모사합니다. 실제 운영에서는 애플리케이션 포트를 외부에 직접 공개하지 않아야 헤더 위조를 막을 수 있습니다.
./mvnw -q spring-boot:runcurl -I -H "Authorization: Bearer $READ_TOKEN" \
-H "X-Forwarded-Proto: https" \
http://localhost:8080/api/study-sessionsTLS 종료 proxy를 모사한 응답에 HSTS와 X-Content-Type-Options가 있어야 합니다. 운영 proxy는 외부 Forwarded 헤더를 제거하고 신뢰한 값으로 다시 써야 합니다.전송 경로를 신뢰할 수 있게 했으니 다음에는 인증된 요청도 과도한 크기와 비용으로 자원을 고갈시키지 못하게 제한합니다.