SpringBoot 2.0.x에 lucy-xss-servlet-filter 적용하기

XSS(Cross Site Scripting)을 막기위해 네이버에서 lucy-xss-servlet-filter를 개발했다. (그 전에 lucy-xss-filter가 있었다.)

lucy-xss-servlet-filter는 Servlet filter 기반의 라이브러리로 XSS를 쉽고 효과적으로 방어할 수 있게 해준다.
공식 github 에는 다음과 같이 설명하고 있다.

Lucy-Xss-Servlet-Filter는 웹어플리케이션으로 들어오는 모든 요청 파라메터에 대해 기본적으로 XSS 방어 필터링을 수행하며 아래와 같은 필터링을 제외할 수 있는 효과적인 설정을 제공합니다.

이번 포스팅은 프로젝트에 lucy-xss-servlet-filter를 적용하면서 겪은 삽질을 머리 속에만 캐싱하고 있기엔 아까워서이다.

그럼 실제로 SpringBoot project에 lucy-xss-servlet-filter를 적용해보자.
(이 예제는 github에 올라와 있다.)

간단하게 XSS 공격이 가능성이 있는 클라이언트 요청을 DB에 저장하는 예제를 만들어보았다.

dependency는 아래와 같이 설정했고 빠르게 테스트 개발하기 위해서 h2에 devtools를 사용했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
</dependency>

<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>

<!-- filter 역할은 lucy-xss-servlet이 해주기 때문에 필수는 아님 -->
<!-- <dependency>
<groupId>com.navercorp.lucy</groupId>
<artifactId>lucy-xss</artifactId>
<version>1.6.3</version>
</dependency> -->

<dependency>
<groupId>com.navercorp.lucy</groupId>
<artifactId>lucy-xss-servlet</artifactId>
<version>2.0.0</version>
</dependency>

다음에 Filter 설정을 위한 rule xml 파일을 classpath:resources에 저장한다.

lucy-xss-servlet-filter-rule.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<?xml version="1.0" encoding="UTF-8"?>

<config xmlns="http://www.navercorp.com/lucy-xss-servlet">
<defenders>
<!-- XssPreventer 등록 -->
<defender>
<name>xssPreventerDefender</name>
<class>com.navercorp.lucy.security.xss.servletfilter.defender.XssPreventerDefender</class>
</defender>

<!-- XssSaxFilter 등록 -->
<defender>
<name>xssSaxFilterDefender</name>
<class>com.navercorp.lucy.security.xss.servletfilter.defender.XssSaxFilterDefender</class>
<init-param>
<param-value>lucy-xss-superset-sax.xml</param-value> <!-- lucy-xss-filter의 sax용 설정파일 -->
<param-value>false</param-value> <!-- 필터링된 코멘트를 남길지 여부, 성능 효율상 false 추천 -->
</init-param>
</defender>

<!-- XssFilter 등록 -->
<defender>
<name>xssFilterDefender</name>
<class>com.navercorp.lucy.security.xss.servletfilter.defender.XssFilterDefender</class>
<init-param>
<param-value>lucy-xss.xml</param-value> <!-- lucy-xss-filter의 dom용 설정파일 -->
<param-value>false</param-value> <!-- 필터링된 코멘트를 남길지 여부, 성능 효율상 false 추천 -->
</init-param>
</defender>
</defenders>

<!-- default defender 선언, 필터링 시 지정한 defender가 없으면 여기 정의된 default defender를 사용해 필터링 한다. -->
<default>
<defender>xssPreventerDefender</defender>
</default>

<!-- global 필터링 룰 선언 -->
<global>
<!-- 모든 url에서 들어오는 globalParameter 파라메터는 필터링 되지 않으며
또한 globalPrefixParameter1로 시작하는 파라메터도 필터링 되지 않는다.
globalPrefixParameter2는 필터링 되며 globalPrefixParameter3은 필터링 되지 않지만
더 정확한 표현이 가능하므로 globalPrefixParameter2, globalPrefixParameter3과 같은 불분명한 표현은 사용하지 않는 것이 좋다. -->
<params>
<param name="globalParameter" useDefender="false" />
<param name="globalPrefixParameter1" usePrefix="true" useDefender="false" />
<param name="globalPrefixParameter2" usePrefix="true" />
<param name="globalPrefixParameter3" usePrefix="false" useDefender="false" />
</params>
</global>

