본문 바로가기
빌드노트 검색

빌드노트

iframe 메시지, origin이 같으면 같은 위젯에서 온 걸까

iframe 메시지의 데이터 형식과 origin이 맞아도 예상한 위젯의 메시지는 아닐 수 있다. 두 loopback origin과 세 iframe으로 source 검사, 데이터 계약, 발신 targetOrigin의 차이를 확인한다.

iframe 위젯의 message 수신 코드에서 event.data의 형식만 검사하면, 같은 형식으로 보낸 다른 창의 메시지도 처리할 수 있다. origin 검사를 추가해도 같은 origin의 다른 iframe은 남는다. 특정 위젯 하나와 연동한다면 정확한 origin, 예상한 창인 source, 데이터 구조를 각각 확인한 뒤 처리해야 한다.

이 차이를 보기 위해 로컬 부모 페이지에 합성 iframe 세 개를 붙였다. 실제 서비스의 장애 사례가 아니라 무해한 카운터만 바꾸는 별도 실험이다. 데이터 검사만 통과한 메시지는 3건이었고, 세 조건을 모두 통과한 메시지는 정상 위젯의 1건뿐이었다.

주소는 같은데 보낸 창은 달랐다

첫 관찰에서 부모 페이지는 http://127.0.0.1:6954, 허용할 위젯의 origin은 http://127.0.0.1:6955였다. 포트는 실행 때 임의로 배정했다. 위젯과 같은 origin의 두 번째 iframe, 부모와 같은 origin의 세 번째 iframe도 만들었다. 최종 실험 페이지는 부모 수신 리스너를 먼저 등록한 뒤 iframe의 src를 지정한다. 각 iframe이 준비됐다는 메시지를 받은 뒤 차례로 입력을 보냈다.

정상 데이터는 { type: "widget-count", v: 1, count: 1 }이다. 계약은 키 세 개, 정해진 type과 버전, 1부터 5까지의 정수 count로 좁혔다. 다른 두 창은 count만 각각 2와 3으로 바꿔 같은 구조를 보냈다. 아래 건수는 값을 더한 합계가 아니라 처리한 메시지 수다.

  • 정상 위젯: 허용 origin과 예상 source가 모두 맞았다. 세 검사 방식에서 모두 처리됐다.
  • 다른 origin의 iframe: 데이터 형식은 맞아서 데이터만 보는 수신부는 처리했다. origin 검사를 추가한 수신부는 거부했다.
  • 같은 origin의 다른 iframe: origin과 데이터 검사까지 통과했다. event.source === widget.contentWindow는 false여서 source 검사에서 거부됐다.

같은 11개 수신 이벤트를 세 방식으로 판정한 최종 카운터는 다음과 같았다.

데이터 구조만 확인:                3
origin + 데이터 구조 확인:         2
origin + source + 데이터 구조 확인: 1

나머지 8건은 정상 위젯에서 보낸 잘못된 데이터였다. null, JSON 문자열, 배열, 다른 버전, 다른 type, 문자열 count, 범위를 넘는 count, 추가 키가 있는 객체를 각각 보냈고 모두 데이터 검사에서 거부됐다. origin과 source가 맞는다는 사실이 메시지 내용까지 계약대로라는 뜻은 아니었다.

일반 HTTP·HTTPS에서 origin은 scheme·호스트·포트를 기준으로 구별한다. 이번 두 주소도 포트가 달라 다른 origin이다. 반대로 같은 origin을 공유하는 문서는 여럿일 수 있다. 이것이 특정 iframe을 고를 때 source 비교가 더 필요한 이유다. HTML 표준의 origin 정의 (새 탭에서 열림)

수신부에서 세 조건을 따로 확인한다

다음은 부모 페이지의 수신부 예다. 페이지에 <iframe id="widget"> 요소가 있어야 한다. 새 위젯을 열 때는 이 리스너를 등록한 다음 iframe의 src를 지정한다. widgetOrigin에는 서비스가 승인한 위젯 origin을 고정한다. 아래 예시 주소를 자신의 허용 주소로 바꾸되, 수신 데이터가 주장하는 주소를 비교 기준으로 쓰면 안 된다. processed는 실제 작업을 대신하는 관찰용 카운터다.

const widget = document.getElementById("widget");
const widgetOrigin = "https://widgets.example.invalid";
let processed = 0;

function isWidgetCount(data) {
  return data !== null && typeof data === "object" &&
    !Array.isArray(data) &&
    Object.keys(data).length === 3 &&
    data.type === "widget-count" && data.v === 1 &&
    Number.isSafeInteger(data.count) &&
    data.count >= 1 && data.count <= 5;
}

window.addEventListener("message", event => {
  if (event.origin !== widgetOrigin) return;
  if (event.source !== widget.contentWindow) return;
  if (!isWidgetCount(event.data)) return;
  processed += 1;
});

origin은 전체 값을 정확하게 비교한다. https://widgets.example.invalid.other.invalid처럼 허용 문자열이 앞에 붙은 주소를 통과시키는 prefix 비교는 쓰지 않는다. event.origin에는 경로가 없으므로 위젯 페이지의 /widget 경로까지 넣어 비교하는 것도 맞지 않는다.

