내가 필요해서 찾던 중 emoji와 LocalDate 관련된 녀석을 모두 처리하고 싶어서 한 방에 합쳐봤다.
+) json 형태로 요청을 받을 것이기 때문에 Lucy를 사용하지 못했다.
+) 실제 요청보다는 html character가 잘 escape하는지 확인하기 위해 전용 controller와 dto를 만들고 싶었기에 nested class로 구성했다.
🤖 Thanks to Igoc 🤖
HtmlCharacterEscapes in util package
public class HtmlCharacterEscapes extends CharacterEscapes {
private final int[] asciiEscapes;
public HtmlCharacterEscapes() {
// 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() {
// 유니코드의 처음 128자(ASCII 문자)에 대한 이스케이프 처리를 결정하기 위해 호출
return asciiEscapes;
}
@Override
public SerializableString getEscapeSequence(int ch) {
// 특정 문자에 사용할 이스케이프 시퀀스를 결정하기 위해 호출
SerializedString serializedString;
char charAt = (char) ch;
// 이모지(써로게이트 쌍으로 표현)에 대한 처리를 위한 로직
// 없을 경우 이모지 사용시 MalformedJsonException 에러 발생
if (Character.isHighSurrogate(charAt) || Character.isLowSurrogate(charAt)) {
StringBuilder sb = new StringBuilder();
sb.append("\\u"); // \\u 다음은 유니코드로 인식
sb.append(String.format("%04x", ch)); // 16진수를 4자리로 표현, 4자리 아닐 시 0으로 채움
serializedString = new SerializedString(sb.toString());
} else {
serializedString = new SerializedString(StringEscapeUtils.escapeHtml4(Character.toString(charAt)));
}
return serializedString;
}
}
WebMvcConfig in config package
@RequiredArgsConstructor
@Configuration
public class WebMvcConfig {
public static final String timeZone = "Asia/Seoul";
@Bean
public ObjectMapper objectMapper() {
return new Jackson2ObjectMapperBuilder()
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.modules(new JavaTimeModule())
.timeZone(timeZone)
.build();
}
@Bean
public MappingJackson2HttpMessageConverter jsonEscapeConverter() {
// MappingJackson2HttpMessageConverter Default ObjectMapper 설정 및 ObjectMapper Config 설정
final ObjectMapper objectMapper = objectMapper().copy();
objectMapper.getFactory().setCharacterEscapes(new HtmlCharacterEscapes());
return new MappingJackson2HttpMessageConverter(objectMapper);
}
}
HtmlCharacterEscapesTest in test package
3-1. Nested class로 controller와 시간에 대한 직렬화와 역직렬화를 구성했기 때문에 @ContextConfiguration에 value 값으로 해당 Nested class들을 등록해주어야한다.
@WebMvcTest(value = HtmlCharacterEscapesTest.XssRequestController.class,
excludeAutoConfiguration = SecurityAutoConfiguration.class)
@ContextConfiguration(classes= {HtmlCharacterEscapesTest.XssRequestController.class,
HtmlCharacterEscapesTest.LocalDateSerializer.class,
HtmlCharacterEscapesTest.LocalDateDeserializer.class,
HtmlCharacterEscapesTest.LocalDateTimeSerializer.class,
HtmlCharacterEscapesTest.LocalDateTimeDeserializer.class,
WebMvcConfig.class})
3-2. Gson은 LocalDate 관련된 녀석을 알아듣지 못하므로 알아들을 수 있게 따로 등록해주어야한다.
public class HtmlCharacterEscapesTest {
@Autowired
private MockMvc mockMvc;
private Gson gson;
@BeforeEach
void beforeEach() {
GsonBuilder gsonBuilder = new GsonBuilder();
gsonBuilder.registerTypeAdapter(LocalDate.class, new LocalDateSerializer());
gsonBuilder.registerTypeAdapter(LocalDateTime.class, new LocalDateTimeSerializer());
gsonBuilder.registerTypeAdapter(LocalDate.class, new LocalDateDeserializer());
gsonBuilder.registerTypeAdapter(LocalDateTime.class, new LocalDateTimeDeserializer());
gson = gsonBuilder.setPrettyPrinting().create();
}
3-3. script에 대한 테스트
@Test
@DisplayName("[Success] json strains script")
public void successIfJsonStrainsScript() throws Exception {
// Given
final String title = "<script>alert(0);</script>";
final String expectedTitle = "<script>alert(0);</script>";
final String content = "<li>content</li>";
final String expectedContent = "<li>content</li>";
final HtmlCharacterEscapesRequestDto requestDto = HtmlCharacterEscapesRequestDto.builder()
.title(title)
.content(content)
.build();
final HtmlCharacterEscapesRequestDto changedRequestDto = HtmlCharacterEscapesRequestDto.builder()
.title(expectedTitle)
.content(expectedContent)
.build();
// When
final ResultActions resultActions = mockMvc.perform(
MockMvcRequestBuilders.post("/xss")
.content(gson.toJson(requestDto))
.contentType(MediaType.APPLICATION_JSON)
);
final HtmlCharacterEscapesResponseDto response = gson.fromJson(resultActions.andReturn()
.getResponse()
.getContentAsString(StandardCharsets.UTF_8), HtmlCharacterEscapesResponseDto.class);
// Then
assertThat(response.getTitle()).isEqualTo(changedRequestDto.getTitle());
assertThat(response.getContent()).isEqualTo(changedRequestDto.getContent());
}
3-4. emoji에 대한 테스트
@Test
@DisplayName("[Success] json keeps emoji")
public void successIfJsonStrainsScriptAndEmoji() throws Exception {
// Given
final String emoji = "😂😀❤️";
final String expectedEmoji = "😂😀❤️";
final HtmlCharacterEscapesRequestDtoWithEmoji requestDto = HtmlCharacterEscapesRequestDtoWithEmoji.builder()
.emoji(emoji)
.build();
final HtmlCharacterEscapesRequestDtoWithEmoji changedRequestDto = HtmlCharacterEscapesRequestDtoWithEmoji.builder()
.emoji(expectedEmoji)
.build();
// When
final ResultActions resultActions = mockMvc.perform(
MockMvcRequestBuilders.post("/xss/emoji")
.content(gson.toJson(requestDto))
.contentType(MediaType.APPLICATION_JSON)
);
final HtmlCharacterEscapesResponseDtoWithEmoji response = gson.fromJson(resultActions.andReturn()
.getResponse()
.getContentAsString(StandardCharsets.UTF_8), HtmlCharacterEscapesResponseDtoWithEmoji.class);
// Then
assertThat(response.getEmoji()).isEqualTo(changedRequestDto.getEmoji());
System.out.println(response.getEmoji());
}
3-5. LocalDate에 대한 테스트
@Test
@DisplayName("[Success] json strains script and local date")
public void successIfJsonStrainsScriptAndLocalDate() throws Exception {
// Given
final LocalDate date = LocalDate.now();
final HtmlCharacterEscapesRequestDtoWithLocalDate requestDto = HtmlCharacterEscapesRequestDtoWithLocalDate.builder()
.localDate(date)
.build();
final HtmlCharacterEscapesRequestDtoWithLocalDate changedRequestDto = HtmlCharacterEscapesRequestDtoWithLocalDate.builder()
.localDate(date)
.build();
// When
final ResultActions resultActions = mockMvc.perform(
MockMvcRequestBuilders.post("/xss/date")
.content(gson.toJson(requestDto))
.contentType(MediaType.APPLICATION_JSON)
);
final HtmlCharacterEscapesResponseDtoWithLocalDate response = gson.fromJson(resultActions.andReturn()
.getResponse()
.getContentAsString(StandardCharsets.UTF_8), HtmlCharacterEscapesResponseDtoWithLocalDate.class);
// Then
assertThat(response.getLocalDate()).isEqualTo(changedRequestDto.getLocalDate());
}
3-6. LocalDateTime에 대한 테스트
@Test
@DisplayName("[Success] json strains script and local date time")
public void successIfJsonStrainsScriptAndLocalDateTime() throws Exception {
// Given
final LocalDateTime dateTime = LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS);
final HtmlCharacterEscapesRequestDtoWithLocalDateTime requestDto = HtmlCharacterEscapesRequestDtoWithLocalDateTime.builder()
.localDateTime(dateTime)
.build();
final HtmlCharacterEscapesRequestDtoWithLocalDateTime changedRequestDto = HtmlCharacterEscapesRequestDtoWithLocalDateTime.builder()
.localDateTime(dateTime)
.build();
// When
final ResultActions resultActions = mockMvc.perform(
MockMvcRequestBuilders.post("/xss/datetime")
.content(gson.toJson(requestDto))
.contentType(MediaType.APPLICATION_JSON)
);
final HtmlCharacterEscapesResponseDtoWithLocalDateTime response = gson.fromJson(resultActions.andReturn()
.getResponse()
.getContentAsString(StandardCharsets.UTF_8), HtmlCharacterEscapesResponseDtoWithLocalDateTime.class);
// Then
assertThat(response.getLocalDateTime()).isEqualTo(changedRequestDto.getLocalDateTime());
}
3-7. 기존에 등록한 controller 및 직렬화/역직렬화
@RestController
static class XssRequestController {
@PostMapping("/xss")
public HtmlCharacterEscapesResponseDto xss (@RequestBody HtmlCharacterEscapesRequestDto xssRequestDto) {
return HtmlCharacterEscapesResponseDto.builder()
.title(xssRequestDto.getTitle())
.content(xssRequestDto.getContent())
.build();
}
@PostMapping("/xss/emoji")
public HtmlCharacterEscapesResponseDtoWithEmoji xss (@RequestBody HtmlCharacterEscapesRequestDtoWithEmoji xssRequestDto) {
return HtmlCharacterEscapesResponseDtoWithEmoji.builder()
.emoji(xssRequestDto.getEmoji())
.build();
}
@PostMapping("/xss/date")
public HtmlCharacterEscapesResponseDtoWithLocalDate xssForLocalDate (@RequestBody HtmlCharacterEscapesRequestDtoWithLocalDate xssRequestDto) {
return HtmlCharacterEscapesResponseDtoWithLocalDate.builder()
.localDate(xssRequestDto.getLocalDate())
.build();
}
@PostMapping("/xss/datetime")
public HtmlCharacterEscapesResponseDtoWithLocalDateTime xssForLocalDate (@RequestBody HtmlCharacterEscapesRequestDtoWithLocalDateTime xssRequestDto) {
return HtmlCharacterEscapesResponseDtoWithLocalDateTime.builder()
.localDateTime(xssRequestDto.getLocalDateTime())
.build();
}
}
static class LocalDateSerializer implements JsonSerializer <LocalDate> {
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
@Override
public JsonElement serialize(LocalDate localDate, Type srcType, JsonSerializationContext context) {
return new JsonPrimitive(formatter.format(localDate));
}
}
static class LocalDateDeserializer implements JsonDeserializer <LocalDate> {
@Override
public LocalDate deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
throws JsonParseException {
return LocalDate.parse(json.getAsString(),
DateTimeFormatter.ofPattern("yyyy-MM-dd").withLocale(Locale.KOREA));
}
}
static class LocalDateTimeSerializer implements JsonSerializer <LocalDateTime> {
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Override
public JsonElement serialize(LocalDateTime localDateTime, Type srcType, JsonSerializationContext context) {
return new JsonPrimitive(formatter.format(localDateTime));
}
}
static class LocalDateTimeDeserializer implements JsonDeserializer <LocalDateTime> {
@Override
public LocalDateTime deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
throws JsonParseException {
return LocalDateTime.parse(json.getAsString(),
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withLocale(Locale.KOREA));
}
}
3-8. controller에서 사용하기 위한 DTO들
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED, force = true)
@RequiredArgsConstructor
@Builder
static class HtmlCharacterEscapesRequestDto {
private final String title;
private final String content;
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED, force = true)
@RequiredArgsConstructor
@Builder
static class HtmlCharacterEscapesResponseDto {
private final String title;
private final String content;
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED, force = true)
@RequiredArgsConstructor
@Builder
static class HtmlCharacterEscapesRequestDtoWithEmoji {
private final String emoji;
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED, force = true)
@RequiredArgsConstructor
@Builder
static class HtmlCharacterEscapesResponseDtoWithEmoji {
private final String emoji;
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED, force = true)
@RequiredArgsConstructor
@Builder
static class HtmlCharacterEscapesRequestDtoWithLocalDate {
@JsonFormat(pattern = "yyyy-MM-dd", timezone = WebMvcConfig.timeZone)
private final LocalDate localDate;
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED, force = true)
@RequiredArgsConstructor
@Builder
static class HtmlCharacterEscapesResponseDtoWithLocalDate {
@JsonFormat(pattern = "yyyy-MM-dd", timezone = WebMvcConfig.timeZone)
private final LocalDate localDate;
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED, force = true)
@RequiredArgsConstructor
@Builder
static class HtmlCharacterEscapesRequestDtoWithLocalDateTime {
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = WebMvcConfig.timeZone)
private final LocalDateTime localDateTime;
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED, force = true)
@RequiredArgsConstructor
@Builder
static class HtmlCharacterEscapesResponseDtoWithLocalDateTime {
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = WebMvcConfig.timeZone)
private final LocalDateTime localDateTime;
}
}
https://jojoldu.tistory.com/470
https://inseok9068.github.io/springboot/springboot-xss-response/