보안·입력 검증 · Web Security

GET 요청에 XSS 필터를 적용한 뒤 포워드 파라미터가 사라진 이유

같은 요청을 재사용하는 포워드에서 래퍼와 원본 요청의 파라미터가 어긋났습니다. 원인을 고친 뒤 GET·JSON·Multipart 요청에 맞는 별도 처리로 방어 범위를 넓힌 사례입니다.

XSSServlet FilterRequestWrapperMultipart

GET 검사를 추가하자 Forward 값이 사라졌습니다

Java·Spring Framework·JSP 기반의 기존 시스템에는 Lucy XSS 필터가 적용돼 있었지만, 일부 POST 요청 위주로 동작해 GET·application/json·multipart/form-data 요청을 일관되게 검증하지 못했습니다. 보안 점검 결과를 반영해 GET 요청과 태그 속성에 대한 차단 범위를 넓히자 기존 기능에서 서로 다른 부작용이 나타났습니다.

  • 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를 분리하되 파일 스트림과 메타데이터를 훼손하지 않아야 합니다.

모든 요청을 하나의 래퍼로 감싸면 된다고 보기 어려운 이유입니다. HTTP 메서드와 Content-Type별로 데이터가 어디에 있고 누가 읽는지부터 구분했습니다.

Forward에서만 필수 파라미터가 사라졌습니다

문제가 발생한 화면은 JSP에서 jsp:forward로 Controller를 호출하면서 공개용 가명 legacyMode 같은 필수 파라미터를 추가했습니다. GET 필터링 적용 전에는 정상 전달됐지만, 적용 후 Controller에서는 값이 없다고 판단했습니다.

아래 순서와 코드는 실제 이름을 드러내지 않도록 핵심 동작만 단순화했습니다.

Client
  → JSP 요청
  → XSS Filter에서 RequestWrapper 생성
  → JSP가 필수 파라미터를 추가해 Controller로 Forward
  → Controller의 getParameter("legacyMode") 결과 누락

Redirect와 달리 Forward는 브라우저에 새 요청을 요구하지 않습니다. 같은 애플리케이션 안에서 현재 Request를 재사용하므로 URL도 바뀌지 않습니다. 이 차이가 원인을 좁히는 단서였습니다.

원본 요청과 래퍼에 파라미터가 따로 남았습니다

HttpServletRequest는 애플리케이션 코드가 요청 파라미터를 직접 바꿀 수 있는 setter를 제공하지 않습니다. XSS Filter는 원본 값을 수정하는 대신 HttpServletRequestWrapper에 필터링한 값을 보관하고, getParameter 계열 메서드를 Override해 이후 코드가 정제된 값을 읽도록 구성합니다.

문제는 Forward 이후에 나타났습니다.

  1. 최초 요청의 파라미터는 Filter가 만든 Wrapper 내부 Map에 보관됐습니다.
  2. Forward 과정에서 추가된 파라미터는 Servlet Container가 원본 Request 쪽에 반영했습니다.
  3. 애플리케이션이 호출한 객체는 여전히 Wrapper였습니다.
  4. Override된 getParameter는 Wrapper 내부 Map만 조회했습니다.
  5. 최초 요청에 없던 Forward 파라미터는 Wrapper Map에 없었으므로 null이 반환됐습니다.

원본 Request에는 값이 있지만 Wrapper가 그 경로를 가리고 있던 것입니다. 파라미터가 전달되지 않았다기보다 전달된 값을 Override 메서드가 읽지 못했다가 정확한 원인이었습니다.

기존 Forward를 유지하고 래퍼를 보완했습니다

처음에는 포워드를 리다이렉트로 바꿔 새로운 요청을 만들면 문제가 사라질 것으로 봤습니다. 하지만 리다이렉트는 브라우저가 다시 요청하므로 URL이 바뀌고, 파라미터 전달과 화면 이동도 함께 달라집니다. 기존 포워드 동작을 바꾸는 범위가 커지고 다른 화면의 회귀 위험도 있었습니다.

래퍼에 값이 없을 때 원본 요청의 값을 다시 찾는 대체 조회를 추가했습니다. 아래 코드는 이 동작을 단순화한 예시입니다.

@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에서 다시 찾고 같은 필터링 규칙을 적용했습니다. getParameterValues, getParameterMap, getParameterNames처럼 같은 데이터를 노출하는 메서드도 서로 다른 결과를 반환하지 않는지 함께 확인했습니다.

같은 이름의 파라미터가 있을 때의 우선순위

최초 요청과 Forward 요청이 같은 이름의 파라미터를 서로 다른 값으로 전달하면 어느 값을 우선할지 결정해야 합니다. 단순히 Wrapper 값을 먼저 반환하면 최초 값이 남고, 상위 Request를 먼저 보면 Forward 값이 우선될 수 있습니다.

