안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

오늘은 향로님의 SpringBoot Json Api XSS Filter 적용하기 글을 읽고, XSS Filter를

직접 적용하며 배운 개념들과 과정을 정리하는 시간을 가져보았습니다.

 

먼저, 향로님 원본 글 주소입니다. (https://jojoldu.tistory.com/470

XSS의 개념, 왜 스프링 부트에서 XSS가 위험한 것일까? , XSS을 스프링 부트에서 방지하는 방법 순으로 정리하도록 하겠습니다.

 

1. XSS이란?

XSS(Cross-Site Scripting) 이란 SQL Injection과 함께 웹 상에서 가장 기초적인 취약점 공격 방법 중 하나로, 권한이 없는 사용자가 악의적인 용도로 웹 사이트에 스크립트를 삽입하는 공격을 의미합니다. 다른 웹사이트와 정보를 교환하는 식으로 작동하므로 사이트 간 스크립팅이라고 부릅니다.

XSS는 자바스크립트를 사용하여 공격하는 경우가 가장 많고, 사용자의 세션을 공격자의 서버로 전송하거나, 악성코드가 있는 페이지로 리다이렉트 시키는 방법으로 공격이 주로 행해집니다.

 

https://www.easymedia.net/Culture/EasyStory/index.asp?no=170&mode=view&IDX=1165&p=1

 

2. 왜 스프링 부트에서 XSS가 위험한 것일까? 

 

처음에, XSS Filter 글을 읽고 의아한 점이 생겼습니다. HTML 태그와 같은 마크업 언어는 자바와 다른 형식이므로 서버에서 단순히 문자열로 인식될 것이라고 생각을 했습니다. 하지만, 클라이언트와 확장해서 생각해보니 데이터를 문자열로 판단한다는 점이 XSS의 위험한 점이었다는 것을 알게 되었습니다. (제 개인적인 사견이므로, 해당 부분은 사실과 다를 수 있습니다.)

 

이를 코드와 예시로 파악해 보겠습니다.

만약, A라는 사람이 학교 데이터를 Post 요청해서 데이터베이스에 저장되었다고 가정한 후, B라는 사람이 학교 데이터를 Get 요청하여 클라이언트 화면에 학교 리스트가 보이는 상황을 가정해 보겠습니다.

 

@PostMapping("/insert")
public ResponseEntity insertSchool(@Valid @RequestBody SchoolSaveDto saveDto,
                                   BindingResult bindingResult) {

    if (bindingResult.hasErrors()) {
        return new ResponseEntity(HttpStatus.BAD_REQUEST);
    }

    Long schoolId = schoolService.save(saveDto);
    return new ResponseEntity(new ResponseDto<>(schoolId), HttpStatus.CREATED);
}

@GetMapping("/{schoolId}")
public ResponseEntity getSchoolOne(@PathVariable("schoolId") Long schoolId) {
    School school = schoolService.findOne(schoolId);
    return new ResponseEntity(school, HttpStatus.OK);
}

@Data
@NoArgsConstructor
static class ResponseDto<T> {
    T data;

    public ResponseDto(T data) {
        this.data = data;
    }
}

A라는 클라이언트가 POST로 정상적인 요청을 수행한다면, 결과는 다음과 같이 전달됩니다.

POST /api/v1/schools/insert HTTP/1.1

B라는 클라이언트가 GET으로 정상 요청을 수행한다면, POST로 저장된 값을 받아올 수 있습니다.

GET /api/v1/schools?schoolId=8

하지만, 만약 Json 형태의 Post 요청에 XSS 공격 형태의 값을 넣게 되면 어떻게 될까요? 바인딩 에러가 생기지 않는 String 타입의 필드에 <a href='www.naver.com'> 형태의 값을 넣고 Post 요청을 보내면 데이터베이스에 저장이 됩니다.

 

이후, GET /api/v1/schools?schoolId=9 HTTP/1.1 요청을 수행하면  schoolAddress에 해당 a태그가 전달되는 것을 볼 수 있습니다. 만약 서버에서 받은 데이터를 클라이언트에게 제공한다면 해당 태그로 인해 클릭 시 외부 사이트로 접근할 수 있는 상황이 생길 수 있습니다. 따라서 XSS 공격의 취약점이 발생할 수 있습니다.

 

3. JSON API에서 XSS 공격 방지 적용

 

향로님의 블로그를 바탕으로, 주어진 환경에 맞춰 진행하였습니다.

먼저 제 버전은 , SpringBoot 2.7.6 버전, 자바 11, Gradle입니다.

plugins {
   id 'java'
   id 'org.springframework.boot' version '2.7.6'
   id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}

group = 'messenger'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

XSS 방지 필터는 naver 개발자 분들이 개발하신 라이브러리와 StringEsxapeUtils 의존성 주입을 받아야 합니다.

//xss
implementation 'com.navercorp.lucy:lucy-xss-servlet:2.0.0'
implementation 'com.navercorp.lucy:lucy-xss:1.6.3'

//StringEscapeUtils
implementation 'org.apache.commons:commons-text:1.8'
package messenger.messenger.business.school.presentation.filter;

import com.fasterxml.jackson.core.SerializableString;
import com.fasterxml.jackson.core.io.CharacterEscapes;
import com.fasterxml.jackson.core.io.SerializedString;
import org.apache.commons.text.StringEscapeUtils;


public class HtmlCharacterEscape extends CharacterEscapes {

    private final int[] asciiEscapes;

    public HtmlCharacterEscape() {
        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)));
    }
}

