반디북
모던 리액트 Deep Dive
Ch14
준영
도은
  • 웹사이트의 성능만큼이나 중요한 것은 바로 웹사이트의 보안
  • 코드의 규모가 증가한다는 것은 필연적으로 보안 취약점에 노출될 확률도 증가한다는 것을 의미

14.1 리액트에서 발생하는 크로스 사이트 스크립팅(XSS)

💡 크로스 사이트 스크립팅(XSS)란?
   웹사이트 개발자가 아닌 제3자가 웹사이트에 악성 스크립트를 삽입해 실행할 수 있는 취약점을 의미
  • script가 실행될 수 있다면, 웹사이트 개발자가 할 수 있는 모든 작업을 함께 수행 가능
    • 쿠키를 획득해 사용자의 로그인 세션 등을 탈취
    • 사용자의 데이터를 변경하는 등 각종 위험성

14.1.1 dangerouslySetInnerHTML prop

  • 특정 브라우저 DOM의 innerHTML을 특정한 내용을 교체할 수 있는 방법
  • 일반적으로 게시판과 같이 사용자나 관리자가 입력한 내용을 브라우저에 표시하는 용도로 사용
function App() {
  return <div dangerouslySetInnerHTML={{ __html: 'First &middot; Second' }} />;
}
  • 위험성은 dangerouslySetInnerHTML이 인수로 받는 문자열에는 제한이 없다는 것
const html = `<span><svg/onload=alrt(origin)></span>`;
 
function App() {
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
  • 즉, 자바스크립트 코드가 들어가면 실행이 가능하다

14.1.2 useRef를 활용한 직접 삽입

  • useRef를 활용하면 직접 DOM에 접근 가능 → innerHTML에 보안 취약점이 있는 스크립트를 삽입하면 동일한 문제가 발생
const html = `<span><svg/onload=alert(origin)></span>`;
 
function App() {
  const divRef = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    if (divRef.current) {
      divRef.current.innerHTML = html;
    }
  });
 
  return <div ref={divRef} />;
}

14.1.3 리액트에서 XSS 문제를 피하는 방법

💡 리액트에서 XXS 이슈를 피하는 가장 확실한 방법은
   제 3자가 삽입할 수 있는 HTML을 안전한 HTML 코드로 한 번 치환하는 것
import sanitizeHTML, { IOptions as SanitizeOptions } from 'sanitize-html';
 
// 허용하는 태그
const allowedTags = [
  'div',
  'p',
  'span',
  'h1',
  'h2',
  'h3',
  'h4',
  'h5',
  'h6',
  'figure',
  'iframe',
  'a',
  'strong',
  'i',
  'br',
  'img',
];
 
// 위 태그에서 허용할 모든 속성
const defaultAttributes = ['style', 'class'];
 
// 허용할 iframe 도메인
const allowedIframeDomains = ['naver.com'];
 
// 허용되는 태그 중 추가로 허용할 속성
const allowedAttributeForTags: {
  [key in (typeof allowedTags)[number]]: Array<string>;
} = {
  iframe: ['src', 'allowfullscreen', 'scrolling', 'frameborder', 'allow'],
  img: ['src', 'alt'],
  a: ['href'],
};
 
// allowedTags, allowedAttributeForTags, defaultAttributes를 기반으로 허용할 태그와 속성을 정의
const allowedAttributes = allowedTags.reduce((result, tag) => {
  const additionalAttrs = allowedAttributeForTags[tag] || [];
  return { ...result, [tag]: [...additionalAttrs, ...defaultAttributes] };
}, {});
  • 허용할 태그와 목록을 일일히 나열하는 이른바 허용 목록(allow list) 방식을 채택하기 때문에
    • 사용하기에 매우 귀찮게 느껴질 수도...
    • 그러나 이렇게 허용 목록ㅇ르 작성하는 것이 훨씬 안전
  • 단순히 보여줄 때뿐만 아니라 사용자가 콘텐츠를 저장할 때도 한번 이스케이프 과정을 거치는 것이 효율적이고 안전
    • 애초에 XSS 위험이 있는 콘텐츠를 데이터베이스에 저장하는 것이 도움
    • 이러한 치환 과정은 되도록 서버에서 수행하는 것이 좋다
    • 서버는 '클라이언트에서 사용자가 입력한 데이터는 일단 의심한다'라는 자세로 POST 요청이 있는 HTML을 이스케이프 하는 것이 제일 안전

