fix(security): P0 — CSRF 방어, h2-console prod 격리, CORS 외부화

- CSRF: CookieCsrfTokenRepository(이중제출 토큰) + SPA용 CsrfTokenRequestAttributeHandler,
  CsrfCookieFilter로 XSRF-TOKEN 쿠키 강제 렌더. /api/auth/login·/api/public/** 는 예외.
  프론트 api.ts가 변경요청에 X-XSRF-TOKEN 헤더 자동 주입.
- 세션쿠키 SameSite=Lax·HttpOnly, prod는 Secure=${ACS_COOKIE_SECURE:false}(HTTPS 시 활성).
- h2-console permitAll·frameOptions.sameOrigin을 spring.h2.console.enabled에 연동 → prod 자동 비노출.
- CORS allowed-origins를 acs.cors.allowed-origins 프로퍼티로 외부화(prod 기본 빈 값, nginx 동일출처).
- .env.example·docker-compose에 ACS_CORS_ALLOWED_ORIGINS·ACS_COOKIE_SECURE 추가.

검증: 빌드/테스트 통과, curl로 CSRF 차단(403)·토큰 통과(404)·로그인/공개 예외·dev h2-console 확인.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
unknown
2026-07-03 09:54:55 +09:00
parent b3f1d5ebb2
commit d15ffe3fce
7 changed files with 130 additions and 25 deletions

View File

@@ -0,0 +1,32 @@
package com.itcenter.acs.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* Forces the {@code XSRF-TOKEN} cookie to be rendered on responses.
* <p>
* {@link org.springframework.security.web.csrf.CookieCsrfTokenRepository} only writes
* the cookie when the token is actually resolved. Calling {@link CsrfToken#getToken()}
* here guarantees the SPA receives the cookie (e.g. on the initial {@code GET /api/auth/me}
* probe or a public pass page load) so it can echo it back in the {@code X-XSRF-TOKEN} header.
*/
public class CsrfCookieFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
if (csrfToken != null) {
// Resolving the token value triggers CookieCsrfTokenRepository to set the cookie.
csrfToken.getToken();
}
filterChain.doFilter(request, response);
}
}

View File

