diff --git a/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/DecryptingMultipartRequestWrapper.java b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/DecryptingMultipartRequestWrapper.java new file mode 100644 index 0000000..358de2d --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/DecryptingMultipartRequestWrapper.java @@ -0,0 +1,90 @@ +package com.eactive.apim.portal.common.security.passwordcrypto; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.util.MultiValueMap; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.MultipartHttpServletRequest; + +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * 멀티파트(파일 업로드 동반) 요청 전용 복호화 래퍼. + * + *

배경 — {@link DecryptingRequestWrapper} 는 {@code getParameter()} 호출 시 + * {@code super.getParameter()} 위임 결과를 복호화하는 방식이다. 그런데 Spring 의 + * {@link org.springframework.web.multipart.support.MultipartFilter} 가 만드는 + * {@code MultipartHttpServletRequest} 는 자신이 직접 파싱한 폼 필드(파일이 아닌 text part) 값을 + * {@code getParameter()} 에서 곧바로 반환하고 {@code super.getParameter()} 로 위임하지 않는다. + * 그 결과 {@code DecryptingRequestWrapper} 가 멀티파트 파싱 "이전"(안쪽)에 씌워지면, 멀티파트 안의 + * 비밀번호 파라미터는 복호화되지 않은 봉투(ENC1) 문자열 그대로 컨트롤러까지 전달된다 — 법인가입처럼 + * 파일 첨부(사업자등록증)와 비밀번호가 같은 폼에 있는 경우 재현된다. + * + *

해결 — {@link PasswordDecryptFilter} 를 MultipartFilter "이후"에 실행되도록 순서를 + * 옮기면(그리고 Lucy XSS 필터보다는 여전히 앞서도록), 이 필터가 감싸는 대상이 이미 + * {@code MultipartHttpServletRequest} 다. 이때는 이 클래스로 감싸 {@link MultipartHttpServletRequest} + * 인터페이스 자체를 구현하고 파일 관련 메서드는 원본 멀티파트 요청에 위임한다 — Spring 의 + * {@code MultipartFile} 바인딩({@code WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class)})이 + * 이 래퍼를 그대로 인식하도록 하기 위함이다. 파라미터(텍스트 필드) 복호화 로직은 부모 + * {@link DecryptingRequestWrapper} 그대로 재사용한다. + */ +public class DecryptingMultipartRequestWrapper extends DecryptingRequestWrapper + implements MultipartHttpServletRequest { + + private final MultipartHttpServletRequest multipartRequest; + + public DecryptingMultipartRequestWrapper(MultipartHttpServletRequest request, + PasswordEnvelopeCodec codec, + PasswordKeyStore keyStore, + PasswordCryptoProperties properties) { + super(request, codec, keyStore, properties); + this.multipartRequest = request; + } + + @Override + public Iterator getFileNames() { + return multipartRequest.getFileNames(); + } + + @Override + public MultipartFile getFile(String name) { + return multipartRequest.getFile(name); + } + + @Override + public List getFiles(String name) { + return multipartRequest.getFiles(name); + } + + @Override + public Map getFileMap() { + return multipartRequest.getFileMap(); + } + + @Override + public MultiValueMap getMultiFileMap() { + return multipartRequest.getMultiFileMap(); + } + + @Override + public String getMultipartContentType(String paramOrFileName) { + return multipartRequest.getMultipartContentType(paramOrFileName); + } + + @Override + public HttpMethod getRequestMethod() { + return multipartRequest.getRequestMethod(); + } + + @Override + public HttpHeaders getRequestHeaders() { + return multipartRequest.getRequestHeaders(); + } + + @Override + public HttpHeaders getMultipartHeaders(String paramOrFileName) { + return multipartRequest.getMultipartHeaders(paramOrFileName); + } +} diff --git a/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/DecryptingRequestWrapper.java b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/DecryptingRequestWrapper.java index 941246a..dcb61ba 100644 --- a/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/DecryptingRequestWrapper.java +++ b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/DecryptingRequestWrapper.java @@ -18,9 +18,17 @@ import java.util.Set; * 파싱해버려, 본문을 직접 읽는 필터({@code ApiTesterFilter})가 이후 {@code getInputStream()} 을 못 쓴다. * 따라서 값은 {@code getParameter*} 호출 시점에만 건드린다.

