원문 캡처 · blog.master.dev
원문 캡처 · blog.master.dev

iframe의 postMessage는 부모 문서와 프레임 사이에 메시지를 주고받게 하지만, 받는 쪽이 발신자를 확인하지 않으면 다른 창도 메시지를 보낼 수 있다. 이 글은 위젯 크기 조절 메시지를 예로 들어 출처(origin), 발신 창, 데이터 형식을 확인하고 값의 범위를 제한하는 방법을 설명한다.

메시지 수신 측에서 확인할 점

iframe은 별도의 문서라 부모의 CSS가 프레임 안으로 전달되지 않는다. 출처가 다르면 동일 출처 정책(same-origin policy)에 따라 서로의 DOM에도 접근할 수 없다. 그래도 양쪽이 메시지를 주고받을 때는 postMessage를 사용할 수 있다. 문제는 메시지 이벤트를 등록하기만 하면, 참조를 가진 다른 창도 메시지를 보낼 수 있다는 점이다.

예를 들어 위젯은 자신의 높이를 부모에게 알리고, 부모는 그 값에 따라 iframe 크기를 바꿀 수 있다. 하지만 메시지를 무조건 신뢰하는 수신기는 엉뚱한 창이 보낸 값에도 반응한다. 높이를 1로 보내면 프레임이 거의 보이지 않게 되고, 999999를 보내면 페이지 레이아웃이 밀릴 수 있다.

수신기는 먼저 event.origin을 예상한 출처와 정확히 비교해야 한다. 부분 문자열이나 느슨한 정규식 비교는 비슷한 문자열을 포함한 다른 도메인을 허용할 수 있다. 샌드박스에서 allow-same-origin을 지정하지 않은 프레임은 불투명 출처를 가지며 이벤트의 출처가 "null"로 표시된다. 서로 다른 불투명 출처가 모두 같은 문자열로 표현되므로 이를 허용 목록에 넣어서는 안 된다.

출처 확인만으로는 충분하지 않다. 같은 출처에서 제공되는 다른 iframe도 출처 검사를 통과할 수 있기 때문이다. 메시지가 실제로 의도한 프레임에서 왔는지 event.source와 해당 iframe의 contentWindow를 비교해 확인해야 한다. 출처 검사는 프레임이 다른 곳으로 이동할 가능성에 대비해 함께 유지한다.

마지막으로 데이터의 모양과 값도 검사해야 한다. postMessage는 JSON이 아니라 구조화 복제(structured clone)를 사용하므로 NaN, Infinity, 순환 객체도 전달될 수 있다. null에서 속성을 읽으면 예외가 발생하고, 문자열이나 잘못된 객체는 예상한 높이 값을 제공하지 않을 수 있다. 먼저 데이터가 type: 'resize'인 객체인지 확인하고, 높이가 유한한 숫자인지 검사한 다음 허용 범위로 제한한다. Number()로 변환하면 null, 불리언, 빈 문자열도 숫자로 바뀔 수 있으므로 실제 숫자인지 확인하는 편이 맞다.

예시 데모에서 높이 범위는 CSS 픽셀 100~1200이다. 이는 브라우저가 요구하는 값이 아니라 데모의 정책이며, 상한보다 큰 콘텐츠는 iframe 안에서 스크롤된다. 실제 레이아웃에 맞춰 범위를 정해야 한다. 범위를 벗어난 값을 제한하는 대신 거부하는 프로토콜도 가능하다.

메시지 발신 측과 운영 시 주의점

메시지를 보내는 쪽도 대상 출처를 '*'로 두지 말고 허용할 출처를 지정해야 한다. 위젯이 예상하지 못한 사이트에 임베드되거나 창 참조가 다른 출처의 문서로 이동하면, 와일드카드는 메시지가 그 문서에 전달되게 할 수 있다. 세션 토큰이나 폼 데이터가 오가는 경우에는 정보 유출로 이어질 수 있다. 응답은 검증한 값을 재사용해 event.source에 event.origin을 대상으로 보내는 방식으로 처리할 수 있다.

높이 제한은 값의 크기만 제한할 뿐 메시지 빈도는 제한하지 않는다. 실제 서비스에서는 잦은 크기 변경 작업을 합치고, 거부 로그의 빈도도 제한해야 한다. 메시지를 받을 필요가 없다면 리스너 자체를 두지 않는 것도 한 방법이다. 글은 추가 방어 수단으로 iframe의 sandbox, 누가 페이지를 임베드할 수 있는지 정하는 CSP frame-ancestors, 두 창 사이의 전용 통신 채널인 MessageChannel을 소개한다.

데모는 CodePen 페이지와 GitHub Pages에서 제공되는 위젯을 사용해 서로 다른 출처를 구성한다. 위협을 흉내 내는 빨간 프레임은 CodePen 호스트와 같은 출처이므로, 실제로 분리된 공격자와 동일한 조건은 아니다. 로컬에서 다시 실행하려면 저장소를 복제하고 python3 serve.py를 실행한 뒤 http://localhost:4173/demo-1.html부터 demo-5.html까지 열면 된다. 호스트는 4173번 포트, 위젯은 4174번 포트에서 제공된다. 서로 다른 포트는 서로 다른 출처로 취급되며 file://에서는 이 데모를 재현할 수 없다.

관련 자료: 동일 출처 정책, MDN의 postMessage 보안 안내, 출처 검증 누락 사례, postMessage 예시, 구조화 복제, iframe sandbox, CSP frame-ancestors, MessageChannel, 데모 위젯.