얼마 전에 마케팅 팀에서 연락이 왔습니다. 현재 회사에서 마케팅 성과 분석을 위해 사용하고 있는 툴을 변경한다는 내용이었습니다. 당연히 웹뷰 서비스를 개발하는 프론트엔드 팀에게 해당 마케팅 툴의 Web SDK 설치를 부탁하는 내용과 함께 말이죠.
사실 이전에 딥링킹을 위해 앱스플라이어(AppsFlyer)의 원링크 설치를 해봤던 경험이 있어 별로 어려운 일이 아니라고 생각이 들어 해당 업무를 맡겠다고 하였습니다.
(그는 그 뒤에 어떤 일이 일어날지 예상하지 못했다...)
생각보다 SDK 설치는 간단했습니다. cdn을 사용하거나, 패키지 매니저(npm, yarn 등)를 사용하거나 혹은 GTM(구글 태그 매니저)를 활용하는 방법이 있었습니다. 기존에도 웹뷰에서 GTM을 사용하였고, yarn으로 설치하게 되면 매번 빌드 때마다 패키지 설치 시간이 오래 걸릴 거 같아 GTM을 사용하기로 하였습니다.(네.. 저희는 빌드 때마다 모든 dependency를 새로 설치하고 있습니다..)
참고 : 에어브릿지 WEB SDK Docs
제가 이해한 선에서 GTM을 설명하자면, 특정 트리거를 발동시켜 태그를 활성화 시키는 도구입니다. 흔히 말하는 DOM element에 이벤트 핸들러를 등록하고 특정 이벤트 발생하면 해당 이벤트 핸들러가 실행되는 것처럼, 특정 트리거를 정의하고 해당 트리거에 따라 특정 마케팅 도구 (GA 등)에 데이터를 보내거나, 자바스크립트도 실행시킬 수 있습니다. (하지만, GTM에 자바스크립트를 바로 등록하는 경우 ES6 이후 문법을 지원하지 않습니다..🥲)
GTM은 전역 객체 내 dataLayer라는 배열을 바라보고 있는데, 해당 배열에 이벤트 객체(ex : { event : 'buy_product', ...})를 추가하면 이벤트 이름과 동일한 이름의 이벤트로 설정된 트리거가 발동되도록 설정하였습니다.
(출처 : https://goo-gy.github.io/2021-04-28-google-tag-manager)
이렇게 트리거 설정을 마친 후, GTM 버전을 배포하게 되면 해당 설정이 바로 적용됩니다. 근데 그게 문제였습니다. 해당 GTM 버전을 설치한 후, 1시간 뒤쯤 갑자기 로그인 토큰이 필요한 웹뷰 페이지에서 에러가 발생하였습니다.
로그인 토큰이 담겨 보내야 하는 API 요청에, 토큰 값이 없어 API 오류가 지속적으로 발생하였고, 급하게 GTM 배포를 되돌렸더니 에러가 발생하지 않았습니다.
우선 이 지점에서 가장 큰 실수는 먼저 테스트를 거치지 않고 바로 라이브에 적용되는 GTM 버전을 배포한 것이었습니다. 당연히 모든 코드는 라이브로 나가기 전에 항상 테스트를 거쳐야 하지만, 이 사실을 간과하고 바로 배포한 것이 가장 큰 실수였습니다.
이 때문에 고객 센터에 에러 문의가 들어와 꽤 곤혹을 겪었습니다. 이 부분에서 사소한 설정이라도 라이브로 나가기 전 항상 테스트를 거쳐야 한다는 사실을 인상 깊게 느끼게 되었습니다.
우선 에러의 원인은 로그인 토큰이 사라진 것이었습니다. 서버 요청을 살펴 보니, request header 에 같이 담겨 보내져야 하는 token 값이 null 로 되어 있었습니다.
현재 회사의 웹뷰 서비스는 페이지가 mount 된 후 앱의 인터페이스를 호출(useEffect의 callback 함수로 등록)하여 사용자의 로그인 정보 및 앱 정보를 불러와 이를 브라우저 스토리지에 저장하고 있습니다. 인터페이스를 호출하게 되면 message 이벤트 객체에 토큰 정보가 담겨 오기 때문에, 기존 로그인 로직은 message 이벤트 핸들러를 등록하고 있었습니다.
그런데 여기서 airbridge 역시 App 최상단(여기서는 javascript 코드 최상단)에서 airbridge init 함수를 호출하게 되는데 이 함수에서 message 이벤트가 실행된다는 점입니다. message 이벤트 핸들러는 event 객체에 토큰값이 없을 경우(e.g 로그아웃 한 경우), 토큰 정보를 갱신하기 위해서 저장된 토큰값을 모두 삭제하는 로직이 포함되어 있습니다.
근데 앱 인터페이스와 airbridge init의 message 이벤트가 순차적으로 발생하면서 토큰 정보가 저장된 이후, 다시 토큰이 삭제된 것이 문제가 되었습니다.
다행히 앱 팀의 협조를 통해 전역 객체에 함수를 등록하게 되면, 인터페이스 호출 시 앱에서 바로 해당 함수를 평가하고 실행할 수 있게 되었다 (전역을 오염시키고 싶지 않았지만 레거시 코드를 주니어가 뜯어고치기에 부담이 너무 컸습니다...)
이 과정을 통해서 피상적으로 알고 있던 message 이벤트에 대해 공부하면서, message 이벤트가 가진 취약점과 이를 보완할 수 있는 방법에 대해 배웠습니다.
mdn에 따르면 message 이벤트를 trigger 할 수 있는 여러 인터페이스를 소개하고 있습니다(한국어 번역 버전 말고 원문 버전에 있습니다) 그 중에서 Cross-document messaging으로 window.postMessage 를 통해서 message 이벤트를 등록할 수 있는데, 찾아보니 해당 인터페이스가 가진 보안 이슈가 있다는 사실을 알게 되었습니다.
window.postMessage는 서로 다른 origin(cross-origin)간의 통신을 가능하게 하는 메소드로, 서로 데이터를 주고 받을 수 있도록 합니다. 브라우저는 보안 상의 이유로 동일 출처(same origin)으로부터 온 문서나 스크립트만 상호작용하는 것을 허용하는 보안 방식을 취하고 있습니다. 이를 통해서 해로운 문서로부터 공격받을 수 없도록 만듭니다. 하지만 window.postMessage는 다음과 같이 이를 우회할 수 있습니다.
const targetWindow = window.opener;
targetWindow.postMessage(...);
위와 같은 방식으로 다른 출처(origin)의 window에게 message Event를 전송할 수 있습니다.
XSS 공격 방식은 악의적인 해커가 특정 사용자의 브라우저에 스크립트를 심어, 의도치 않은 스크립트 실행으로 사용자 정보를 빼내오는 등의 공격 방식을 의미합니다. 바로 postMessage를 통해 받은 event data에 script가 심겨져 있을 경우 이를 방어해줄 수 있는 로직이 필요합니다.
만약 script가 담겨있는 메시지를 받은 다음, 필터링 없이 해당 데이터를 element.innerHTML에 삽입한다고 생각해보면 굉장히 위험할 것입니다.
window.addEventListenr('message', (event) => {
const data = event.data; // <script>console.log('hacking')</script>
const $name = document.getElementId('name');
$name.innerHTML = data;
});
만약 postMessage를 통해서 사용자의 민감한 정보를 보낸다고 한다면, 이를 이용해 정보를 Hijacking 할 수 있습니다. 만약 어떤 웹 서비스에서 child로 iframe을 이용해 결제 서비스를 붙이고, 해당 결제 페이지에서 결제를 마친 정보를 다시 parent 웹페이지로 보낸다고 생각해봅시다.
// in iframe
const sendPaymentInfo = (paymentInfo) => {
window.postMessage(paymentInfo);
}
이렇게 child에서 자신이 보낼 parent에 대한 검증을 거치지 않게 된다면, 공격자가 해당 페이지를 자신의 하위 페이지로 둔 다음, message 이벤트를 탈취할 가능성이 있습니다.
가장 단순한 방법은 mdn에서 소개했듯이, message 이벤트 핸들러를 등록하지 않는 것이다. 그러나 불가피하게 사용해야 하는 환경이라면 우선 event 객체의 origin을 체크하는 방법이 가장 단순하다.
const messageEventHandler = (event) => {
if(event.origin !== "https://www.ourOrigin.com") return;
...
}
만약 DOM 트리에 바로 접근하여 데이터를 삽입하는 경우, 적어도 해당 데이터에 script 등 악의적인 코드가 심겨져있을 경우 dompurify를 통해 순수한 string으로 이를 바꿀 수 있습니다. 이를 통해 XSS 와 같은 악의적인 코드 실행을 방지할 수 있습니다.
child에서 parent로 postMessage를 통해서 민감한 사용자 정보를 전달할 경우, 두 번째 인자로 메시지를 보낼 origin을 명시하는 방법으로 정보 탈취를 방지할 수 있습니다.
이벤트를 전송하려 할 때에 targetWindow의 스키마, 호스트 이름, 포트가 targetOrigin의 정보와 맞지 않다면, 이벤트는 전송되지 않습니다. 세 가지 모두 일치해야 이벤트가 전송됩니다...악의적인 제 3자가 가로채지 못하도록, targetOrigin을 반드시 지정한 수신자와 동일한 URI를 가지도록 설정하는 것이 정말 중요합니다(by mdn)
로그인이 안 되는 초유의 에러를 터뜨린 후, 굉장히 당황했던 순간이었지만 이를 통해서 그동안 잘 알지 못했던 message 이벤트와 위험성을 알게 되어 의미있었습니다. 앞으로는 사소한 것도 배포 전에 그 위험성과 효과를 생각하는 습관을 가져야습니다.