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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,22 @@ package com.itcenter.acs.config;
|
||||
import com.itcenter.acs.dto.ApiResponse;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
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.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
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.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.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
@@ -31,6 +34,14 @@ public class SecurityConfig {
|
||||
|
||||
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
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
@@ -48,14 +59,33 @@ public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
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
|
||||
.cors(cors -> {})
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.authorizeHttpRequests(authz -> authz
|
||||
.csrf(csrf -> csrf
|
||||
.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
|
||||
.requestMatchers("/api/auth/login", "/api/auth/logout").permitAll()
|
||||
.requestMatchers("/", "/health", "/error").permitAll()
|
||||
.requestMatchers("/h2-console/**").permitAll()
|
||||
.requestMatchers("/", "/health", "/error").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)
|
||||
.requestMatchers("/api/public/**").permitAll()
|
||||
// staff access console (search + force check-in/out) — 담당자(HOST)/보안/관리자
|
||||
@@ -67,10 +97,14 @@ public class SecurityConfig {
|
||||
// reports — security & admin
|
||||
.requestMatchers("/api/reports/**").hasAnyRole("SECURITY", "ADMIN")
|
||||
// everything else requires a logged-in user
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
// H2 console renders in a frame
|
||||
.headers(h -> h.frameOptions(f -> f.sameOrigin()))
|
||||
.anyRequest().authenticated();
|
||||
})
|
||||
// relax frame options only for the H2 console (dev); prod keeps the default DENY
|
||||
.headers(h -> {
|
||||
if (h2ConsoleEnabled) {
|
||||
h.frameOptions(f -> f.sameOrigin());
|
||||
}
|
||||
})
|
||||
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
|
||||
.securityContext(sc -> sc.securityContextRepository(securityContextRepository()))
|
||||
.exceptionHandling(ex -> ex
|
||||
@@ -91,10 +125,8 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOrigins(List.of(
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173"
|
||||
));
|
||||
// allowCredentials=true forbids "*" — origins must be listed explicitly (acs.cors.allowed-origins).
|
||||
configuration.setAllowedOrigins(allowedOrigins);
|
||||
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
configuration.setAllowedHeaders(List.of("*"));
|
||||
configuration.setAllowCredentials(true);
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
spring.application.name=acs
|
||||
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) =====
|
||||
spring.datasource.url=jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:acs}
|
||||
spring.datasource.driver-class-name=org.postgresql.Driver
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
spring.application.name=acs
|
||||
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.level.root=INFO
|
||||
logging.level.com.itcenter.acs=DEBUG
|
||||
|
||||
@@ -21,10 +21,22 @@ const BASE = '/api';
|
||||
/** Endpoints whose 401 must NOT trigger a redirect (login probe / public pages). */
|
||||
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> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
credentials: 'include',
|
||||
...init,
|
||||
headers: { ...(init?.headers ?? {}), ...csrfHeaders(init?.method) },
|
||||
});
|
||||
// Session expired / not authenticated on a protected call → send to login.
|
||||
if (
|
||||
|
||||
@@ -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
|
||||
# (the server's real address/domain, not localhost). e.g. https://acs.example.co.kr
|
||||
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
|
||||
|
||||
@@ -28,6 +28,9 @@ services:
|
||||
ACS_SMS_PROVIDER: ${ACS_SMS_PROVIDER:-dev}
|
||||
ACS_SMS_API_URL: ${ACS_SMS_API_URL:-http://210.104.132.59:8000}
|
||||
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:
|
||||
- db
|
||||
networks:
|
||||
|
||||
Reference in New Issue
Block a user