[보안/SER-004] CSRF 토큰 방어 신규 구현

Spring Security 2.0.4 에 CSRF 기능이 없어 세션 동기화 토큰(Synchronizer Token)을
자체 구현. 웹취약점 점검(ZAP)의 "Absence of Anti-CSRF Tokens" 및 정적 분석에서
관리자 상태변경 요청 다수가 무방비였던 것을 조치.

- CsrfTokenInterceptor(신규): /mnt/ 상태변경 요청(POST/PUT/DELETE)에서 토큰 검증.
  이중 방어 — 토큰 없거나 불일치해도 '같은 출처'면 통과(정상 화면 회귀 방지),
  '교차 출처'만 차단(CSRF 공격 차단). 토큰은 SecureRandom 48자 hex.
- egov-com-servlet.xml: 인터셉터 리스트 등록 + bean 정의(enforce=true, /mnt/ 한정).
- mnt/include/head.jsp: 토큰 meta + 전역 스크립트로 모든 폼(hidden)·AJAX(X-CSRF-TOKEN
  헤더)에 자동 주입. 167개 관리자 화면 공통 적용, 개별 화면 수정 없음.

검증: cross-origin+토큰없음 → 403 차단, same-origin(토큰유무 무관) → 통과(회귀 없음).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbEjijyQdS54xso1d2ks3u
This commit is contained in:
infraurp 2026-08-31 10:31:22 +09:00
parent a085956a8b
commit d75c0da00e
3 changed files with 312 additions and 0 deletions

View File