source 비교는 데이터에 담긴 frameId나 sender 이름을 믿는 방식과 다르다. 브라우저가 이벤트에 넣은 발신 창 참조를, 부모가 보관한 iframe의 contentWindow와 비교한다. 이 실험에서 같은 origin의 두 iframe을 가른 조건이 이것이었다. 표준의 메시지 전달 절차도 발신 측 origin과 WindowProxy를 이벤트의 origin·source로 설정한다. HTML 표준의 postMessage 전달 절차 (새 탭에서 열림)

위 데이터 검사는 이 글에서 정한 작은 계약이다. 추가 키를 허용할지, count 범위를 얼마로 정할지는 실제 연동 계약에 맞춰 바꾼다. 다만 "1"을 임의로 숫자로 바꾸거나 JSON 문자열을 객체처럼 취급하면 송수신 측 계약 차이가 감춰질 수 있다. WHATWG도 origin 검사 후 수신 데이터의 예상 형식을 확인하도록 요구한다. postMessage 보안 지침 (새 탭에서 열림)

보내는 쪽의 targetOrigin도 수신 대상을 제한한다

수신부의 origin 검사는 보낸 쪽을 확인한다. 발신 호출의 targetOrigin은 받을 창의 origin을 제한한다. 위젯이 부모에게 보낼 때는 부모의 승인된 origin을 지정한다. 이번 로컬 실험에서 정상 입력에 해당하는 호출은 다음과 같다. 이 포트를 다른 환경에서도 그대로 쓰는 것은 아니다.

parent.postMessage(
  { type: "widget-count", v: 1, count: 1 },
  "http://127.0.0.1:6954"
);

부모에서 위젯으로 보내는 방향도 별도로 확인했다. 정확한 위젯 origin인 http://127.0.0.1:6955를 지정하자 위젯이 받은 횟수가 1이 됐다. 같은 위젯 창에 부모 origin인 http://127.0.0.1:6954를 targetOrigin으로 지정한 다음에는 약 359ms 동안 추가 수신 응답이 없었다. 다시 정확한 origin으로 보냈을 때 받은 횟수는 2였다. 중간 메시지가 카운터를 늘리지 않았고, 브라우저 콘솔에는 수신 창의 origin과 targetOrigin이 다르다는 경고가 남았다.

표준상 대상 창의 origin이 targetOrigin과 다르면 메시지 전달을 중단한다. 정상 대상이 정해져 있다면 *로 열어 두지 않고 그 origin을 지정한다. 이 제한은 발신자 검사를 대신하지 않는다. 다른 창도 올바른 targetOrigin을 지정해 부모에게 보낼 수 있기 때문이다. HTML 표준의 대상 origin 검사 (새 탭에서 열림)

정상 메시지까지 막히면 무엇을 볼까

자신의 개발 환경에서는 기존 수신 함수의 거부 조건 앞에 아래 로그를 잠시 넣어 볼 수 있다. 앞의 widget, widgetOrigin, isWidgetCount를 사용하는 코드다. 메시지 본문 전체를 찍지 않고 어느 조건이 맞지 않는지만 본다.

console.log({
  originOk: event.origin === widgetOrigin,
  sourceOk: event.source === widget.contentWindow,
  dataOk: isWidgetCount(event.data)
});

이번 정상 입력은 세 값이 모두 true였다. originOk가 false면 실제 송신 페이지의 scheme·호스트·포트를 허용 설정과 대조한다. originOk는 true인데 sourceOk가 false면 다른 iframe이나 교체 전 iframe 요소를 가리키는지 확인한다. 둘 다 true이고 dataOk만 false면 버전과 필드 타입을 맞춘다. 이벤트 자체가 오지 않는다면 발신 targetOrigin과 수신 리스너 준비 시점을 먼저 본다.

실험 페이지도 처음에는 iframe의 src가 부모 리스너보다 먼저 실행됐다. 부모 수신 스크립트 응답을 150ms 늦추자 세 iframe의 준비 메시지는 먼저 도착했지만 부모의 준비 목록은 비어 있었고, 입력을 보내는 단계로 진행하지 못했다. 부모 리스너 등록 뒤에 src를 지정하도록 바꾸자 같은 지연에서도 세 준비 메시지를 모두 받고 앞의 3·2·1건을 확인했다. 부모가 준비 메시지를 받을 수 있게 만든 뒤, 자식의 수신 준비를 기다려야 한다. 새 iframe이 준비를 알린 다음 부모가 전송하는 순서는 표준의 준비 메시지 권고 (새 탭에서 열림)에도 나온다. 원인을 보지 않고 origin·source 검사를 제거하면 이 실험의 다른 창 메시지도 다시 처리된다.

관찰 환경은 Windows 11, Chrome 155.0.8059.80, Playwright 1.59.0이었다. 서버는 loopback에만 열었고 종료 후 두 포트가 닫힌 것도 확인했다. iframe 이동·교체, sandbox의 불투명 origin, 다른 브라우저, 실제 OAuth·결제 연동은 실행하지 않았다. 같은 origin에서 악성 코드가 실행되는 상황까지 source 비교로 격리할 수 있다는 주장도 아니다. 여기서 확인한 것은 세 발신 창과 정해진 메시지 계약을 구별한 결과이며, 사용자 인증·권한이나 메시지 재전송 방지 전체를 검증한 것은 아니다.

← 전체 글 보기