웹 보안 개념(CSRF, XSS)+JWT, Spring Framework + OWASP JAVA HTML Sanitizer + JVM 으로 XSS 방어하기(JWT 사용할 때 XSS 방어기법)
웹 개발자를 위한 웹 보안 기초 개념 시리즈
CSRF 공격
Cross-Site Request Forgery
*Request Forgery: 요청을 위조한다.
로그인된 사용자에게 사용자가 원하지 않지만, 해커가 원하는 요청을 유도하는 공격 기법
*사용자가 ADMIN과 같은 권한을 가지면 큰 피해를 입을 수 있음.
계정을 통해서, 특정한 요청에 대해서 서버에서 실행이 될 텐데, 해커가 계정이 로그인된 브라우저한테 요청을 유도시켜서
서버에 요청이 날라가는 방법입니다.
모든 요청은 서비스의 구현 취약점을 찾은 후, 취약점 트랩을 만드는 방법입니다.
취약점 1: GET 요청 URI에 행위가 나타나는 케이스
https://www.devyummi.com/delete/1
위 경로가: 1번 게시글을 지우는 역할을 한다고 가정
원래는 admin or 1번 권한 작성자만 인가 책임이 주어진다.
GET 요청을 admin 주소를 통해서 하는 거임.
1. 위 주소 요청의 HTML의 태그를 만듦, <a>, <img> 태그를 심어둠
2. 해당 태그를 권한이 있는 유저에게 보냄(이메일, 게시판 등)
해결방법: GET 요청 URI일 때, Read 이외에 행위가 들어가면 안 됩니다.
사례
2008년 옥션이 당했음 (1,800만 유저 정보 유출)
옥션 계정 비밀번호 변경 로직이 URI 쿼리 파라미터 기반이었음.
해커가 <img src> 태그에 비밀번호 변경 URI를 어드민 계정에 심어서 어드민 계정 로그인함.
취약점 2: Form 태그 요청에 hidden 삽입
CUD 행위를 POST Form태그 기반으로 진행하면 안전할까요?
1. 파라미터를 hidden type으로 숨긴 Form 태그를 만듦
2. 해당 Form을 권한이 있는 유저에게 보냄(이메일, 게시판 등)
해결 방법: CSRF 토큰을 도입, 스프링 시큐리티(SecurityConfig) 필터체인 작성 csrf() 설정을 추가하는 것이
csrf 토큰을 검증하는 로직입니다.
그래서 보통
개발환경: csrf disable
배포환경: csrf enable
csrf 토큰 역할
서버는 로그인한 유저에게 고유 난수 CSRF 토큰 발급
서버사이드로 유저에게 제공할 때, Form 태그에 해당 CSRF토큰을 hidden으로 담겨서 유저에게 제공된다.
이때, 해커가 만든 Form 태그는 CSRF 토큰을 담지 못하기에 인가가 거부됩니다.
해커가 만든 비정상 FORM 태그 | 개발자가 만든 정상 FORM 태그 |
CSRF 토큰 없음, 인가거부 | CSRF 토큰 있음, 인가승인 |
세션 방식일 경우 위험성
위 두 가지 취약점은 세션 인증 방식일 경우 특히 유효합니다.
CSRF 보안 로직을 세션일 때, 추가하고 JWT일 경우 제외하고 있습니다.
왜 세션 방식이 위험한지 파악하고, JWT 방식은 정말 안전한지 확인해 볼까요?
쿠키-세션 방식 인증
세션 방식 인증은 접근하는 브라우저별로 쿠키에(톰캣은 JSESSIONID)를 발급하고, 그 쿠키에 대응되는 정보를 서버 메모리(세션)에 저장합니다.
브라우저로 네이버로 요청한다면 항상 쿠키를 들고 가기 때문에, 쿠키는 브라우저에 의해 매 요청 끼워서 필수적으로 전송(예외케이스 존재)
JWT 방식
반면 JWT 방식 인증은 로그인을 마친 유저에게 JWT를 발급하고, 인증이 필요할 때 서버 측으로 JWT를 보내지만,
쿠키 방식이 아니어서 자동으로 요청에 따라가지 않습니다.
*클라이언트 사이드 렌더링(CSR): 이면 클라이언트 측에서 따로 엑세스 요청에 태우는 로직 구현해줘야 함.
*저도 코드 레벨에 csrf disable 하면서 JWT방식을 이용해서 구현했습니다.
그럼 왜 JWT는 항상 안전한가??
JWT는 발급 후 프론트의 로컬 스토리지 or 프론트에서 삽입한 쿠키에 저장하게 됩니다.
로컬 스토리지에 저장한 JWT는 요청 시 JS의 axios나 fetch에 붙이게 된다.
하지만 -> 해커가 만든 위조 요청은 위 행위를 구현하기 어렵다. 따라서 JWT는 CSRF 공격으로부터 안전하다.
다만, JWT를 로컬 스토리지가 아닌 쿠키에 보관하는 경우, 쿠키의 모든 요청에 항상 따라가기 때문에 CSRF 공격에 조심해야 한다.
XSS 공격
Cross Site Scripting은 정보를 탈취할 수 있는 JS로직을 삽입, 정상적인 유저가 로직을 실행하면 발생하는 해킹 기법
JWT는 브라우저에서 자동으로 백엔드로 넘어가지 않기 때문에 CSRF 공격으로 안전하다. 하지만 XSS공격에 대해서는 취약점을 가지게 됩니다.
로컬 스토리지에서 토큰을 빼내기 위해서는 JS로 결국에 빼내야 해서 결국은 악성 JS가 쉽게 잘 넣고 뺄 수 있다.
풀 네임대로 하면 CSS여야 하는데 CSS가 너무 유명해서 혼동 방지를 위해 XSS로 표기합니다.
XSS 공격은 3개 정도 있다.
- Relected:
- DOM based:
- Stored:
Relected XSS
프론트의 입력창에 문자열대신 악성 JS를 서버로 넘어간 뒤, 백엔드에서 View로 사용할 경우 JS로 로딩되면서 악성 JS가 로딩됨
네이버에 검색을 하면, 주소로 쿼리에 담겨서 네이버 서버로 날아가고 View에 해당하는 정보를 보여줌
우리가 검색한 검색쿼리가 그대로 숨겨져 있음.
@Controller
public class SearchController {
@GetMapping("/search/{query}")
public String search(@PathVariable String query, Model model) {
model.addAttribute("query", query);
return "search";
}
}
우리가 보낸 검색어 쿼리는 모델에 의해서 재사용하기 위해, 로딩을 진행함.
그때 담겨서 View에서 로딩할 때,
해결 방법 1
- 프론트에서 해결하기: 프론트에서 입력 값에 JS 값이 포함되는지 검증
- 하지만 프론트에서 막는다고 한들, 결국 백엔드에서 열려있음.
해결 방법 2
쿼리에 쿼리를 검사해서 악성 JS 코드가 있다면, 조건문이나 로직으로 제거해서 리턴한다.
스프링: mustache {{}}, 타임리프 th:utext, 리액트 {}로 모두 지원
XSS에 대한 JWT와 쿠키
쿠키
- httpOnly 설정만 킨다면 JS로 조회 자체가 불가
로컬 스토리지 JWT
- 로컬 스토리지에 저장된 JWT가 위험합니다, JS 기반으로 저장과 로드하다 보니 XSS 공격에 취약합니다.
OWASP JAVA HTML Sanitizer(XSS 방어하기 - 스프링 로직)
OWASP에서 만든 HTML을 안전하게 정제하기 위한 자바 라이브러리
정규식고 조건문을 통해 악성 JS로직을 삭제하면 되지만, 범주가 너무 큼.
따라서 모듈을 사용하는 방법이 있음.
*OWASP: 전 세계적으로 운영되는 보안 비영리 단체
*Sanitizer: 살균제 == 검열이라는 뜻
사용방법
implementation 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1'
build.gradle 해당 라이브러리 추가한다.
* 항상 버전을 명시해야 한다.
버전은 mvnrepository를 통해서 확인가능하다.
이거 기반으로 Util 클래스 등록
*Util 클래스: 그거를 앞으로 서비스나 컨트롤러에서 땡겨쓰도록 정의해 두는 것이다.
화이트 리스트 기반으로 작성한다.
이유: 블랙리스트는 너무 많기 때문에
Util 이기 때문에 static으로 메소드를 선언
*static 키워드:
JVM은 Runtime Data Area를 가지고 있다.
여기서 Method Area(Static Area)를 보면 정적영역이라고 부르는 메모리이다.
프로그램 실행 중 클래스나 인터페이스를 사용하게 되면, JVM은 Class Loader를 이용해 클래스와 인터페이스의 메타 데이터를 저장한다.
프로그램이 시작되면, JVM은 main() 메서드가 static이기 때문에 클래스를 클래스 로더를 통해 메모리로 올린다.
다른 static 클래스는 클래스 로딩 시점(클래스 파일이 JVM 메모리에 적재되는 순간)에 필요할 때 로드됩니다.
필요할 때란? 클래스 자체를 처음 참조할 때
static 멤버는 JVM에 한 번 적재된 이후에는 JVM이 종료될 때까지 유지됩니다.(JDK 8 이후)
결론적으로 왜 HtmlSanitizerUtil은 static 메서드로 만들었는가?
공통 기능을 한 곳에 모아두고 서비스나 컨트롤러에서 객체 생성 없이 바로 사용할 수 있도록 하기 위함
예: HtmlSanitizerUtil.sanitize(html) 형태로 바로 호출
장점: 인스턴스 생성 불필요, 클래스 로딩 시 한 번만 초기화
import org.owasp.html.HtmlPolicyBuilder;
import org.owasp.html.PolicyFactory;
public class HtmlSanitizerUtil {
// 무조건 화이트 리스트 기반으로 작성 함
private static final PolicyFactory POLICY = new HtmlPolicyBuilder()
.allowCommonInlineFormattingElements() // 자주 사용하는 기본 포매팅 엘리먼트
.allowCommonBlockElements() // 자주 사용하는 기본 엘리먼트
.allowElements("hr", "br") // 추가 엘리먼트
.allowElements("pre", "code", "img") // 추가 엘리먼트
.allowElements("table", "thead", "tbody", "tfoot", "tr", "th", "td") // 추가 엘리먼트
.allowAttributes("href", "target").onElements("a") // a 태그에 대해 속성 허용
.requireRelNofollowOnLinks() // nofollow 추가
.allowAttributes("src", "alt", "width", "height").onElements("img")
.allowAttributes("class").onElements("pre", "code", "span")
.allowAttributes("colspan", "rowspan").onElements("th", "td")
.toFactory();
public static String sanitize(String html) {
return POLICY.sanitize(html);
}
}
컨트롤러에서 사용예시
String cleanHtml = HtmlSanitizerUtil.sanitize(beforeHtml);

주의점
개발 블로그 같은 경우 코드 블록에 <script>를 포함하여 게시글을 작성하는 경우가 많습니다.
블록사용 시 예외처리로 처리하면 된다.
하지만, 댓글 같은 곳에 사용하면 사용자가 의도한 행위임에도 불구하고 악성 JS로 판단함.
따라서 상황에 따라 조건 및 모듈 선택이 필요함.
*(UX, 속도 <-> 보안)가 trade-off 관계로 되기 때문에 이런 부분을 고려하세요.