@ -0,0 +1,236 @@
package egovframework.com.cmm.privacy.interceptor;
import java.io.PrintWriter;
import java.security.SecureRandom;
import java.util.List;
import java.util.regex.Pattern;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
/**
* CSRF(크로스사이트 요청 위조) 방지 인터셉터 — 개인정보보호 기능개선 사업(SER-004 / 웹취약점 조치).
*
* <p>Spring Security 2.0.4 에는 CSRF 토큰 기능이 없어, 세션 기반 동기화 토큰(Synchronizer Token)을
* 이 인터셉터로 자체 구현한다. 개인정보취급자 영역(기본 {@code /mnt/})의 상태변경 요청
* (POST/PUT/DELETE/PATCH)에 대해 요청 토큰과 세션 토큰의 일치를 검증한다.</p>
*
* <p><b>이중 방어(회귀 최소화):</b> 본 프로젝트는 관리자 화면이 폼 전송(다수)과 AJAX(일부)로 혼재한다.
* 공통 헤더({@code /mnt/include/head.jsp})가 모든 폼에 토큰 hidden 을, 모든 AJAX 에 {@code X-CSRF-TOKEN}
* 헤더를 자동 주입하지만, 공통 헤더를 쓰지 않는 화면·동적 생성 폼이 있을 수 있다. 따라서 토큰이
* 없거나 불일치해도 <b>같은 출처(Origin/Referer host == Host)</b> 요청이면 통과시킨다(정상 화면 회귀 방지).
* CSRF 공격은 본질적으로 다른 출처(cross-origin)에서 발생하므로, 토큰 불일치 + 교차 출처인 경우에만 차단한다.
* 토큰이 정상 첨부된 요청은 출처와 무관하게 통과하므로 방어의 상한은 토큰이 담당한다.</p>
*
* <p><b>적용 범위 판정:</b> 본 프로젝트는 전용 {@code <mvc:interceptors>} 경로매핑이 아니라
* {@code selectAnnotaionMapper}가 {@code /**}/*.do 전체에 인터셉터를 적용하므로, 적용 범위 판정을
* 이 클래스 내부에서 수행한다(그 외 경로는 통과). {@code enforce=false}로 두면 검증 실패를 로그로만
* 남기고 통과시켜(관찰 모드), 운영 반영 전 토큰 미첨부 화면을 식별할 수 있다.</p>
*
* <pre>
* << 개정이력(Modification Information) >>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.08.30 URP 최초 생성 (개인정보보호 기능개선 사업 SER-004 / CSRF 조치)
* </pre>
*/
public class CsrfTokenInterceptor extends HandlerInterceptorAdapter {
private static final Logger LOG = LoggerFactory.getLogger(CsrfTokenInterceptor.class);
/** 세션에 저장되는 CSRF 토큰 키. head.jsp 가 ${sessionScope.CSRF_TOKEN} 으로 읽는다. */
public static final String ATTR_CSRF_TOKEN = "CSRF_TOKEN";
/** 요청이 토큰을 싣는 헤더명(AJAX) */
private static final String HEADER_NAME = "X-CSRF-TOKEN";
/** 요청이 토큰을 싣는 파라미터명(폼 전송) */
private static final String PARAM_NAME = "_csrf";
private static final SecureRandom RANDOM = new SecureRandom();
/** 강제 여부. false 면 검증 실패를 로그만 남기고 통과(관찰 모드). */
private boolean enforce = true;
/** 적용 경로 접두(개인정보취급자 영역). 기본 /mnt/ */
private String applyPathPrefix = "/mnt/";
/** 적용 제외 정규식 패턴(contextPath 제외 URI 대상) */
private List<String> excludePatterns;
public void setEnforce(boolean enforce) { this.enforce = enforce; }
public void setApplyPathPrefix(String applyPathPrefix) { this.applyPathPrefix = applyPathPrefix; }
public void setExcludePatterns(List<String> excludePatterns) { this.excludePatterns = excludePatterns; }
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String ctx = request.getContextPath();
String uri = request.getRequestURI();
String path = (ctx != null && uri.startsWith(ctx)) ? uri.substring(ctx.length()) : uri;
// 1) 취급자 영역(/mnt/) 외 요청은 통과 — 공개 프론트에 영향 없음
if (applyPathPrefix != null && !path.startsWith(applyPathPrefix)) {
return true;
}
// 2) 세션 토큰 보장 — GET 진입 시에도 발급되어 head.jsp 가 화면에 심을 수 있게 한다.
HttpSession session = request.getSession(true);
String sessionToken = (String) session.getAttribute(ATTR_CSRF_TOKEN);
if (sessionToken == null || sessionToken.length() == 0) {
sessionToken = generateToken();
session.setAttribute(ATTR_CSRF_TOKEN, sessionToken);
}
// 3) 제외 패턴(로그인/프레임/공통 include 등) 통과
if (isExcluded(path)) {
return true;
}
// 4) 안전 메서드(GET/HEAD/OPTIONS/TRACE)는 상태를 바꾸지 않으므로 검증 대상 아님
String method = request.getMethod();
if (method == null || isSafeMethod(method)) {
return true;
}
// 5) 상태변경 요청 — 토큰 검증
String requestToken = request.getHeader(HEADER_NAME);
if (requestToken == null || requestToken.length() == 0) {
requestToken = request.getParameter(PARAM_NAME);
}
if (requestToken != null && constantTimeEquals(sessionToken, requestToken)) {
return true; // 토큰 일치 — 정상
}
// 6) 토큰 불일치/부재 — 같은 출처면 통과(회귀 방지), 교차 출처면 CSRF 로 차단
if (isSameOrigin(request)) {
LOG.warn("[개인정보/SER-004] CSRF 토큰 미첨부(같은 출처라 통과) — path={}, method={}", path, method);
return true;
}
if (!enforce) {
LOG.warn("[개인정보/SER-004] CSRF 토큰 불일치(관찰 모드 통과) — path={}, method={}, origin={}",
new Object[] { path, method, originOf(request) });
return true;
}
LOG.error("[개인정보/SER-004] CSRF 차단 — 교차 출처 상태변경 요청. path={}, method={}, origin={}",
new Object[] { path, method, originOf(request) });
writeForbidden(request, response);
return false;
}
/** 차단 응답. AJAX(JSON) 요청과 일반 폼 요청을 구분해 응답한다. */
private void writeForbidden(HttpServletRequest request, HttpServletResponse response) throws Exception {
response.setStatus(HttpServletResponse.SC_FORBIDDEN); // 403
String accept = request.getHeader("Accept");
String xrw = request.getHeader("X-Requested-With");
boolean ajax = (xrw != null) || (accept != null && accept.contains("application/json"));
if (ajax) {
response.setContentType("application/json; charset=UTF-8");
PrintWriter out = response.getWriter();
out.print("{\"resCode\":\"403\",\"message\":\"잘못된 요청입니다. 페이지를 새로고침한 뒤 다시 시도해 주세요.\"}");
out.flush();
} else {
response.setContentType("text/html; charset=UTF-8");
PrintWriter out = response.getWriter();
out.print("<html><head><meta charset='UTF-8'></head><body>"
+ "<script>alert('잘못된 요청입니다. 페이지를 새로고침한 뒤 다시 시도해 주세요.');history.back();</script>"
+ "</body></html>");
out.flush();
}
}
private boolean isSafeMethod(String method) {
return "GET".equalsIgnoreCase(method) || "HEAD".equalsIgnoreCase(method)
|| "OPTIONS".equalsIgnoreCase(method) || "TRACE".equalsIgnoreCase(method);
}
private boolean isExcluded(String path) {
if (excludePatterns == null) {
return false;
}
for (String p : excludePatterns) {
if (p != null && p.length() > 0 && Pattern.matches(p, path)) {
return true;
}
}
return false;
}
/**
* 같은 출처 여부. Origin(우선) 또는 Referer 의 host 가 요청 Host 와 같으면 true.
* 두 헤더가 모두 없으면(브라우저 정상 폼 전송은 통상 Referer 를 보냄) 관례상 통과(true)로 처리해
* 프록시·직접 호출 등에서의 과잉 차단을 피한다. CSRF 공격은 헤더가 실려 교차 출처로 판별된다.
*/
private boolean isSameOrigin(HttpServletRequest request) {
String source = originOf(request);
if (source == null) {
return true; // 출처 헤더 없음 — 관례상 통과(토큰이 주 방어)
}
String sourceHost = hostOf(source);
String requestHost = hostOf(request.getHeader("Host"));
return sourceHost != null && sourceHost.equalsIgnoreCase(requestHost);
}
private String originOf(HttpServletRequest request) {
String source = request.getHeader("Origin");
if (source == null) {
source = request.getHeader("Referer");
}
return source;
}
private String hostOf(String value) {
if (value == null) {
return null;
}
String host = value;
int scheme = host.indexOf("://");
if (scheme >= 0) {
host = host.substring(scheme + 3);
}
int slash = host.indexOf('/');
if (slash >= 0) {
host = host.substring(0, slash);
}
int colon = host.indexOf(':');
if (colon >= 0) {
host = host.substring(0, colon);
}
return host.length() == 0 ? null : host;
}
/** SecureRandom 24바이트를 48자 소문자 hex 로 인코딩(외부 의존 없음, URL·헤더 안전). */
private String generateToken() {
byte[] bytes = new byte[24];
RANDOM.nextBytes(bytes);
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (int i = 0; i < bytes.length; i++) {
int v = bytes[i] & 0xFF;
sb.append(Character.forDigit(v >> 4, 16));
sb.append(Character.forDigit(v & 0x0F, 16));
}
return sb.toString();
}
/** 길이·내용 비교(타이밍 부채널 완화). */
private boolean constantTimeEquals(String a, String b) {
if (a == null || b == null) {
return false;
}
if (a.length() != b.length()) {
return false;
}
int result = 0;
for (int i = 0; i < a.length(); i++) {
result |= a.charAt(i) ^ b.charAt(i);
}
return result == 0;
}
}