해당 클래스는 특수문자 입력을 받게 되면, 다른 글자로 치환해주는 역할을 수행합니다. 이후,  objectMapper를 복사한 Converter용 objectMapper에 HtmlCharacterExscape를 의존성 주입받아 사용합니다.

 

 

4. 테스트코드 작성하기

TestRestTemplate를 사용하기 위해서는, @Autowired를 활용한 의존성 자동 주입을 받아야 합니다. 하지만, 자동으로 의존성 주입을 받을 수 없다는 에러가 발생했습니다. 

 

@SpringBootTest의 webEnvironment 속성은 테스트의 웹 환경을 설정하는 속성이며 기본값은 SpringBootTest.WebEnvironment.MOCK입니다.
WebEnvironment.MOCK은 실제 서블릿 컨테이너를 띄우지 않고 서블릿 컨테이너를 mocking 한 것이 실행됩니다.
스프링 부트의 내장 서버를 랜덤 포트로 띄우려면 webEnvironment를 SpringBootTest.WebEnvironment.RANDOM_PORT로 설정하면 됩니다. 이 설정은 실제로 테스트를 위한 서블릿 컨테이너를 띄웁니다.

 

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Transactional
@ExtendWith(MockitoExtension.class)
class SchoolControllerTest {

//중략

@Autowired TestRestTemplate restTemplate;
@Test
    public void 학교_저장() throws Exception {

        //given
        SchoolSaveDto saveDto = new SchoolSaveDto("서울c 학교", "<a ref='www.naver.com'> </a>", "1233");

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.set("Authorization", accessToken);

        HttpEntity<SchoolSaveDto> entity = new HttpEntity<>(saveDto, headers);

        //when
        ResponseEntity<Long> response = restTemplate
                .exchange(
                "/api/v1/schools/insert",
                HttpMethod.POST,
                entity,
                Long.class);

        School school = schoolService.findOne(response.getBody());

        //then
        Assertions.assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        Assertions.assertThat(school.getSchoolAddress()).isNotEqualTo("<a ref='www.naver.com'> </a>");
        Assertions.assertThat(school.getSchoolAddress()).isEqualTo("&lt;a ref='www.naver.com'&gt; &lt;/a&gt;");

    }

restTemplates를 사용하기 위해서는 header 설정을 진행해야 합니다.

 

GIVEN
저는 로그인한 상태에서만 요청이 가능하기 때문에 헤더에 액세스 토큰을 추가하였고,
Application/json으로 요청을 받아 처리하였습니다.
컨트롤러가 Dto 타입의 요청을 받기 때문에, HttpEntity<SchoolSaveDto> entity = new HttpEntity<>(saveDto, header);를 설정하였습니다.
WHEN
restTemplate.exchange()는 url, method 방법, 요청과 헤더를 담은 HttpEntity, 반환 타입을 정합니다.
insert이기 때문에 post 요청을 진행하였고, 최종적으로 결과물은 학교 번호를 받으므로 Long.class로 설정하였습니다.
THEN
클라이언트의 POST 요청 성공에 따른, status 상태 코드를 CREATED (201)로 설정하였고, 
HtmlCharacterEscape 클래스를 주입받은 objectMapper가 제대로 작동했다면, 아래 테스트가 성공되어야 합니다.!

 

결과는. 성공한 것을 확인할 수 있습니다.!

 

오늘은 SpringBoot XSS 공격 방지 Filter를 처리하는 방법을 정리하였습니다.

다음에도 더 발전한 기술 블로그를 작성할 수 있도록 노력하겠습니다.

 

읽어주셔서 감사합니다~!

+ Recent posts