문제의 출발점
Java·Spring Framework·JSP 기반의 기존 시스템에는 Lucy XSS Filter가 적용돼 있었지만, 일부 POST 요청 위주로 동작해 GET·application/json·multipart/form-data 요청을 일관되게 검증하지 못했다. 보안 점검 결과를 반영해 GET 요청과 태그 속성에 대한 차단 범위를 넓히자 방어 범위는 넓어졌지만 기존 기능에서 서로 다른 Side Effect가 나타났다.
- JSP가 Controller로 Forward하면서 전달한 필수 파라미터가 사라졌다.
- 메뉴 렌더링 과정에서 사용하던 링크의
href속성이 제거됐다. - 파일 업로드 요청의 일반 파라미터는 여전히 필터링되지 않았다.
- JSON Body를 필터에서 읽으면 이후 Controller가 다시 읽지 못할 가능성이 있었다.
보안 문자열이 차단되는지만 확인해서는 해결할 수 없는 문제였다. 요청이 Filter에서 Controller까지 어떤 객체와 데이터 형태로 전달되는지, 기존 기능이 그 요청을 다시 어떻게 사용하는지부터 분리해 확인해야 했다.
먼저 요청 흐름을 네 가지로 분리했다
같은 XSS Filter를 거치더라도 요청 데이터의 위치와 생명주기는 같지 않다.
- GET·일반 POST:
getParameter,getParameterValues,getParameterMap계열로 값을 읽는다. - Forward: 새로운 HTTP 요청을 만들지 않고 현재 Request를 다음 Servlet이나 Controller까지 이어서 사용한다.
application/json: 파라미터 Map이 아니라 Request Body를 읽어야 하며, 한 번 소비한 Body를 다시 제공할 방법이 필요하다.multipart/form-data: 일반 파라미터와 파일 Part를 분리하되 파일 스트림과 메타데이터를 훼손하지 않아야 한다.
따라서 모든 요청을 하나의 Wrapper로 감싸면 된다는 접근 대신 Method와 Content-Type별로 데이터가 어디에 있고 누가 읽는지를 확인했다.
Forward에서만 필수 파라미터가 사라졌다
문제가 발생한 화면은 JSP에서 jsp:forward로 Controller를 호출하면서 공개용 가명 legacyMode 같은 필수 파라미터를 추가했다. GET 필터링 적용 전에는 정상 전달됐지만, 적용 후 Controller에서는 값이 없다고 판단했다.
아래 코드는 문제 해결 구조를 설명하기 위한 pseudocode이며 실제 클래스·테이블·설정 이름과는 무관합니다.
Client
→ JSP 요청
→ XSS Filter에서 RequestWrapper 생성
→ JSP가 필수 파라미터를 추가해 Controller로 Forward
→ Controller의 getParameter("legacyMode") 결과 누락Redirect와 달리 Forward는 브라우저에 새 요청을 요구하지 않는다. 같은 애플리케이션 안에서 현재 Request를 재사용하므로 URL도 바뀌지 않는다. 이 차이가 원인을 좁히는 단서였다.
RequestWrapper가 만든 두 개의 파라미터 경로
HttpServletRequest는 애플리케이션 코드가 요청 파라미터를 직접 바꿀 수 있는 setter를 제공하지 않는다. XSS Filter는 원본 값을 수정하는 대신 HttpServletRequestWrapper에 필터링한 값을 보관하고, getParameter 계열 메서드를 Override해 이후 코드가 정제된 값을 읽도록 구성한다.
문제는 Forward 이후에 나타났다.
- 최초 요청의 파라미터는 Filter가 만든 Wrapper 내부 Map에 보관됐다.
- Forward 과정에서 추가된 파라미터는 Servlet Container가 원본 Request 쪽에 반영했다.
- 애플리케이션이 호출한 객체는 여전히 Wrapper였다.
- Override된
getParameter는 Wrapper 내부 Map만 조회했다. - 최초 요청에 없던 Forward 파라미터는 Wrapper Map에 없었으므로
null이 반환됐다.
원본 Request에는 값이 있지만 Wrapper가 그 경로를 가리고 있던 것이다. 파라미터가 전달되지 않았다기보다 전달된 값을 Override 메서드가 읽지 못했다가 정확한 원인이었다.
Redirect 전환 대신 Wrapper를 보완한 이유
처음에는 Forward를 Redirect로 바꿔 새로운 Request를 만들면 문제가 사라질 것으로 봤다. 하지만 Redirect는 브라우저가 다시 요청하므로 URL이 바뀌고, 파라미터 전달과 화면 흐름도 함께 달라진다. 기존 호출 계약을 바꾸는 범위가 커지고 다른 화면의 회귀 위험도 있었다.
선택한 방법은 Wrapper에 값이 없을 때 상위 Request의 값을 확인하는 fallback을 추가하는 것이었다. 아래 코드는 공개용으로 단순화한 구조다.
아래 코드는 문제 해결 구조를 설명하기 위한 pseudocode이며 실제 클래스·테이블·설정 이름과는 무관합니다.
@Override
public String getParameter(String name) {
String value = filteredParameterMap.get(name);
if (value == null) {
value = super.getParameter(name);
}
return value == null ? null : filter(value);
}핵심은 Forward 이후 추가된 값을 원본 Request에서 다시 찾되, fallback이 XSS 검증 우회 경로가 되지 않도록 같은 필터링 규칙을 적용하는 것이다. getParameterValues, getParameterMap, getParameterNames처럼 같은 데이터를 노출하는 메서드도 서로 다른 결과를 반환하지 않는지 함께 확인했다.
이 보완에도 남는 경계
최초 요청과 Forward 요청이 같은 이름의 파라미터를 서로 다른 값으로 전달하면 어느 값을 우선할지 결정해야 한다. 단순히 Wrapper 값을 먼저 반환하면 최초 값이 남고, 상위 Request를 먼저 보면 Forward 값이 우선될 수 있다.
당시 확인한 사용 경로에서는 Forward가 새로운 필수 파라미터를 추가하는 형태였고, 같은 이름의 값을 바꾸는 사례는 확인되지 않았다. 따라서 실제 장애를 해결하는 fallback을 적용하되, Forward에서 파라미터 값을 덮어쓰는 기능이 추가되면 Wrapper 우선순위를 다시 검토해야 한다는 제한을 남겼다.
href 차단이 메뉴와 다운로드를 깨뜨린 이유
속성 기반 공격을 막기 위해 href 사용 범위를 제한하자, DB에서 관리되는 메뉴 URL과 사용자 가이드 다운로드 링크까지 제거되는 문제가 발생했다. XSS Filter는 그 값이 공격 문자열인지 업무상 필요한 링크인지 자체적으로 알 수 없다.
메뉴 렌더링은 일반 화면 요청과 달리 서비스 로직에서 메뉴 URL을 다시 조회하고 화면에 조립하는 구조였다. 이 경로를 일반 사용자 입력과 같은 규칙으로 처리하면서 정상 링크가 사라졌다.
업무상 HTML과 URL을 사용해야 하는 경로는 설정 기반 예외 URL로 분리했다. 다만 예외 등록은 안전한 URL 선언이 아니라 공통 필터가 책임지지 않는 구간을 만드는 일이다. 예외 경로가 요청 파라미터를 그대로 DB에 저장하거나 HTML에 출력한다면 개별 검증과 출력 인코딩이 필요하다. 그래서 예외는 메뉴·다운로드 등 확인된 최소 범위로 제한하고 서비스별 회귀 테스트 대상으로 관리했다.
Multipart는 파일과 일반 파라미터를 분리했다
기존 XSS Filter는 multipart/form-data의 Part를 해석하지 못해 일반 파라미터가 없는 요청처럼 처리했다. 파일 업로드 기능은 동작해도 파일과 함께 전송된 제목·설명 등의 값은 XSS 검증 범위 밖에 남을 수 있었다.
Multipart 전용 Wrapper에서는 다음 책임을 분리했다.
- 파일 Part와 파일 메타데이터는 업로드 처리 흐름을 유지한다.
- 일반 Form Data만 파라미터 Map으로 분리해 XSS Filter를 적용한다.
- Controller가 기대하는 Multipart 요청 타입과 파일 조회 메서드를 유지한다.
- 파일 스트림을 Filter에서 불필요하게 소비하지 않는다.
JSON 요청에는 Body를 캐시해 이후 처리 단계가 다시 읽을 수 있는 전용 Wrapper를 사용했다. 요청 형식이 다른데도 하나의 Wrapper로 통합하면 구현은 짧아지지만 Body 소진, 파일 누락, 파라미터 불일치 같은 회귀를 만들 수 있어 유형별 책임을 분리했다.
검증 범위
보안 문자열 몇 개가 차단되는지만 확인하지 않았다. XSS Filter가 요청 처리 계약을 바꾸는 공통 기능인 만큼 정상 입력과 기존 업무 흐름을 같은 비중으로 검증했다.
- GET·POST·JSON·Multipart별 허용 입력과 차단 입력
- Forward 전후의 필수 파라미터와 기존 파라미터 유지
- 메뉴 렌더링, 사용자 가이드와 결과 파일 다운로드
- 일반 파라미터를 포함한 파일 업로드
- 예외 URL에서 입력값이 저장·출력되는 경로
- 본사 적용 후 일본·유럽법인의 법인별 메뉴·업로드·외부 연계 차이
이후 본사에서 확인한 GET·POST·JSON·Multipart 검증 구조와 추가 차단 정책을 일본·유럽법인 COMS에도 반영했다. 법인별로 본사와 다른 URL·외부 응답 형식은 별도 예외와 회귀 범위로 확인했다.
정리
Forward 파라미터 누락의 원인은 Servlet Container가 값을 전달하지 않은 것이 아니라, XSS Filter의 Wrapper가 Forward 이후 원본 Request에 추가된 값을 보지 못한 것이었다. Wrapper fallback으로 기존 Forward 계약을 보존하고, GET·JSON·Multipart의 서로 다른 데이터 생명주기에 맞춰 필터 책임을 나눴다.
XSS Filter는 문자열 몇 개를 치환하는 기능이 아니다. 공통 Request를 바꾸는 순간 Forward, Body, 파일, 메뉴 렌더링과 예외 URL까지 영향을 받는다. 방어 범위를 넓힐수록 차단 테스트와 정상 업무 회귀 테스트를 하나의 작업으로 다뤄야 한다.