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
+
+
+
+