View File

@ -105,6 +105,8 @@
<!-- ② 2단계 인증 판정(SFR-003/#14) → ③ 권한 판정(SFR-001/#2) --> <!-- ② 2단계 인증 판정(SFR-003/#14) → ③ 권한 판정(SFR-001/#2) -->
<ref local="secondFactorInterceptor" /> <ref local="secondFactorInterceptor" />
<ref local="authorityInterceptor" /> <ref local="authorityInterceptor" />
<!-- ④ CSRF 토큰 검증(SER-004 / 웹취약점 조치) — 상태변경 요청 위조 차단 -->
<ref local="csrfTokenInterceptor" />
<!-- <ref bean="egovWebLogInterceptor" /> --> <!-- <ref bean="egovWebLogInterceptor" /> -->
</list> </list>
</property> </property>
@ -251,6 +253,27 @@
</property> </property>
</bean> </bean>
<!-- ============================================================
CSRF 토큰 검증 인터셉터 (SER-004 / 웹취약점 조치 · 2026.08.30)
· Spring Security 2.0.4 에 CSRF 기능이 없어 세션 동기화 토큰을 자체 구현.
· /mnt/ 상태변경 요청(POST/PUT/DELETE/PATCH)에서 토큰↔세션 일치 검증.
· 이중 방어: 토큰 없거나 불일치해도 '같은 출처'면 통과(정상 화면 회귀 방지),
'교차 출처'만 차단 → head.jsp 미적용 화면도 안 깨지고 CSRF 공격만 막는다.
· 토큰은 /mnt/include/head.jsp 가 모든 폼(hidden)·AJAX(X-CSRF-TOKEN 헤더)에 자동 주입.
· enforce=false 로 두면 관찰 모드(로그만). excludePatterns 는 프레임·공통 include 제외.
============================================================ -->
<bean id="csrfTokenInterceptor" class="egovframework.com.cmm.privacy.interceptor.CsrfTokenInterceptor">
<property name="enforce" value="true" />
<property name="applyPathPrefix" value="/mnt/" />
<property name="excludePatterns">
<list>
<value>\A/mnt/(main|left|top|right|footer)\.do.*\Z</value>
<value>\A/mnt/(include)/.*\.do.*\Z</value>
<value>\A/mnt/session/.*\Z</value>
</list>
</property>
</bean>
<!-- ============================================================ <!-- ============================================================
[개인정보보호 SFR-004 / 2026.08.07] 관리자 화면 메뉴 접속기록. [개인정보보호 SFR-004 / 2026.08.07] 관리자 화면 메뉴 접속기록.
종전 서비스 AOP 수집(personalDataLogAspect, context-syslogaop.xml)을 대체한다. 종전 서비스 AOP 수집(personalDataLogAspect, context-syslogaop.xml)을 대체한다.

View File

@ -5,6 +5,9 @@
<html xmlns="http://www.w3.org/1999/xhtml" lang="ko" xml:lang="ko"> <html xmlns="http://www.w3.org/1999/xhtml" lang="ko" xml:lang="ko">
<head> <head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<%-- [보안/SER-004] CSRF 토큰. CsrfTokenInterceptor 가 세션에 발급한 값을 화면에 노출한다. --%>
<meta name="_csrf" content="${sessionScope.CSRF_TOKEN}" />
<meta name="_csrf_header" content="X-CSRF-TOKEN" />
<title>통합관리시스템 <title>통합관리시스템
<c:forEach items="${siteMenuNavigation}" var="result" varStatus="status"> <c:forEach items="${siteMenuNavigation}" var="result" varStatus="status">
&gt; &gt;
@ -34,3 +37,53 @@
</style> </style>
<script type="text/javascript" src="/js/site/frt/util.js"></script> <script type="text/javascript" src="/js/site/frt/util.js"></script>
<%-- ============================================================
[보안/SER-004] CSRF 토큰 전역 주입 (jQuery 로드 이후)
· 모든 폼에 hidden(_csrf) 을 자동 추가 → 폼 전송(다수) 커버
· 모든 jQuery AJAX 요청에 X-CSRF-TOKEN 헤더 자동 첨부 → AJAX 커버
· 개별 화면 수정 없이 공통 헤더 한 곳으로 적용. 동적 생성 폼은 submit 시점에 재확인.
============================================================ --%>
<script type="text/javascript">
(function(){
var token = null, headerName = 'X-CSRF-TOKEN';
try {
token = document.querySelector('meta[name="_csrf"]').getAttribute('content');
headerName = document.querySelector('meta[name="_csrf_header"]').getAttribute('content') || headerName;
} catch(e) { return; }
if (!token) { return; }
// 1) jQuery AJAX 전역 헤더
if (window.jQuery) {
jQuery(document).ajaxSend(function(e, xhr){ xhr.setRequestHeader(headerName, token); });
}
// 2) 폼에 hidden 토큰 주입 (없을 때만)
function ensureField(form){
if (!form || form.__csrfDone) { return; }
var exist = form.querySelector('input[name="_csrf"]');
if (!exist) {
var input = document.createElement('input');
input.type = 'hidden'; input.name = '_csrf'; input.value = token;
form.appendChild(input);
} else {
exist.value = token;
}
form.__csrfDone = true;
}
function injectAll(){
var forms = document.getElementsByTagName('form');
for (var i = 0; i < forms.length; i++) { ensureField(forms[i]); }
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', injectAll);
} else {
injectAll();
}
// 동적 생성·직전 추가 폼 대비: submit 순간에도 재확인
document.addEventListener('submit', function(ev){
var f = ev.target;
if (f && f.tagName === 'FORM') { f.__csrfDone = false; ensureField(f); }
}, true);
})();
</script>