@@ -3,19 +3,22 @@ package com.itcenter.acs.config;
import com.itcenter.acs.dto.ApiResponse; import com.itcenter.acs.dto.ApiResponse;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.security.web.context.SecurityContextRepository; import org.springframework.security.web.context.SecurityContextRepository;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@@ -31,6 +34,14 @@ public class SecurityConfig {
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
/** Only expose the H2 console (and relax frame options for it) when it is actually enabled — dev only. */
@Value("${spring.h2.console.enabled:false}")
private boolean h2ConsoleEnabled;
/** Allowed CORS origins; empty in prod (nginx same-origin), localhost in dev. */
@Value("${acs.cors.allowed-origins:http://localhost:5173,http://127.0.0.1:5173}")
private List<String> allowedOrigins;
@Bean @Bean
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); return new BCryptPasswordEncoder();
@@ -48,14 +59,33 @@ public class SecurityConfig {
@Bean @Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// SPA double-submit: the SPA reads the XSRF-TOKEN cookie and echoes it in the
// X-XSRF-TOKEN header. The plain handler (XOR opt-out) makes the header value
// match the raw cookie token.
CsrfTokenRequestAttributeHandler csrfHandler = new CsrfTokenRequestAttributeHandler();
csrfHandler.setCsrfRequestAttributeName(null);
http http
.cors(cors -> {}) .cors(cors -> {})
.csrf(AbstractHttpConfigurer::disable) .csrf(csrf -> csrf
.authorizeHttpRequests(authz -> authz .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(csrfHandler)
// login happens before a session exists; public pass endpoints are
// guarded by an unguessable token — both are exempt from CSRF.
.ignoringRequestMatchers("/api/auth/login", "/api/public/**")
)
// ensure the XSRF-TOKEN cookie is written on every response
.addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class)
.authorizeHttpRequests(authz -> {
authz
// public // public
.requestMatchers("/api/auth/login", "/api/auth/logout").permitAll() .requestMatchers("/api/auth/login", "/api/auth/logout").permitAll()
.requestMatchers("/", "/health", "/error").permitAll() .requestMatchers("/", "/health", "/error").permitAll();
.requestMatchers("/h2-console/**").permitAll() // H2 console — dev only (see h2ConsoleEnabled)
if (h2ConsoleEnabled) {
authz.requestMatchers("/h2-console/**").permitAll();
}
authz
// public visitor pass (opened from the SMS link, token-guarded) // public visitor pass (opened from the SMS link, token-guarded)
.requestMatchers("/api/public/**").permitAll() .requestMatchers("/api/public/**").permitAll()
// staff access console (search + force check-in/out) — 담당자(HOST)/보안/관리자 // staff access console (search + force check-in/out) — 담당자(HOST)/보안/관리자
@@ -67,10 +97,14 @@ public class SecurityConfig {
// reports — security & admin // reports — security & admin
.requestMatchers("/api/reports/**").hasAnyRole("SECURITY", "ADMIN") .requestMatchers("/api/reports/**").hasAnyRole("SECURITY", "ADMIN")
// everything else requires a logged-in user // everything else requires a logged-in user
.anyRequest().authenticated() .anyRequest().authenticated();
) })
// H2 console renders in a frame // relax frame options only for the H2 console (dev); prod keeps the default DENY
.headers(h -> h.frameOptions(f -> f.sameOrigin())) .headers(h -> {
if (h2ConsoleEnabled) {
h.frameOptions(f -> f.sameOrigin());
}
})
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
.securityContext(sc -> sc.securityContextRepository(securityContextRepository())) .securityContext(sc -> sc.securityContextRepository(securityContextRepository()))
.exceptionHandling(ex -> ex .exceptionHandling(ex -> ex
@@ -91,10 +125,8 @@ public class SecurityConfig {
@Bean @Bean
public CorsConfigurationSource corsConfigurationSource() { public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration(); CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of( // allowCredentials=true forbids "*" — origins must be listed explicitly (acs.cors.allowed-origins).
"http://localhost:5173", configuration.setAllowedOrigins(allowedOrigins);
"http://127.0.0.1:5173"
));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*")); configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true); configuration.setAllowCredentials(true);

View File

@@ -1,6 +1,19 @@
spring.application.name=acs spring.application.name=acs
server.port=8080 server.port=8080
# ===== Session cookie hardening =====
# SameSite=Lax complements the CSRF token defense.
# Secure=true means the cookie is only sent over HTTPS — enable it (ACS_COOKIE_SECURE=true)
# ONCE the site is served over HTTPS, otherwise login breaks over plain http://internal-ip.
server.servlet.session.cookie.same-site=lax
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=${ACS_COOKIE_SECURE:false}
# ===== CORS =====
# nginx serves web+API from the same origin, so CORS is normally not exercised (empty is fine).
# Set ACS_CORS_ALLOWED_ORIGINS (comma-separated) only if the SPA is hosted on a different origin.
acs.cors.allowed-origins=${ACS_CORS_ALLOWED_ORIGINS:}
# ===== PostgreSQL (prod) ===== # ===== PostgreSQL (prod) =====
spring.datasource.url=jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:acs} spring.datasource.url=jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:acs}
spring.datasource.driver-class-name=org.postgresql.Driver spring.datasource.driver-class-name=org.postgresql.Driver

View File

@@ -1,6 +1,11 @@
spring.application.name=acs spring.application.name=acs
server.port=8080 server.port=8080
# ===== Session cookie hardening =====
# SameSite=Lax complements the CSRF token defense. Secure=false in dev (plain http).
server.servlet.session.cookie.same-site=lax
server.servlet.session.cookie.http-only=true
# ===== Logging ===== # ===== Logging =====
logging.level.root=INFO logging.level.root=INFO
logging.level.com.itcenter.acs=DEBUG logging.level.com.itcenter.acs=DEBUG

View File

@@ -21,10 +21,22 @@ const BASE = '/api';
/** Endpoints whose 401 must NOT trigger a redirect (login probe / public pages). */ /** Endpoints whose 401 must NOT trigger a redirect (login probe / public pages). */
const NO_REDIRECT_ON_401 = ['/auth/me', '/auth/login', '/auth/logout', '/public/']; const NO_REDIRECT_ON_401 = ['/auth/me', '/auth/login', '/auth/logout', '/public/'];
/**
* CSRF double-submit: the backend sets an XSRF-TOKEN cookie; echo it back in the
* X-XSRF-TOKEN header on state-changing requests. GET/HEAD need no token.
*/
function csrfHeaders(method?: string): Record<string, string> {
const m = (method ?? 'GET').toUpperCase();
if (m === 'GET' || m === 'HEAD') return {};
const c = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/);
return c ? { 'X-XSRF-TOKEN': decodeURIComponent(c[1]) } : {};
}
async function request<T>(path: string, init?: RequestInit): Promise<T> { async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, { const res = await fetch(`${BASE}${path}`, {
credentials: 'include', credentials: 'include',
...init, ...init,
headers: { ...(init?.headers ?? {}), ...csrfHeaders(init?.method) },
}); });
// Session expired / not authenticated on a protected call → send to login. // Session expired / not authenticated on a protected call → send to login.
if ( if (

View File

@@ -16,3 +16,11 @@ ACS_SMS_API_URL=http://210.104.132.59:8000
# URL the SMS link points to — MUST be reachable from the visitor's phone # URL the SMS link points to — MUST be reachable from the visitor's phone
# (the server's real address/domain, not localhost). e.g. https://acs.example.co.kr # (the server's real address/domain, not localhost). e.g. https://acs.example.co.kr
ACS_PUBLIC_BASE_URL=http://localhost ACS_PUBLIC_BASE_URL=http://localhost
# ===== Security =====
# CORS allowed origins (comma-separated). Leave EMPTY when web+API share one origin
# via nginx (default). Set only if the SPA is hosted on a different origin.
ACS_CORS_ALLOWED_ORIGINS=
# Set to true ONLY when the site is served over HTTPS — marks the session cookie Secure.
# Leaving it false over plain http keeps login working; true over http would break it.
ACS_COOKIE_SECURE=false

View File

@@ -28,6 +28,9 @@ services:
ACS_SMS_PROVIDER: ${ACS_SMS_PROVIDER:-dev} ACS_SMS_PROVIDER: ${ACS_SMS_PROVIDER:-dev}
ACS_SMS_API_URL: ${ACS_SMS_API_URL:-http://210.104.132.59:8000} ACS_SMS_API_URL: ${ACS_SMS_API_URL:-http://210.104.132.59:8000}
ACS_PUBLIC_BASE_URL: ${ACS_PUBLIC_BASE_URL:-http://localhost} ACS_PUBLIC_BASE_URL: ${ACS_PUBLIC_BASE_URL:-http://localhost}
# Security: CORS origins (empty = same-origin via nginx); cookie Secure (enable under HTTPS)
ACS_CORS_ALLOWED_ORIGINS: ${ACS_CORS_ALLOWED_ORIGINS:-}
ACS_COOKIE_SECURE: ${ACS_COOKIE_SECURE:-false}
depends_on: depends_on:
- db - db
networks: networks: