XSS preventer (Lucy야 미안하다. 넌 선택되지 못했어.)

leverest96·2022년 12월 1일
0

Spring / Java

목록 보기
7/20
post-thumbnail

내가 필요해서 찾던 중 emoji와 LocalDate 관련된 녀석을 모두 처리하고 싶어서 한 방에 합쳐봤다.
+) json 형태로 요청을 받을 것이기 때문에 Lucy를 사용하지 못했다.
+) 실제 요청보다는 html character가 잘 escape하는지 확인하기 위해 전용 controller와 dto를 만들고 싶었기에 nested class로 구성했다.
🤖 Thanks to Igoc 🤖

  1. 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;
        }
    }

  1. 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);
        }
    }

  1. 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 = "&lt;script&gt;alert(0);&lt;/script&gt;";
    
            final String content = "<li>content</li>";
            final String expectedContent = "&lt;li&gt;content&lt;/li&gt;";
    
            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/

profile
응애 난 애기 개발자

0개의 댓글