당시 사용 경로에서 Forward는 새로운 필수 파라미터만 추가했고 같은 이름의 값을 바꾸지는 않았습니다. 따라서 해당 문제를 해결하는 대체 조회를 적용하되, 앞으로 Forward에서 파라미터 값을 덮어쓰게 된다면 Wrapper 우선순위를 다시 검토해야 합니다.

href 차단이 메뉴와 다운로드를 깨뜨린 이유

속성 기반 공격을 막기 위해 href 사용 범위를 제한하자, DB에서 관리되는 메뉴 URL과 사용자 가이드 다운로드 링크까지 제거되는 문제가 발생했습니다. XSS Filter는 그 값이 공격 문자열인지 업무상 필요한 링크인지 자체적으로 알 수 없습니다.

메뉴 렌더링은 서비스 로직에서 메뉴 URL을 다시 조회해 화면에 조립했습니다. 이 값을 일반 사용자 입력과 같은 규칙으로 처리하면서 정상 링크가 사라졌습니다.

업무상 HTML과 URL을 사용해야 하는 경로는 설정 기반 예외 URL로 분리했습니다. 그러나 필터 제외가 곧 안전을 뜻하지는 않습니다. XSS 방어의 마지막 단계는 값을 출력하는 HTML 속성·본문·JavaScript 같은 문맥에 맞는 인코딩입니다. 예외 경로에서도 업무값 검증과 출력 인코딩을 별도로 적용해야 하며, 제외 범위는 메뉴·다운로드처럼 확인된 최소 경로로 제한해야 합니다.

Multipart는 파일과 일반 파라미터를 분리했습니다

기존 XSS Filter는 multipart/form-data의 Part를 해석하지 못해 일반 파라미터가 없는 요청처럼 처리했습니다. 파일 업로드 기능은 동작해도 파일과 함께 전송된 제목·설명 등의 값은 XSS 검증 범위 밖에 남을 수 있었습니다.

Multipart 전용 래퍼에서는 파일과 일반 폼 데이터를 다르게 처리했습니다.

  • 파일 Part와 파일 메타데이터는 업로드 처리 흐름을 유지합니다.
  • 일반 Form Data만 파라미터 Map으로 분리해 XSS Filter를 적용합니다.
  • Controller가 기대하는 Multipart 요청 타입과 파일 조회 메서드를 유지합니다.
  • 파일 스트림을 Filter에서 불필요하게 소비하지 않습니다.

JSON 요청에는 본문을 캐시해 이후 처리 단계가 다시 읽을 수 있는 전용 래퍼를 사용했습니다. 요청 형식이 다른데도 하나의 래퍼로 통합하면 구현은 짧아지지만 본문 소진, 파일 누락, 파라미터 불일치 같은 회귀를 만들 수 있어 요청 형식마다 별도 래퍼를 사용했습니다.

검증 범위

보안 문자열 몇 개가 차단되는지만 확인하지 않았습니다. XSS 필터가 기존 요청 처리 방식에 영향을 주는 공통 기능인 만큼 정상 입력과 기존 업무 동작을 같은 비중으로 검증했습니다.

  • GET·POST·JSON·Multipart별 허용 입력과 차단 입력
  • Forward 전후의 필수 파라미터와 기존 파라미터 유지
  • 메뉴 렌더링, 사용자 가이드와 결과 파일 다운로드
  • 일반 파라미터를 포함한 파일 업로드
  • 예외 URL에서 입력값이 저장·출력되는 경로
  • 본사 적용 후 일본·유럽법인의 법인별 메뉴·업로드·외부 연계 차이

이후 본사에서 확인한 GET·POST·JSON·Multipart 검증 방식과 추가 차단 정책을 일본·유럽법인 COMS에도 반영했습니다. 법인별로 본사와 다른 URL·외부 응답 형식은 별도 예외와 회귀 범위로 확인했습니다.

정리

포워드 파라미터는 원본 요청에 남아 있었지만 XSS 필터의 래퍼가 새 값을 읽지 못했습니다. 원본 요청을 다시 조회할 때도 같은 필터링 규칙을 적용해 기존 포워드 동작을 살렸고, GET·JSON·Multipart는 서로 다른 데이터 수명에 맞춰 별도 래퍼로 나눴습니다.

XSS Filter가 공통 Request를 바꾸면 Forward, Body, 파일, 메뉴 렌더링과 예외 URL까지 영향을 받습니다. 방어 범위를 넓힐수록 차단 테스트와 정상 업무 회귀 테스트를 함께 수행해야 합니다.