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()`는 모두 `<`, `>`, `&`, `"`, `'`를 각각 `<`, `>`, `&`, `"`, `'`로 변환합니다. 반복 파라미터의 순서를 유지하며, 여러 번 읽거나 반환 배열을 수정해도 원본 요청 값은 바뀌지 않습니다. 이미 엔티티 형태인 입력도 동일하게 처리됩니다(예: `<` → `<`).
- 인증 미통과는 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>"},
+ {"<&", "<&"}, {"", ""}, {"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 = "<>&"' 한글😀 <";
+ 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");
+ });
+ }
+}