[보안/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:
parent
a085956a8b
commit
d75c0da00e
@ -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;
|
||||
}
|
||||
}
|
||||
@ -105,6 +105,8 @@
|
||||
<!-- ② 2단계 인증 판정(SFR-003/#14) → ③ 권한 판정(SFR-001/#2) -->
|
||||
<ref local="secondFactorInterceptor" />
|
||||
<ref local="authorityInterceptor" />
|
||||
<!-- ④ CSRF 토큰 검증(SER-004 / 웹취약점 조치) — 상태변경 요청 위조 차단 -->
|
||||
<ref local="csrfTokenInterceptor" />
|
||||
<!-- <ref bean="egovWebLogInterceptor" /> -->
|
||||
</list>
|
||||
</property>
|
||||
@ -251,6 +253,27 @@
|
||||
</property>
|
||||
</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] 관리자 화면 메뉴 접속기록.
|
||||
종전 서비스 AOP 수집(personalDataLogAspect, context-syslogaop.xml)을 대체한다.
|
||||
|
||||
@ -5,6 +5,9 @@
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" lang="ko" xml:lang="ko">
|
||||
<head>
|
||||
<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>통합관리시스템
|
||||
<c:forEach items="${siteMenuNavigation}" var="result" varStatus="status">
|
||||
>
|
||||
@ -34,3 +37,53 @@
|
||||
</style>
|
||||
|
||||
<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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user