From d75c0da00ef4e36d47cd9577adfca8d2475420f3 Mon Sep 17 00:00:00 2001 From: infraurp Date: Mon, 31 Aug 2026 10:31:22 +0900 Subject: [PATCH] =?UTF-8?q?[=EB=B3=B4=EC=95=88/SER-004]=20CSRF=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EB=B0=A9=EC=96=B4=20=EC=8B=A0=EA=B7=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01AbEjijyQdS54xso1d2ks3u --- .../interceptor/CsrfTokenInterceptor.java | 236 ++++++++++++++++++ .../springmvc/egov-com-servlet.xml | 23 ++ .../WEB-INF/jsp/site/mnt/include/head.jsp | 53 ++++ 3 files changed, 312 insertions(+) create mode 100644 src/main/java/egovframework/com/cmm/privacy/interceptor/CsrfTokenInterceptor.java diff --git a/src/main/java/egovframework/com/cmm/privacy/interceptor/CsrfTokenInterceptor.java b/src/main/java/egovframework/com/cmm/privacy/interceptor/CsrfTokenInterceptor.java new file mode 100644 index 0000000..882d304 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/interceptor/CsrfTokenInterceptor.java @@ -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 / 웹취약점 조치). + * + *

Spring Security 2.0.4 에는 CSRF 토큰 기능이 없어, 세션 기반 동기화 토큰(Synchronizer Token)을 + * 이 인터셉터로 자체 구현한다. 개인정보취급자 영역(기본 {@code /mnt/})의 상태변경 요청 + * (POST/PUT/DELETE/PATCH)에 대해 요청 토큰과 세션 토큰의 일치를 검증한다.

+ * + *

이중 방어(회귀 최소화): 본 프로젝트는 관리자 화면이 폼 전송(다수)과 AJAX(일부)로 혼재한다. + * 공통 헤더({@code /mnt/include/head.jsp})가 모든 폼에 토큰 hidden 을, 모든 AJAX 에 {@code X-CSRF-TOKEN} + * 헤더를 자동 주입하지만, 공통 헤더를 쓰지 않는 화면·동적 생성 폼이 있을 수 있다. 따라서 토큰이 + * 없거나 불일치해도 같은 출처(Origin/Referer host == Host) 요청이면 통과시킨다(정상 화면 회귀 방지). + * CSRF 공격은 본질적으로 다른 출처(cross-origin)에서 발생하므로, 토큰 불일치 + 교차 출처인 경우에만 차단한다. + * 토큰이 정상 첨부된 요청은 출처와 무관하게 통과하므로 방어의 상한은 토큰이 담당한다.

+ * + *

적용 범위 판정: 본 프로젝트는 전용 {@code } 경로매핑이 아니라 + * {@code selectAnnotaionMapper}가 {@code /**}/*.do 전체에 인터셉터를 적용하므로, 적용 범위 판정을 + * 이 클래스 내부에서 수행한다(그 외 경로는 통과). {@code enforce=false}로 두면 검증 실패를 로그로만 + * 남기고 통과시켜(관찰 모드), 운영 반영 전 토큰 미첨부 화면을 식별할 수 있다.

+ * + *
+ * << 개정이력(Modification Information) >>
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.08.30    URP     최초 생성 (개인정보보호 기능개선 사업 SER-004 / CSRF 조치)
+ * 
+ */ +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 excludePatterns; + + public void setEnforce(boolean enforce) { this.enforce = enforce; } + public void setApplyPathPrefix(String applyPathPrefix) { this.applyPathPrefix = applyPathPrefix; } + public void setExcludePatterns(List 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("" + + "" + + ""); + 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; + } +} diff --git a/src/main/webapp/WEB-INF/config/egovframework/springmvc/egov-com-servlet.xml b/src/main/webapp/WEB-INF/config/egovframework/springmvc/egov-com-servlet.xml index 5686b1c..21d2fce 100644 --- a/src/main/webapp/WEB-INF/config/egovframework/springmvc/egov-com-servlet.xml +++ b/src/main/webapp/WEB-INF/config/egovframework/springmvc/egov-com-servlet.xml @@ -105,6 +105,8 @@ + + @@ -251,6 +253,27 @@ + + + + + + + \A/mnt/(main|left|top|right|footer)\.do.*\Z + \A/mnt/(include)/.*\.do.*\Z + \A/mnt/session/.*\Z + + + +