diff --git a/README.md b/README.md index 13dd8dca..23f514a6 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,7 @@ mvn spring-boot:run - 관리(`/admin/**`, `/members/**`) 및 회원(`/mypage/**`)·게시판 관리(`/inform/**`) 경로는 GET 포함 전체 메서드에 대해 JWT 토큰 인증이 필요합니다. - 그 외 POST/PUT/DELETE 변경계 요청도 기본적으로 JWT 토큰 인증이 필요합니다. - 상세 규칙은 `SecurityConfig.java` 의 `filterChain()` 정의를 참고합니다. + - `HTMLTagFilter`가 래핑한 요청의 `getParameter()`와 `getParameterValues()`는 모두 `<`, `>`, `&`, `"`, `'`를 각각 `<`, `>`, `&`, `"`, `'`로 변환합니다. 반복 파라미터의 순서를 유지하며, 여러 번 읽거나 반환 배열을 수정해도 원본 요청 값은 바뀌지 않습니다. 이미 엔티티 형태인 입력도 동일하게 처리됩니다(예: `<` → `&lt;`). - 인증 미통과는 401, 인증은 되었으나 권한이 부족한 경우(예: `ROLE_USER` 가 `/admin/**` 접근)에는 403 응답이 반환됩니다. #### JWT 토큰 인증 방법 diff --git a/src/main/java/egovframework/com/cmm/filter/HTMLTagFilterRequestWrapper.java b/src/main/java/egovframework/com/cmm/filter/HTMLTagFilterRequestWrapper.java index 08ee02bd..d98bc000 100644 --- a/src/main/java/egovframework/com/cmm/filter/HTMLTagFilterRequestWrapper.java +++ b/src/main/java/egovframework/com/cmm/filter/HTMLTagFilterRequestWrapper.java @@ -24,57 +24,30 @@ public HTMLTagFilterRequestWrapper(HttpServletRequest request) { super(request); } + @Override public String[] getParameterValues(String parameter) { - String[] values = super.getParameterValues(parameter); - - if(values==null){ - return null; - } - - for (int i = 0; i < values.length; i++) { - if (values[i] != null) { - StringBuffer strBuff = new StringBuffer(); - for (int j = 0; j < values[i].length(); j++) { - char c = values[i].charAt(j); - switch (c) { - case '<': - strBuff.append("<"); - break; - case '>': - strBuff.append(">"); - break; - //case '&': - //strBuff.append("&"); - //break; - case '"': - strBuff.append("""); - break; - case '\'': - strBuff.append("'"); - break; - default: - strBuff.append(c); - break; - } - } - values[i] = strBuff.toString(); - } else { - values[i] = null; - } + if (values == null) { + return null; } - return values; + String[] escapedValues = values.clone(); + for (int i = 0; i < escapedValues.length; i++) { + escapedValues[i] = escapeHtml(escapedValues[i]); + } + return escapedValues; } + @Override public String getParameter(String parameter) { - - String value = super.getParameter(parameter); - - if(value==null){ + return escapeHtml(super.getParameter(parameter)); + } + + private String escapeHtml(String value) { + if (value == null) { return null; } - + StringBuffer strBuff = new StringBuffer(); for (int i = 0; i < value.length(); i++) { @@ -100,10 +73,7 @@ public String getParameter(String parameter) { break; } } - - value = strBuff.toString(); - - return value; + return strBuff.toString(); } -} \ No newline at end of file +} diff --git a/src/test/java/egovframework/com/cmm/filter/HTMLTagFilterRequestWrapperTest.java b/src/test/java/egovframework/com/cmm/filter/HTMLTagFilterRequestWrapperTest.java new file mode 100644 index 00000000..06b188ab --- /dev/null +++ b/src/test/java/egovframework/com/cmm/filter/HTMLTagFilterRequestWrapperTest.java @@ -0,0 +1,144 @@ +package egovframework.com.cmm.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.EnumSet; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +import org.springframework.boot.web.server.WebServer; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import jakarta.servlet.DispatcherType; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +class HTMLTagFilterRequestWrapperTest { + + @Test + void scalarAndArrayEscapeAllFiveCharacters() { + for (String[] example : new String[][] { + {"<", "<"}, {">", ">"}, {"&", "&"}, + {"\"", """}, {"'", "'"}, + {"'한글'", "<a title="A&B">'한글'</a>"}, + {"<&", "&lt;&#38;"}, {"", ""}, {"plain 한글", "plain 한글"}}) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addParameter("value", example[0]); + HTMLTagFilterRequestWrapper wrapper = new HTMLTagFilterRequestWrapper(request); + assertThat(wrapper.getParameter("value")).isEqualTo(example[1]); + assertThat(wrapper.getParameterValues("value")).containsExactly(example[1]); + } + } + + @Test + void repeatedReadsPreserveOrderAndUnderlyingRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addParameter("value", "A&B", "", ""); + HTMLTagFilterRequestWrapper wrapper = new HTMLTagFilterRequestWrapper(request); + assertThat(wrapper.getParameterValues("value")).containsExactly("A&B", "<second>", ""); + assertThat(wrapper.getParameter("value")).isEqualTo("A&B"); + assertThat(wrapper.getParameterValues("value")).containsExactly("A&B", "<second>", ""); + assertThat(request.getParameterValues("value")).containsExactly("A&B", "", ""); + } + + @Test + void callerCannotChangeSubsequentReadsThroughReturnedArray() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addParameter("value", "A&B"); + HTMLTagFilterRequestWrapper wrapper = new HTMLTagFilterRequestWrapper(request); + wrapper.getParameterValues("value")[0] = "changed"; + assertThat(wrapper.getParameter("value")).isEqualTo("A&B"); + assertThat(wrapper.getParameterValues("value")).containsExactly("A&B"); + } + + @Test + void missingEmptyAndNullValuesRetainTheirShape() { + MockHttpServletRequest request = new MockHttpServletRequest(); + HTMLTagFilterRequestWrapper wrapper = new HTMLTagFilterRequestWrapper(request); + assertThat(wrapper.getParameter("missing")).isNull(); + assertThat(wrapper.getParameterValues("missing")).isNull(); + request.setParameter("empty", new String[0]); + assertThat(wrapper.getParameterValues("empty")).isEmpty(); + request.setParameter("nullable", new String[] {null, "", "&"}); + assertThat(wrapper.getParameter("nullable")).isNull(); + assertThat(wrapper.getParameterValues("nullable")).containsExactly(null, "", "&"); + } + + @Test + void parameterMapStillDelegatesToTheUnderlyingRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addParameter("value", "A&B", ""); + HTMLTagFilterRequestWrapper wrapper = new HTMLTagFilterRequestWrapper(request); + wrapper.getParameterValues("value"); + assertThat(wrapper.getParameterMap().get("value")) + .isSameAs(request.getParameterMap().get("value")) + .containsExactly("A&B", ""); + } + + @Test + void httpFormReadsRemainStableThroughEmbeddedTomcat(@TempDir Path baseDirectory) throws Exception { + TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory(0); + factory.setAddress(java.net.InetAddress.getLoopbackAddress()); + factory.setBaseDirectory(baseDirectory.toFile()); + WebServer server = factory.getWebServer(context -> { + context.addFilter("htmlTagFilter", new HTMLTagFilter()) + .addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/*"); + context.addServlet("form", new HttpServlet() { + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException { + request.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType("text/plain;charset=UTF-8"); + String first = request.getParameter("value"); + String[] values = request.getParameterValues("value"); + values[0] = "caller mutation"; + response.getWriter().print(first + "\n" + + String.join("|", request.getParameterValues("value")) + "\n" + + request.getParameter("value") + "\n" + + request.getParameter("missing")); + } + }).addMapping("/form"); + }); + try { + server.start(); + String raw = "<>&\"' 한글😀 <"; + String escaped = "<>&"' 한글😀 &lt;"; + String form = "value=" + URLEncoder.encode(raw, StandardCharsets.UTF_8) + + "&value=" + URLEncoder.encode("A&B", StandardCharsets.UTF_8) + "&value="; + HttpRequest request = HttpRequest.newBuilder( + new URI("http", null, java.net.InetAddress.getLoopbackAddress().getHostAddress(), + server.getPort(), "/form", null, null)) + .timeout(java.time.Duration.ofSeconds(10)) + .header("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(); + HttpResponse response = HttpClient.newHttpClient().send(request, + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.body()).isEqualTo(escaped + "\n" + escaped + "|A&B|\n" + escaped + "\nnull"); + } finally { + server.stop(); + } + } + + @Test + void filterDeliversEscapedValuesToTheServletChain() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/form"); + request.addParameter("value", "A&B", ""); + new HTMLTagFilter().doFilter(request, new MockHttpServletResponse(), (filtered, response) -> { + HttpServletRequest downstream = (HttpServletRequest) filtered; + assertThat(downstream.getParameter("value")).isEqualTo("A&B"); + assertThat(downstream.getParameterValues("value")).containsExactly("A&B", "<second>"); + assertThat(downstream.getParameter("value")).isEqualTo("A&B"); + }); + } +}