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.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,29 +59,52 @@ 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())
|
||||||
// public
|
.csrfTokenRequestHandler(csrfHandler)
|
||||||
.requestMatchers("/api/auth/login", "/api/auth/logout").permitAll()
|
// login happens before a session exists; public pass endpoints are
|
||||||
.requestMatchers("/", "/health", "/error").permitAll()
|
// guarded by an unguessable token — both are exempt from CSRF.
|
||||||
.requestMatchers("/h2-console/**").permitAll()
|
.ignoringRequestMatchers("/api/auth/login", "/api/public/**")
|
||||||
// public visitor pass (opened from the SMS link, token-guarded)
|
|
||||||
.requestMatchers("/api/public/**").permitAll()
|
|
||||||
// staff access console (search + force check-in/out) — 담당자(HOST)/보안/관리자
|
|
||||||
.requestMatchers("/api/access/**").hasAnyRole("HOST", "SECURITY", "ADMIN")
|
|
||||||
// approvals — IT센터 관리자(admin) 전용
|
|
||||||
.requestMatchers("/api/approvals/**").hasRole("ADMIN")
|
|
||||||
// blacklist & admin management — admin only
|
|
||||||
.requestMatchers("/api/blacklist/**", "/api/admin/**").hasRole("ADMIN")
|
|
||||||
// reports — security & admin
|
|
||||||
.requestMatchers("/api/reports/**").hasAnyRole("SECURITY", "ADMIN")
|
|
||||||
// everything else requires a logged-in user
|
|
||||||
.anyRequest().authenticated()
|
|
||||||
)
|
)
|
||||||
// H2 console renders in a frame
|
// ensure the XSRF-TOKEN cookie is written on every response
|
||||||
.headers(h -> h.frameOptions(f -> f.sameOrigin()))
|
.addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class)
|
||||||
|
.authorizeHttpRequests(authz -> {
|
||||||
|
authz
|
||||||
|
// public
|
||||||
|
.requestMatchers("/api/auth/login", "/api/auth/logout").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)/보안/관리자
|
||||||
|
.requestMatchers("/api/access/**").hasAnyRole("HOST", "SECURITY", "ADMIN")
|
||||||
|
// approvals — IT센터 관리자(admin) 전용
|
||||||
|
.requestMatchers("/api/approvals/**").hasRole("ADMIN")
|
||||||
|
// blacklist & admin management — admin only
|
||||||
|
.requestMatchers("/api/blacklist/**", "/api/admin/**").hasRole("ADMIN")
|
||||||
|
// reports — security & admin
|
||||||
|
.requestMatchers("/api/reports/**").hasAnyRole("SECURITY", "ADMIN")
|
||||||
|
// everything else requires a logged-in user
|
||||||
|
.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))
|
.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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user