* - *

Lucy XSS 필터({@code order = MIN_VALUE + 1})보다 에서 이 래퍼가 씌워지므로, - * 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거친다. 순서가 뒤바뀌면 특수문자가 든 비밀번호의 - * 이스케이프 여부가 달라져 기존 계정 로그인이 깨진다.

+ *

Lucy XSS 필터({@code order = MIN_VALUE + 3})보다 ({@code MIN_VALUE + 2})에서 이 래퍼가 + * 씌워지므로, 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거친다. 순서가 뒤바뀌면 특수문자가 든 + * 비밀번호의 이스케이프 여부가 달라져 기존 계정 로그인이 깨진다.

+ * + *

멀티파트(파일 업로드) 요청 주의 — {@code getParameter()} 는 {@code super.getParameter()} 위임 + * 결과를 복호화하는 방식이라, 이 래퍼가 감싸는 시점에 {@code super} 가 이미 멀티파트 파싱이 끝난 + * {@code MultipartHttpServletRequest} 여야만 폼 필드(파일이 아닌 text part)를 볼 수 있다 — Spring 의 + * 멀티파트 파라미터 맵은 자신이 파싱한 값을 곧바로 반환하고 감싸고 있는 원본 요청으로 위임하지 않기 + * 때문이다. 그래서 {@link PasswordDecryptFilter} 는 MultipartFilter({@code order = MIN_VALUE + 1}) + * "이후"에 실행되도록 등록돼 있고, 감싸는 대상이 {@code MultipartHttpServletRequest} 면 이 클래스 대신 + * {@link DecryptingMultipartRequestWrapper}(이 클래스를 상속하며 멀티파트 인터페이스도 구현)를 쓴다.