마지막으로 단순히 게시판과 같은 예시가 없다고 하더라도 XSS 문제는 충분히 발생 가능

import { useRouter } from 'next/router';
 
function App() {
  const router = useRouter();
  const query = router.query;
  const html = query?.html?.toString() || '';
 
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
  • 위와 같이 쿼리스트링에 있는 내용을 그대로 실행하거나 보여주는 경우에도 보안 취약점이 발생

14.2 getServerSideProps와 서버 컴포넌트를 주의하자

  • 서버에서는 일반 사용자에게 노출되면 안 되는 정보들이 담겨 있기 때문에
  • 클라이언트, 즉 브라우저에 정보를 내려줄 때는 조심해야 한다.
export default function App({ cookie }: { cookie: string }) {
  if (!validateCookie(cookie)) {
    Router.replace(/* ... */);
    return null;
  }
  /* do something... */
}
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
  const cookie = ctx.req.headers.cookie || '';
  return {
    props: {
      cookie,
    },
  };
};
  • 예제에서는 getServerSideProps에서 cookie 정보를 가져온 다음, 이를 클라이언트 리액트 컴포넌트에 문자열로 제공해
    • 클라이언트에서 쿠기의 유효성에 따라 이후 작업을 처리
    • 보안에 썩 좋지 못함
  • getServerSideProps가 반환하는 props 값은 모두 사용자의 HTML에 기록
  • 충분히 getServerSideProps에서 처리할 수 있는 리다이렉트가 클라이언트에서 실행되어 성능 측면에서도 손해
💡 getServerSideProps가 반환하는 값 또는 서버 컴포넌트가 클라이언트 컴포넌트에 반환하는 props는
   반드시 필요한 값으로만 철저하게 제한되어야 한다.

다음과 같이 수정할 수 있다.

export default function App({ token }: { token: string }) {
  const user = JSON.parse(window.atob(token.split('.')[1]));
  const user_id = user.id;
  /* do something... */
}
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
  const cookie = ctx.req.headers.cookie || '';
  const token = validateCookie(cookie);
 
  if (!token) {
    return {
      redirect: {
        destination: '/404',
        permanet: false,
      },
    };
  }
  return {
    props: {
      token,
    },
  };
};
  • 쿠키 전체를 제공하는 것이 아니라 클라이언트에서 필요한 token 값만 제한적으로 제공
  • 이 값을 얻을 때 예외 처리할 리다이렉트 모두 서버에서 처리

14.3 <a> 태그의 값에 적절한 제한을 둬야 한다

  • 웹 개발 시에는 <a> 태그의 href에 javascript:로 시작하는 자바스크립트 코드를 넣어둔 경우를 본 적이 있을 것
  • <a> 태그의 기본 기능, 즉 href로 선언된 URL로 페이지를 이동하는 것을 막고 onClick이 이벤트와 같이 별도 이벤트 핸들러만 작동시키기 위한 용도로 주로 사용
function App() {
  function handleClick() {
    console.log('hello');
  }
 
  return (
    <>
      <a href="javascript:;" onClick={handleClick}>
        링크
      </a>
    </>
  );
}
  • 이러한 방식은 마크업 관점에서 안티패턴
  • <a> 태그는 반드시 페이지 이동이 있을 때만 사용하는 것이 좋다
  • 페이지 이동이 없이 어떠한 핸들러만 작동시키고 싶다면 <a>보다는 button을 사용하는 것이 좋다
  • XSS에서 소개한 사례와 비슷하게, href에 사용자가 입력한 주소를 넣을 수 있다면 이 또한 보안 이슈
💡 href로 들어갈 수 있는 값을 제한해야 한다
  • 그리고 피싱 사이트로 이동하는 것을 막기 위해 가능하다면 origin도 확인해 처리하는 것이 좋다
function isSafeHref(href: string) {
  let isSafe = false;
  try {
    // javascript: 오면 protocol이 javascript:가 된다.
    const url = new URL(href);
    if (['http:', 'https:'].includes(url.protocol)) {
      isSafe = true;
    }
  } catch {
    isSafe = false;
  }
  return isSafe;
}
 
