From 57617de00dbb900cfea302d87b509a1213986a02 Mon Sep 17 00:00:00 2001 From: mikekks Date: Thu, 2 May 2024 14:42:56 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[FEAT]=20XSS=20=EB=B0=A9=EC=A7=80=20?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/util/HtmlCharacterEscapes.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/main/java/synk/meeteam/global/util/HtmlCharacterEscapes.java diff --git a/src/main/java/synk/meeteam/global/util/HtmlCharacterEscapes.java b/src/main/java/synk/meeteam/global/util/HtmlCharacterEscapes.java new file mode 100644 index 00000000..9e0221ec --- /dev/null +++ b/src/main/java/synk/meeteam/global/util/HtmlCharacterEscapes.java @@ -0,0 +1,33 @@ +package synk.meeteam.global.util; + +import com.fasterxml.jackson.core.SerializableString; +import com.fasterxml.jackson.core.io.CharacterEscapes; +import com.fasterxml.jackson.core.io.SerializedString; +import org.apache.commons.lang3.StringEscapeUtils; + +public class HtmlCharacterEscapes extends CharacterEscapes { + + private final int[] asciiEscapes; + + public HtmlCharacterEscapes() { + // 1. XSS 방지 처리할 특수 문자 지정 + asciiEscapes = CharacterEscapes.standardAsciiEscapesForJSON(); + asciiEscapes['<'] = CharacterEscapes.ESCAPE_CUSTOM; + asciiEscapes['>'] = CharacterEscapes.ESCAPE_CUSTOM; + asciiEscapes['\"'] = CharacterEscapes.ESCAPE_CUSTOM; + asciiEscapes['('] = CharacterEscapes.ESCAPE_CUSTOM; + asciiEscapes[')'] = CharacterEscapes.ESCAPE_CUSTOM; + asciiEscapes['#'] = CharacterEscapes.ESCAPE_CUSTOM; + asciiEscapes['\''] = CharacterEscapes.ESCAPE_CUSTOM; + } + + @Override + public int[] getEscapeCodesForAscii() { + return asciiEscapes; + } + + @Override + public SerializableString getEscapeSequence(int ch) { + return new SerializedString(StringEscapeUtils.escapeHtml4(Character.toString((char) ch))); + } +} From 9ec0e23bc34b7720dc278a2b05f766b9da700c29 Mon Sep 17 00:00:00 2001 From: mikekks Date: Thu, 2 May 2024 14:43:24 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[FEAT]=20XSS=20=EB=B0=A9=EC=A7=80=20?= =?UTF-8?q?=EC=BB=A8=EB=B2=84=ED=84=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeteam/global/config/WebMvcConfig.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/main/java/synk/meeteam/global/config/WebMvcConfig.java diff --git a/src/main/java/synk/meeteam/global/config/WebMvcConfig.java b/src/main/java/synk/meeteam/global/config/WebMvcConfig.java new file mode 100644 index 00000000..f73ff6eb --- /dev/null +++ b/src/main/java/synk/meeteam/global/config/WebMvcConfig.java @@ -0,0 +1,24 @@ +package synk.meeteam.global.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import synk.meeteam.global.util.HtmlCharacterEscapes; + +@Slf4j +@RequiredArgsConstructor +@Configuration +public class WebMvcConfig { + + private final ObjectMapper objectMapper; + + @Bean + public MappingJackson2HttpMessageConverter jsonEscapeConverter() { + ObjectMapper copy = objectMapper.copy(); + copy.getFactory().setCharacterEscapes(new HtmlCharacterEscapes()); + return new MappingJackson2HttpMessageConverter(copy); + } +} From 1fc44ba4a82f99483c7ab46692a358f126d4b602 Mon Sep 17 00:00:00 2001 From: mikekks Date: Thu, 2 May 2024 14:43:50 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[TEST]=20XSS=20=EA=B3=B5=EA=B2=A9=EC=97=90?= =?UTF-8?q?=20=EB=8C=80=ED=95=9C=20=EB=B0=A9=EC=96=B4=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeteam/global/xss/XssSpringBootTest.java | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 src/test/java/synk/meeteam/global/xss/XssSpringBootTest.java diff --git a/src/test/java/synk/meeteam/global/xss/XssSpringBootTest.java b/src/test/java/synk/meeteam/global/xss/XssSpringBootTest.java new file mode 100644 index 00000000..1261f215 --- /dev/null +++ b/src/test/java/synk/meeteam/global/xss/XssSpringBootTest.java @@ -0,0 +1,122 @@ +package synk.meeteam.global.xss; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import synk.meeteam.domain.recruitment.recruitment_post.dto.request.CourseTagDto; +import synk.meeteam.domain.recruitment.recruitment_post.dto.request.CreateRecruitmentPostRequestDto; +import synk.meeteam.domain.recruitment.recruitment_post.dto.request.RecruitmentRoleDto; +import synk.meeteam.domain.recruitment.recruitment_post.dto.response.CreateRecruitmentPostResponseDto; +import synk.meeteam.domain.recruitment.recruitment_post.dto.response.GetRecruitmentPostResponseDto; + + +@ExtendWith(SpringExtension.class) +@SpringBootTest(webEnvironment = RANDOM_PORT) +@ActiveProfiles("test") +public class XssSpringBootTest { + private static final String RECRUITMENT_URL = "/recruitment/postings"; + + @Autowired + private TestRestTemplate restTemplate; + + @Value("${jwt.access.header}") + private String accessHeader; + + private String TOKEN = "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJBY2Nlc3NUb2tlbiIsInBsYXRmb3JtSWQiOiI0SUlfbGZaY1Q2NW82MVRfZkh5d2hybzVJanlSTmpSZE1IVi1qNXJWMmtvIiwicGxhdGZvcm1UeXBlIjoiTkFWRVIiLCJpYXQiOjE3MDg1OTkyMTMsImV4cCI6MTgxNjU5OTIxM30.5kAEY2nJ3mNqlnhhFNV0_FVvXTD7JRTzTj6FjpresEA"; + + HttpHeaders headers; + + + + @BeforeEach + void init() { + restTemplate.getRestTemplate().setRequestFactory(new HttpComponentsClientHttpRequestFactory()); + + headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set(accessHeader, TOKEN); + } + + @Test + public void 구인글생성_예외발생_xss공격1() { + HttpEntity request = new HttpEntity(headers); + + String title = "
  • content
  • "; + String expected = "<li>content</li>"; + CreateRecruitmentPostRequestDto requestDto = createRequestDto_title(title); + HttpEntity requestEntity = new HttpEntity<>(requestDto, headers); + + ResponseEntity responseEntity = restTemplate.postForEntity( + RECRUITMENT_URL, + requestEntity, CreateRecruitmentPostResponseDto.class); + + assertEquals(HttpStatus.CREATED, responseEntity.getStatusCode()); + CreateRecruitmentPostResponseDto body = responseEntity.getBody(); + assertNotNull(body.recruitmentPostId());; + + ResponseEntity verifyResponseEntity = restTemplate.exchange( + RECRUITMENT_URL + "/{id}", HttpMethod.GET, request, + GetRecruitmentPostResponseDto.class, body.recruitmentPostId()); + + GetRecruitmentPostResponseDto verifyBody = verifyResponseEntity.getBody(); + assertEquals(verifyBody.title(), expected); + } + + @Test + public void 구인글생성_예외발생_xss공격_LocalDate치환() { + HttpEntity request = new HttpEntity(headers); + + String title = "
  • content
  • "; + String expected = "<li>content</li>"; + CreateRecruitmentPostRequestDto requestDto = createRequestDto_title(title); + HttpEntity requestEntity = new HttpEntity<>(requestDto, headers); + + ResponseEntity responseEntity = restTemplate.postForEntity( + RECRUITMENT_URL, + requestEntity, CreateRecruitmentPostResponseDto.class); + + assertEquals(HttpStatus.CREATED, responseEntity.getStatusCode()); + CreateRecruitmentPostResponseDto body = responseEntity.getBody(); + assertNotNull(body.recruitmentPostId());; + + ResponseEntity verifyResponseEntity = restTemplate.exchange( + RECRUITMENT_URL + "/{id}", HttpMethod.GET, request, + GetRecruitmentPostResponseDto.class, body.recruitmentPostId()); + + GetRecruitmentPostResponseDto verifyBody = verifyResponseEntity.getBody(); + assertEquals(verifyBody.title(), expected); + assertEquals(verifyBody.deadline(), LocalDate.of(2024, 2, 22).toString()); + } + + private CreateRecruitmentPostRequestDto createRequestDto_title(String title) { + CourseTagDto courseTagDto = new CourseTagDto(true, "응소실", "김용혁"); + List recruitmentRoleDtos = new ArrayList<>(); + recruitmentRoleDtos.add(new RecruitmentRoleDto(1L, 1, List.of(1L, 2L, 3L))); + recruitmentRoleDtos.add(new RecruitmentRoleDto(2L, 3, List.of(1L, 2L, 3L))); + recruitmentRoleDtos.add(new RecruitmentRoleDto(3L, 1, List.of(4L, 5L, 6L))); + + return new CreateRecruitmentPostRequestDto("교내", "프로젝트", LocalDate.of(2024, 2, 22), LocalDate.of(2024, 3, 15), + LocalDate.of(2024, 5, 15), 1L, "온라인", + courseTagDto, List.of("웹개발", "AI", "대학생", "구인"), recruitmentRoleDtos, title, "사람구합니당!!"); + } +} From 11d72268f7ed5a08147e06249795d27383079618 Mon Sep 17 00:00:00 2001 From: mikekks Date: Sat, 4 May 2024 01:00:54 +0900 Subject: [PATCH 4/5] =?UTF-8?q?[CHORE]=20=EA=B5=AC=EC=9D=B8=EA=B8=80=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EB=82=B4=EC=9A=A9=EC=97=90=EB=8A=94=20XSS?= =?UTF-8?q?=20=ED=95=84=ED=84=B0=20=EC=A0=81=EC=9A=A9=20=EC=A0=9C=EC=99=B8?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GetRecruitmentPostResponseDto.java | 3 +++ .../global/util/UnescapedFieldSerializer.java | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 src/main/java/synk/meeteam/global/util/UnescapedFieldSerializer.java diff --git a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/response/GetRecruitmentPostResponseDto.java b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/response/GetRecruitmentPostResponseDto.java index 6e8f77ec..5497ba1d 100644 --- a/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/response/GetRecruitmentPostResponseDto.java +++ b/src/main/java/synk/meeteam/domain/recruitment/recruitment_post/dto/response/GetRecruitmentPostResponseDto.java @@ -1,5 +1,6 @@ package synk.meeteam.domain.recruitment.recruitment_post.dto.response; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; import lombok.Builder; @@ -10,6 +11,7 @@ import synk.meeteam.domain.recruitment.recruitment_role.entity.RecruitmentRole; import synk.meeteam.domain.user.user.entity.User; import synk.meeteam.global.util.Encryption; +import synk.meeteam.global.util.UnescapedFieldSerializer; @Builder @Schema(name = "GetRecruitmentPostResponseDto", description = "구인글 조회 Dto") @@ -59,6 +61,7 @@ public record GetRecruitmentPostResponseDto( @Schema(description = "구인 역할", example = "") List recruitmentRoles, @Schema(description = "상세 내용", example = "안녕하세요. 저는 팀원을...") + @JsonSerialize(using = UnescapedFieldSerializer.class) String content, @Schema(description = "댓글, 대댓글", example = "") List comments diff --git a/src/main/java/synk/meeteam/global/util/UnescapedFieldSerializer.java b/src/main/java/synk/meeteam/global/util/UnescapedFieldSerializer.java new file mode 100644 index 00000000..2a77b4be --- /dev/null +++ b/src/main/java/synk/meeteam/global/util/UnescapedFieldSerializer.java @@ -0,0 +1,18 @@ +package synk.meeteam.global.util; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import java.io.IOException; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class UnescapedFieldSerializer extends JsonSerializer { + + @Override + public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + // HTML escaping을 하지 않고 그대로 출력 + gen.writeRawValue("\"" + value + "\""); + } +} + From 66f42d9a04ef9b04cc039bf5ae4f37673c1eab22 Mon Sep 17 00:00:00 2001 From: mikekks Date: Sat, 4 May 2024 01:11:24 +0900 Subject: [PATCH 5/5] =?UTF-8?q?[CHORE]=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=EB=AA=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/java/synk/meeteam/global/xss/XssSpringBootTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/synk/meeteam/global/xss/XssSpringBootTest.java b/src/test/java/synk/meeteam/global/xss/XssSpringBootTest.java index 1261f215..bbbe5429 100644 --- a/src/test/java/synk/meeteam/global/xss/XssSpringBootTest.java +++ b/src/test/java/synk/meeteam/global/xss/XssSpringBootTest.java @@ -58,7 +58,7 @@ void init() { } @Test - public void 구인글생성_예외발생_xss공격1() { + public void escape문자로치환_구인글생성_성공_xss공격() { HttpEntity request = new HttpEntity(headers); String title = "
  • content
  • "; @@ -83,7 +83,7 @@ void init() { } @Test - public void 구인글생성_예외발생_xss공격_LocalDate치환() { + public void escape문자로치환및LocalDate치환_구인글생성_성공_xss공격() { HttpEntity request = new HttpEntity(headers); String title = "
  • content
  • ";