<!-- url 별 필터링 룰 선언 -->
<url-rule-set>

<!-- url disable이 true이면 지정한 url 내의 모든 파라메터는 필터링 되지 않는다. -->
<!-- <url-rule>
<url disable="true">/login/login/loginAjax</url>
</url-rule> -->

</url-rule-set>
</config>

lucy-xss-superset-sax

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<?xml version="1.0" encoding="UTF-8"?>

<config xmlns="http://www.nhncorp.com/lucy-xss"
extends="lucy-xss-default-sax.xml">

<elementRule>
<element name="body" disable="true" /> <!-- <BODY ONLOAD=alert("XSS")>, <BODY BACKGROUND="javascript:alert('XSS')"> -->
<element name="embed" disable="true" />
<element name="iframe" disable="true" /> <!-- <IFRAME SRC=”http://hacker-site.com/xss.html”> -->
<element name="meta" disable="true" />
<element name="object" disable="true" />
<element name="script" disable="true" /> <!-- <SCRIPT> alert(“XSS”); </SCRIPT> -->
<element name="style" disable="true" />
<element name="link" disable="true" />
<element name="base" disable="true" />
</elementRule>

<attributeRule>
<attribute name="data" base64Decoding="true">
<notAllowedPattern><![CDATA[(?i:s\\*c\\*r\\*i\\*p\\*t\\*:)]]></notAllowedPattern>
<notAllowedPattern><![CDATA[(?i:d\\*a\\*t\\*a\\*:)]]></notAllowedPattern>
<notAllowedPattern><![CDATA[&[#\\%x]+[\da-fA-F][\da-fA-F]+]]></notAllowedPattern>
</attribute>
<attribute name="src" base64Decoding="true">
<notAllowedPattern><![CDATA[(?i:s\\*c\\*r\\*i\\*p\\*t\\*:)]]></notAllowedPattern>
<notAllowedPattern><![CDATA[(?i:d\\*a\\*t\\*a\\*:)]]></notAllowedPattern>
<notAllowedPattern><![CDATA[&[#\\%x]+[\da-fA-F][\da-fA-F]+]]></notAllowedPattern>
</attribute>
<attribute name="style">
<notAllowedPattern><![CDATA[(?i:j\\*a\\*v\\*a\\*s\\*c\\*r\\*i\\*p\\*t\\*:)]]></notAllowedPattern>
<notAllowedPattern><![CDATA[(?i:e\\*x\\*p\\*r\\*e\\*s\\*s\\*i\\*o\\*n)]]></notAllowedPattern>
<notAllowedPattern><![CDATA[&[#\\%x]+[\da-fA-F][\da-fA-F]+]]></notAllowedPattern>
</attribute>
<attribute name="href">
<notAllowedPattern><![CDATA[(?i:j\\*a\\*v\\*a\\*s\\*c\\*r\\*i\\*p\\*t\\*:)]]></notAllowedPattern>
</attribute>
</attributeRule>

</config>

이제 마지막으로 ServletFilterBean을 등록해주면 Lucy 적용은 끝난다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class WebConfig implements WebMvcConfigurer {

@Bean
public FilterRegistrationBean<XssEscapeServletFilter> filterRegistrationBean() {
FilterRegistrationBean<XssEscapeServletFilter> filterRegistration = new FilterRegistrationBean<>();
filterRegistration.setFilter(new XssEscapeServletFilter());
filterRegistration.setOrder(1);
filterRegistration.addUrlPatterns("/*");

return filterRegistration;
}
}

XSS Filter 설정은 끝났고 그러면 클라이언트에서 요청을 받기 위한 MVC 로직을 만들어보자.

MessageController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import java.util.List;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* 클라이언트로부터 Message를 받아 처리하는 Controller
*/
@RestController
public class MessageController {

private static final Logger LOGGER = LoggerFactory.getLogger(MessageController.class);

private MessageRepository messageRepository;

public MessageController(MessageRepository messageRepository) {
this.messageRepository = messageRepository;
}

/**
* Message를 받아 DB에 등록
* @param message
* @return
*/
@PostMapping("/message")
public MessageEntity postMessage(MessageDto message) {
LOGGER.debug("message post request : {}", message);

return messageRepository.save(message.toEntity());
}

/**
* DB에 저장된 Message 리스트를 리턴
* @return
*/
@GetMapping("/message")
public List<MessageEntity> getMessageList() {
return messageRepository.findAll();
}
}

MessageEntity.java : 디자인 패턴 연습도 할 겸 Builder를 사용해봤다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

/**
* 클라이언트로 부터의 Message를 담는 Entity class
* @see MessageDto
*/
@Entity
public class MessageEntity {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String name;
private String message;

public MessageEntity(Builder builder) {
this.id = builder.id;
this.name = builder.name;
this.message = builder.message;
}

public static class Builder {
private Long id;
private String name;
private String message;

public Builder id(Long id) {
this.id = id;
return this;
}

public Builder name(String name) {
this.name = name;
return this;
}

public Builder message(String message) {
this.message = message;
return this;
}

public MessageEntity build() {
return new MessageEntity(this);
}
}

public Long getId() {
return id;
}
public String getName() {
return name;
}
public String getMessage() {
return message;
}

@Override
public String toString() {
return "Message [id=" + id + ", name=" + name + ", message=" + message + "]";
}
}

MessageDto.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package com.example.demo.sample;

/**
* Entity의 역할을 줄이기 위한
* 값만을 갖는 Message DTO class
* @see MessageEntity
*/
public class MessageDto {
private Long id;
private String name;
private String message;

public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}

public MessageEntity toEntity() {
return new MessageEntity
.Builder()
.id(id)
.name(name)
.message(message)
.build();
}

@Override
public String toString() {
return "MessageDto [id=" + id + ", name=" + name + ", message=" + message + "]";
}

}

MessageRepository.java

1
2
3
4
5
6
/**
* Message Repository class
*/
public interface MessageRepository extends JpaRepository<MessageEntity, Long> {

}

세팅은 끝났다!
서버 실행 후 Postman으로 테스트를 진행해보자

결과에서 볼 수 있듯 Controller에서는 Servlet filter를 거친 값을 받게된다.
하지만 위의 테스트는 Content-type이 x-www-form-urlencoded인 경우이다.
Content-type이 application/json일 때는 어떨까?

application/json 요청을 받기 위해 @RequestBody를 통해 parameter를 받는 메서드를 추가하자

MessageController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import org.springframework.web.bind.annotation.RequestBody;

@RestController
public class MessageController {
// ~ 생략

/**
* Content-type: application/json의 Message를 받아 DB에 등록
* @param message
* @return
*/
@PostMapping("/jsonMessage")
public MessageEntity postJsonMessage(@RequestBody MessageDto message) {
LOGGER.debug("message post request : {}", message);

return messageRepository.save(message.toEntity());
}

}

그 다음으로 postman으로 테스트!

위 결과에서 보듯 filter가 적용되지 않은 것을 볼 수 있다.
음 뭐가 문제인 걸까?

https://github.com/naver/lucy-xss-servlet-filter/issues/4

위 내용을 봤을 때 설정의 문제가 아닌 json 중복 parsing으로 인한 비효율과 추가적인 json parsing 라이브러리 의존성으로 인해 애초에 설계를 이런 방향으로 했던 것이다.

https://github.com/naver/lucy-xss-servlet-filter/issues/10

@RequestBody로 받아오는 즉, application/json 요청은 filtering 되지 않기때문에 이 경우 XSS 공격의 취약해진다.
(처음엔 적용이 안된 줄 알고 1시간 동안 다시 설정하고 확인하고를 반복했다…)

이러한 문제점(?)을 해결하기 위해 여러 방법이 있겠지만 나는 직접 doFilter 메서드를 호출하는 방식으로 XSS가 가능한 dirty값을 clean값으로 변경했다.
(해당 코드는 여기에서 확인 가능)

다른 방법으로는 MessageConverter를 사용하는 방식이 있는데 이는 여기서 확인하는게 좋을 것 같다.


추가로 lucy-xss-filter와 luxy-xss-servlet-filter 대해 짧게 언급하겠다.

https://github.com/naver/lucy-xss-servlet-filter/issues/10

위 내용에서 볼 수 있듯 lucy-xss-filter는 라이브러리로서 <를 &lt;로 replace 해주는 역할을 해준다.
라이브러리가 잘 해주지만 이를 일일이 설정하는 것이 고역이기 때문에 servlet단에서 자동화해주기 위해 lucy-xss-servlet-filter가 나온 것 같다.

Share