*/ @Slf4j public class DecryptingRequestWrapper extends HttpServletRequestWrapper { diff --git a/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordDecryptFilter.java b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordDecryptFilter.java index 7decae9..767f525 100644 --- a/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordDecryptFilter.java +++ b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordDecryptFilter.java @@ -1,6 +1,7 @@ package com.eactive.apim.portal.common.security.passwordcrypto; import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartHttpServletRequest; import javax.servlet.Filter; import javax.servlet.FilterChain; @@ -13,9 +14,13 @@ import java.io.IOException; /** * 봉투(ENC1) 파라미터를 평문으로 되돌리는 서블릿 필터. * - *

{@code PortalConfigSecurity} 에서 order {@code Integer.MIN_VALUE} 로 등록해 - * Lucy XSS 필터({@code MIN_VALUE + 1})보다 먼저 실행된다. 자세한 이유는 - * {@link DecryptingRequestWrapper} 주석 참고.

+ *

{@code PortalConfigSecurity} 에서 order {@code Integer.MIN_VALUE + 2} 로 등록해 + * MultipartFilter({@code MIN_VALUE + 1}) "이후", Lucy XSS 필터({@code MIN_VALUE + 3}) "이전"에 + * 실행된다. 자세한 이유는 {@link DecryptingRequestWrapper} / {@link DecryptingMultipartRequestWrapper} + * 주석 참고 — 멀티파트(파일 업로드 동반) 요청은 MultipartFilter 가 만든 + * {@code MultipartHttpServletRequest} 가 폼 필드를 자체 파싱해 반환하므로, 이 필터가 그보다 + * 먼저 실행되면 비밀번호 파라미터가 복호화되지 않은 채로 컨트롤러까지 전달된다(법인가입처럼 + * 파일 첨부와 비밀번호가 같은 폼에 있는 경우 재현).

* *

기능이 꺼져 있거나 POST 가 아니면 아무것도 하지 않는다. 래퍼는 파라미터를 조회할 때만 * 복호화하므로, 봉투가 없는 요청에는 사실상 비용이 없다.

@@ -50,8 +55,9 @@ public class PasswordDecryptFilter implements Filter { return; } - DecryptingRequestWrapper wrapper = - new DecryptingRequestWrapper(httpRequest, codec, keyStore, properties); + DecryptingRequestWrapper wrapper = httpRequest instanceof MultipartHttpServletRequest + ? new DecryptingMultipartRequestWrapper((MultipartHttpServletRequest) httpRequest, codec, keyStore, properties) + : new DecryptingRequestWrapper(httpRequest, codec, keyStore, properties); try { chain.doFilter(wrapper, response); } finally { diff --git a/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java b/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java index e39303f..6f30cf8 100644 --- a/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java +++ b/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java @@ -2,6 +2,7 @@ package com.eactive.apim.portal.config; import com.eactive.apim.portal.apps.session.filter.SessionValidationFilter; +import com.eactive.apim.portal.common.security.passwordcrypto.DecryptingMultipartRequestWrapper; import com.eactive.apim.portal.common.security.passwordcrypto.PasswordDecryptFilter; import com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter; import org.springframework.beans.factory.annotation.Autowired; @@ -66,25 +67,40 @@ public class PortalConfigSecurity { } /** - * 비밀번호 전송암호화 복호화 필터. Lucy XSS 필터(order = MIN_VALUE + 1)보다 먼저 실행되어야 한다. - * 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거쳐야 특수문자가 든 비밀번호의 해시 비교 결과가 - * 지금과 동일하게 유지된다. 순서를 뒤집으면 기존 계정 로그인이 깨진다. + * 비밀번호 전송암호화 복호화 필터. + * + *

order {@code MIN_VALUE + 2} — {@code PortalConfigWebDispatcherServlet} 의 + * MultipartFilter({@code MIN_VALUE + 1}) 이후, 아래 Lucy XSS 필터 + * ({@code MIN_VALUE + 3}) 이전에 실행되어야 한다.

+ * + *

MultipartFilter 이후여야 하는 이유: 파일 업로드가 동반된 폼(예: 법인가입의 사업자등록증 + * 첨부)은 {@code multipart/form-data} 로 제출되는데, Spring 의 {@code MultipartHttpServletRequest} + * 는 폼 필드 값을 자체 파싱해 {@code getParameter()} 에서 곧바로 반환하고 감싸고 있는 원본 요청으로 + * 위임하지 않는다. 이 필터가 MultipartFilter 보다 먼저 실행되어(즉 더 안쪽에서) 요청을 감싸면, + * 멀티파트 안의 비밀번호 파라미터는 이 필터의 복호화 로직을 거치지 않고 봉투(ENC1) 문자열 그대로 + * 컨트롤러까지 전달된다({@link DecryptingMultipartRequestWrapper} 주석 참고).

+ * + *

Lucy XSS 필터 이전이어야 하는 이유: 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거쳐야 + * 특수문자가 든 비밀번호의 해시 비교 결과가 지금과 동일하게 유지된다. 이 순서를 뒤집으면 기존 + * 계정 로그인이 깨진다.

*/ @Bean public FilterRegistrationBean passwordDecryptFilterRegistration( PasswordDecryptFilter filter) { FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(filter); - registrationBean.setOrder(Integer.MIN_VALUE); + registrationBean.setOrder(Integer.MIN_VALUE + 2); registrationBean.addUrlPatterns("/*"); return registrationBean; } + // order MIN_VALUE + 3 — 위 PasswordDecryptFilter(MIN_VALUE + 2) 다음에 실행되어야 + // 복호화된 평문이 XSS 이스케이프를 거친다(순서를 뒤집으면 기존 계정 로그인이 깨짐). @Bean public FilterRegistrationBean xssFilterRegistrationBean() { FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); XssEscapeServletFilter xssEscapeServletFilter = new XssEscapeServletFilter(); registrationBean.setFilter(xssEscapeServletFilter); - registrationBean.setOrder(Integer.MIN_VALUE + 1); + registrationBean.setOrder(Integer.MIN_VALUE + 3); registrationBean.addUrlPatterns("/*"); return registrationBean; }