사이드 프로젝트로 진행하다보면 최적의 상황에서만 테스트를 하게 된다. 데이터가 없거나, 에러가 생기는 상황 등은 먼저 이 상황을 생각해서 대비해야한다. 그냥 진행하다보면 놓치기 쉽상이다. 내부 로직은 다 구현해놓고 상상도 못하게 계정이 없는 케이스에서 에러가 생긴 날부터, 최악의 상황을 대비해야한다는 생각에 사로잡혔다. useEffect로 데이터를 가져오는 동안 데이터를 건들지 못하게 하기 위해 lock을 설정해야한다는 지점에서, 프론트엔드의 보안적인 예외상황에 대해 관심이 가기 시작했다.
XSS(Cross Site Scripting)
개발자가 아닌 제 3자가 삽입한 스크립트를 통해 공격하는 기법이다. 즉, 개발자가 작성하지 않은 스크립트를 입력값을 통해 서버에 보내 실행하는 것이다. 따라서 모든 입력값에 대해서는 게시글 내용이 아닌 스크립트에 대한 대비가 필요하다.
dangerouslySetInnerHTML prop
React에서 HTML을 동적으로 렌더링하는 데 사용되는 속성이다. 외부 소스에서 HTML을 가져와서 React 컴포넌트 안에 렌더링할 대, dangerouslySetInnerHTML을 활용할 수 있다. __html의 값으로 문자열을 전달하면 해당 컴포넌트 안에 내부 HTML로 설정된다. 그러나 React의 가상 DOM을 우회하고 직접적으로 HTML을 조작할 수 있기 때문에 XSS 공격에 취약해진다. React는 가상 DOM과 실제 DOM을 상호작용하는데, React의 가상 DOM이 추적하지 않는 실제 DOM의 일부분을 직접 설정할 수 있다. 따라서 이 속성을 사용할 때는 외부 소스에서 제공된 HTML이 안전한지 필수적으로 확인하고 신뢰할 수 있는 출처에서만 사용해야 한다.
function MyComponent() {
const rawHTML = "<script>alert('XSS Attack!');</script>";
return <div dangerouslySetInnerHTML={{ __html: rawHTML }} />;
}
useRef를 통한 직접 삽입
useRef를 통하여 DOM에 직접 접근하고, useEffect 훅을 사용하여 컴포넌트가 마운트될 때 해당 DOM 요소의 innerHTML을 변경하면 React의 가상 DOM을 우회하고 직접 DOM을 조작하므로 XSS 공격에 취약해진다. 이 역시 직접 DOM에 접근할 때는 신뢰할 수 있는 소스에서만 데이터를 가져와서 사용해야 한다.
import React, { useRef, useEffect } from 'react';
const MyComponent = () => {
const myRef = useRef();
useEffect(() => {
myRef.current.innerHTML = "<script>alert('XSS Attack!');</script>";
}, []);
return <div ref={myRef}></div>;
};
export default MyComponent;
XSS 공격 방어하기
입력값 이스케이프
사용자가 입력한 값을 안전한 형태로 변환하여 HTML, CSS, JavaScript 등의 코드가 실행되지 않도록 할 수 있다. 일반적으로 React는 JSX를 렌더링할 때 HTML 이스케이프를 자동으로 수행한다.
function escapeHTML(input) {
return input.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
const userInput = "<script>alert('XSS Attack!');</script>";
const escapedInput = escapeHTML(userInput);
console.log(escapedInput); // <script>alert('XSS Attack!');</script>
라이브러리 사용
- DOMpurity: 외부 HTML을 안전하게 파싱하고 필요한 요소만을 허용하는 라이브러리로, 외부 HTML을 렌더링하기 전에 HTML을 정제하고 안전한 형태로 변환할 수 있다.
import DOMPurify from 'dompurify';
const htmlString = "<script>alert('XSS Attack!');</script>";
const sanitizedHtml = DOMPurify.sanitize(htmlString);
return <div dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />;
- sanitize-html: Node.js 환경에서 HTML을 안전하게 정제한다. 이를 통해 외부 HTML을 안전한 형태로 변환할 수 있다.
const sanitizeHtml = require('sanitize-html');
const htmlString = "<script>alert('XSS Attack!');</script>";
const sanitizedHtml = sanitizeHtml(htmlString);
return <div dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />;
- js-xss: JavaScript에서 XSS 공격을 방어하기 위한 라이브러리로, 외부 입력값을 안전하게 처리할 수 있다.
const xss = require('xss');
const htmlString = "<script>alert('XSS Attack!');</script>";
const sanitizedHtml = xss(htmlString);
return <div dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />;
X-XSS-Protection 보안 헤더
페이지에서 XSS 취약점이 발견되면 페이지 로딩을 중단하는 헤더이다. 이 헤더를 사용하면 브라우저가 페이지를 로드할 때 내부 스크립트를 실행하기 전에 일종의 XSS 필터를 활성화할 수 있다. 그러나 비표준 기술로, 현재 사파리와 구형 브라우저에서만 제공되는 기술이다. Content-Security-Policy가 있다면 이 헤더가 필요 없지만 구형 브라우저에서는 사용 가능하다. 그러나 전적으로 믿어서는 안 되며, 반드시 페이지 내부에서 XSS에 대한 처리가 존재하는 것이 권장된다.
- 0은 XSS 필터링을 끈다
- 1은 기본값으로 XSS 필터링을 켜게 된다. 만약 XSS 공격이 페이지 내부에서 감지되면 XSS 관련 코드를 제거한 안전한 페이지를 보여준다
- 1; mode=block은 1과 유사하지만 코드를 제거하는 것이 아니라 아예 접근 자체를 막아버린다
- 1; report=<reposting-uri>는 크로미움 기반 브라우저에서만 작동하며, XSS 공격이 감지되면 보고서를 report= 쪽으로 보낸다.
import axios from 'axios';
const instance = axios.create();
instance.interceptors.request.use(config => {
config.headers['X-XSS-Protection'] = '1; mode=block';
return config;
});
Content-Security-Policy 헤더
콘텐츠 보안 정책(CSP)는 웹 사이트에서 호출할 수 있는 컨텐츠를 제한하는 정책으로, XSS 공격이나 데이터 삽입 공격과 같은 다양한 보안 위협을 막기 위해 설계되었다. 웹서버에서 HTTP 응답 헤더에 CSP 헤더를 포함시켜야 한다. 이미지, 스타일, 스크립트와 같은 정적인 콘텐츠 뿐만 아니라 주소, 도메인 등의 정보도 포함할 수 있으며, 리소스의 소스를 명시하거나 특정 도메인에서만 리소스를 로드할 수 있도록 설정할 수 있다.
- default-src: 기본적인 리소스 로드 정책을 설정, 설정되지 않은 리소스 유형에 대해 적용
- img-src: 이미지 리소스의 로드를 제어
- script-src: JavaScript 파일의 로드와 실행을 제어
- style-src: CSS 파일의 로드와 실행을 제어
- font-src: 폰트 파일의 로드를 제어
- form-action: 폼의 액션 URL을 제어하여 허용되는 URL을 지정
Express 애플리케이션에서 설정하기
Express 애플리케이션의 경우 helmet 미들웨어를 사용하여 CSP를 설정한다.
const express = require('express');
const helmet = require('helmet');
const app = express();
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'"],
fontSrc: ["'self'"],
imgSrc: ["'self'"],
},
})
);
NGINX 서버에서 설정하기
정적인 파일을 제공하는 Nginx의 경우 경로별로 app_header 지시자를 사용해 원하는 응답 헤더를 추가한다.
location / {
# ...
add_haeder X-XSS-Protection "1; mode=block";
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; child-src e_; style-src 'self' example.com; font-src 'self';";
# ...
}
XSS 공격같은 경우 서버에서도 필터링 가능하지만 완전히 방어하기는 어렵기 때문에 클라이언트에서도 적절한 필터링을 통해 대비해야한다. CSRF(Cross-site request forgery) 공격도 존재한다. 이는 사용자가 자신의 의지와 무관하게 공격자가 의도한 행동을 하여 특정 웹페이지를 보안에 취약하게 한다거나 수정, 삭제 등의 작업을 하게 만드는 공격 방법이다. 예를 들어 로그인한 상태에서 공격자의 웹사이트에 방문했을 때, 공격자의 사이트에서 로그인한 사용자의 권한으로 사용자가 원하지 않은 응행 계좌 이체 요청등의 요청을 보내는 것이다. 또한 악의적인 SQL문을 서버에 보내는 SQL injection도 있다. 스크립트가 브라우저에서 실행되면서 데이터베이스에 접근하는 등 XSS 공격과 SQL injection이 같이 일어날 수도 있고, XSS 공격을 통해 세션 쿠키를 도용하고, 인증된 상태로 SQL Injection을 시도할 수도 있다.
Reference
모던리액트딥다이브
XSS 공격을 직접 해보면서 알아보기(dangerouslySetInnerHTML는 얼마나 위험할까?) | by 민동준 | Medium