function App() {
    const unsafeHref = 'javascript:alert('hello');'
    const safeHref = 'https://www.naver.com'
    return (
        <>
            {/* 위험한 href로 분류되어 #이 반환된다. */}
            <a href={isSafeHref(unsafeHref) ? 'unsafeHref' : '#'}>위험한 href</a>
            {/* 안전한 href로 분류되어 원하는 페이지로 이동할 수 있다. */}
            <a href={isSafeHref(safeHref) ? safeHref : '#'}>안전한 href</a>
        </>
    )
}

14.4 HTTP 보안 헤더 설정하기

💡 HTTP 보안 헤더
   브라우저가 렌더링하는 내용과 관련된 보안 취약점을 미연에 방지하기 위해 브라우저와 함께 작동하는 헤더
  • HTTP 보안 헤더만 효율적으로 사용할 수 있어도 많은 보안 취약점을 방지 가능

14.4.1 Strict-Transport-Security

  • HTTP의 Strict-Transport-Security 응답 헤더는 모든 사이트가 HTTPS를 통해 접근해야 하며
  • 만약 HTTP로 접근하는 경우 이러한 모든 시도는 HTTPS로 변경되게 된다.
Strict-Transport-Security: max-age=<expire-time>; includeSubDomains
  • <expire-time>은 이 설정을 브라우저가 기억해야 하는 시간을 (초 단위)
  • 이 기간 내에는 HTTP로 사용자가 요청한다 하더라도 브라우저는 이 시간을 기억하고 있다가 자동으로 HTTPS로 요청하게 된다.
  • 만약 헤더의 시간이 경과되면 HTTP로 로드를 시도한 다음에 응답에 따라 HTTPS로 이동하는 등의 작업을 수행할 것
  • 시간이 0으로 되어 있다면, 헤더가 즉시 만료되고 HTTP로 요청
  • 권장값은 2년

14.4.2 X-XXS-Protection

  • 비표준 기술. 현재 사파리와 구형 브라우저에서만 제공되는 기능
💡 페이지에서 XSS 취약점이 발견되면 페이지 로딩을 중단하는 헤더
  • Content-Security-Policy를 지원하지 않는 구형 브라우저에서는 사용이 가능
  • 그러나 이 헤더를 전적으로 믿어서는 안 되며, 반드시 페이지 내부에서 XSS에 대한 처리가 존재하는 것이 좋다

14.4.3 X-Frame-Options

  • frame, iframe, embed, object 내부에서 렌더링을 허용할지를 나타낼 수 있다.
  • 페이지에서 네이버를 iframe으로 렌더링한다고 가정해보자
    • 사용자는 이 페이지를 네이버로 오해할 수 있고
    • 공격자를 이를 활용해 사용자의 개인정보를 탈취 가능
💡 X-Frame-Options는 외부에서 자신의 페이지를 위와 같은 방식으로 삽입되는 것을 막아주는 헤더
  • 네이버 같은 경우에도, X-Frame-Options: deny 옵션이 있어서
    • <iframe>으로 삽입되는 것을 막는 역할

14.4.4 Permissions-Policy

💡 웹사이트에서 사용할 수 있는 기능과 사용할 수 없는 기능을 명시적으로 선언하는 헤더
  • 개발자는 다양한 브라우저의 기능이나 API를 선택적으로 활성화하거나 필요에 따라서는 비활성화할 수 있다.
  • 여기서 말하는 기능은 카메라나 GPS와 같이 브라우저가 제공하는 기능
  • 브라우저에서 사용자의 위치를 확인하는 기능과 관련된 코드를 전혀 작성하지 않았다고 해보자
    • XSS 공격 등으로 인해 이 기능을 취득해서 사용하게 되면, 사용자의 위치를 획득할 수 있게 되는 것

14.4.5 X-Content-Type-Options

  • MIME(Multipurpose Internet Mail Extensions): Content-type의 값으로 사용
    • 메일을 전송할 때 사용하던 인코딩 방식으로
    • 현재는 Content-type에서 대표적으로 사용
💡 MIME 유형이 브라우저에 의해 임의로 변경되지 않게 하는 헤더
  • Content-type: text/css 헤더가 없는 파일은 브라우저가 임의로 CSS로 사용할 수 없으며
  • Content-type: text/javascriptContent-type: application/javascript 헤더가 없는 파일은 자바스크립트로 해석할 수 없다.
  • 즉, 웹서버가 브라우저에 강제로 이 파일을 읽는 방식을 지정하는 것