웹 애플리케이션의 외부 서비스 연동 보안 강화: postMessage 데이터 검증 사례

커버 이미지

개발을 하다 보면 대부분 잘 되는데, 가끔씩 터지는 문제가 가장 골치 아픕니다. 외부 서비스 연동 과정에서 그런 문제를 겪었고, 원인을 추적해 postMessage 수신 데이터 검증을 강화하면서 안정성을 끌어올린 과정을 정리합니다.

우리 사이트는 외부 서비스 연동을 위해 팝업 기반 인증 플로우를 제공합니다. 사용자가 팝업에서 인증을 마치면, 팝업은 콜백 페이지로 이동하고 postMessage로 인증 정보를 부모 창에 전달합니다.

평소에는 문제없이 동작했지만, 간헐적으로 인증 정보가 불완전한 상태로 넘어와 API 호출이 실패하는 일이 생겼습니다. 처음에는 네트워크나 타이밍 문제를 의심했는데, 로그를 쌓아보니 일부 브라우저 확장 프로그램이 postMessage를 반복 전송하거나 비정상 데이터를 끼워 넣어 플로우를 흔들고 있었습니다.

problem

가끔만 깨지는 원인이 외부에서 주입되는 메시지였던 셈입니다.

외부 서비스 연동과 postMessage의 이해

이번 이슈의 핵심은 외부 서비스 연동 플로우와 그 안에서 사용하는 postMessage였습니다. 먼저 두 개념을 간단히 짚고, 어떤 부분이 취약했는지와 어떻게 보완했는지를 이어서 설명하겠습니다.

window.postMessage() API: 창과 창 사이의 안전한 대화

웹에서 window.postMessage()는 다른 출처(cross-origin)를 포함한 '다른 창'으로 데이터를 전달할 수 있게 해주는 표준 API입니다. 팝업에서 인증을 마친 뒤 결과를 부모 창으로 전달해야 하는 시나리오에서 사실상 필수에 가깝습니다. 보안상의 이유로 서로 다른 출처의 창은 직접 접근할 수 없는데, postMessage는 그 제약을 유지한 채로 안전하게 데이터를 주고받는 통로를 제공합니다.

메시지는 MessageEvent로 전달되고, 수신 측에서는 window.addEventListener('message', handler)로 이벤트를 받습니다. 이때 기본 중의 기본은 event.origin 검증입니다. 출처를 확인하지 않으면, 악성 스크립트가 위장된 postMessage를 보내 인증 플로우를 교란할 수 있습니다. 특히 토큰처럼 민감한 정보가 오가는 경우에는 "일단 받자"가 아니라 "검증하고 받자"가 원칙입니다.

인증 플로우 (Authentication Flow): 외부 서비스와 안전하게 연결하기

인증 플로우(Authentication Flow)는 사용자가 신원을 증명하고, 애플리케이션이 외부 서비스에 접근할 수 있도록 권한을 위임받는 일련의 절차를 말합니다. 요즘은 외부 인증 서비스(예: OAuth)를 통해 로그인/연동을 제공하는 경우가 많고, 이때 보통 OAuth 2.0 또는 OpenID Connect를 사용합니다.

  • OAuth 2.0은 사용자의 비밀번호를 직접 공유하지 않고도 특정 리소스에 대한 접근 권한을 안전하게 위임할 수 있도록 돕는 프레임워크입니다. 예를 들어, 애플리케이션이 사용자의 동의를 얻어 외부 서비스의 프로필 정보에 접근할 수 있도록 하는 방식입니다.
  • OpenID Connect는 OAuth 2.0 위에 구축된 인증 계층으로, 사용자의 신원을 확인하고 기본적인 프로필 정보를 얻을 수 있게 해줍니다. 즉, '로그인' 기능 자체를 구현할 때 더 적합합니다.

외부 연동에서는 팝업에서 인증을 마친 뒤 postMessage로 토큰(access_token), 토큰 타입(token_type), 만료 시간(expires_in), 상태 값(state) 같은 값을 부모 창으로 전달합니다. 여기서 state는 요청/응답의 연관성을 유지하고 CSRF를 막는 데 쓰이기 때문에, 빠지면 안 되는 값입니다.

다만 postMessage는 '외부에서 들어오는 데이터'를 받는 구조라서, 예상 못 한 입력이 들어올 가능성을 늘 열어둬야 합니다. 이번처럼 브라우저 확장 프로그램 같은 변수가 끼면, 메시지가 반복 전송되거나 형식이 깨진 데이터가 유입될 수 있고요. 그래서 수신 단계에서부터 "신뢰하지 말고 검증한다"는 전제를 깔아야 합니다.

Before: 취약했던 postMessage 데이터 검증 로직

문제의 시작은 postMessage로 넘어온 인증 데이터를 처리하는 로직이 지나치게 낙관적이었다는 점입니다. 인증 콜백 컴포넌트의 메시지 핸들러는 postMessage 이벤트를 받고 곧바로 인증 API를 호출했는데, 변경 전 코드는 아래와 같았습니다.

async function handleAuthMessage(message: any) {
  // 개발 환경에서 디버깅 목적으로만 사용 (프로덕션 환경에서는 민감 정보 로깅 주의)
  // console.log('Received message:', message);
 
  if (!message.data.access_token) {
    // 필수 필드가 없는 경우 로직 중단
    console.warn(
      "Authentication callback: Missing access_token in message data."
    );
    return;
  }
 
  const res = await processAuthenticationApi(message.data);
  const { $notification } = useAppContext();
  $notification.show({
    title: "연동 완료",
    message: "외부 서비스 연동이 성공적으로 완료되었습니다.",
    type: "success",
  });
  // window.close();
}

핵심은 message.data.access_token만 있으면 통과시킨다는 점입니다. 토큰이 "있는 것처럼 보이기만" 하면, 추가 검증 없이 processAuthenticationApi를 호출해 백엔드로 요청을 보냈습니다.

하지만 access_token이 있다고 해서 다른 필드까지 정상이라는 보장은 없습니다. token_type, expires_in, state가 누락되거나 형식이 깨질 수 있고, 실제로 확장 프로그램이 반복적으로 메시지를 보내면서 access_token만 임의로 채운 "불완전한 데이터"가 들어오는 케이스가 있었습니다. 그 결과 불필요한 API 호출이 늘고, 백엔드에서는 유효하지 않은 요청을 처리하다 오류를 내기도 했습니다. 한마디로, 검증이 너무 얕았습니다.

After: postMessage 수신 데이터 검증 로직 강화

그래서 메시지 핸들러에서 postMessage 수신 데이터 검증을 강화했습니다. access_token 하나만 보는 대신, 인증 처리에 필요한 필드가 모두 들어왔는지부터 확인하고, 하나라도 빠져 있으면 바로 종료하도록 바꿨습니다.

async function handleAuthMessage(message: any) {
  // 개발 환경에서 디버깅 목적으로만 사용 (프로덕션 환경에서는 민감 정보 로깅 주의)
  // console.log('Received message:', message);
 
  // 1. 필수 필드 존재 여부 및 타입 검증 강화
  if (
    !message.data ||
    typeof message.data !== "object" ||
    !message.data.token_type ||
    !message.data.access_token ||
    !message.data.expires_in ||
    !message.data.state
  ) {
    console.warn(
      "Authentication callback: Missing or invalid required authentication data."
    );
    // TODO: 프로덕션 환경에서는 사용자에게 의미 있는 오류 메시지를 제공하거나 로깅 시스템에 기록합니다.
    return;
  }
 
  // 2. 추가적인 데이터 형식 및 내용 유효성 검증 (예: 토큰 형식, 만료 시간 유효성 등)
  // if (!isValidTokenFormat(message.data.access_token) || !isValidExpiry(message.data.expires_in)) {
  //   console.warn('PostMessage: Authentication data format or value is invalid.');
  //   return;
  // }
 
  const res = await processAuthenticationApi(message.data);
  const { $notification } = useAppContext();
  $notification.show({
    title: "연동 완료",
    message: "외부 서비스 연동이 성공적으로 완료되었습니다.",
    type: "success",
  });
  // window.close();
}

조건이 길어지긴 했지만 의도는 단순합니다. processAuthenticationApi를 호출하기 전에 message.data가 객체인지 확인하고, token_type, access_token, expires_in, state가 모두 존재하는지 점검합니다. 하나라도 누락되면 논리합(||) 조건에서 걸려 바로 리턴됩니다.

  • !message.data || typeof message.data !== 'object': message.data 자체가 존재하지 않거나, 예상과 달리 객체가 아닌 경우를 방지합니다.
  • !message.data.token_type: 인증 토큰의 타입을 나타내는 필드가 없는지 확인합니다. Bearer와 같은 토큰 타입은 백엔드에서 토큰을 해석하는 데 중요합니다.
  • !message.data.access_token: 핵심 인증 토큰이 없는지 확인합니다. 이는 기존 로직에서도 검증했으나, 다른 필드들과 함께 검증함으로써 더 견고해졌습니다.
  • !message.data.expires_in: 토큰의 만료 시간을 나타내는 필드가 없는지 확인합니다. 만료 시간이 없으면 토큰의 유효성을 판단하기 어렵습니다.
  • !message.data.state: CSRF 방지 등에 사용되는 state 파라미터가 없는지 확인합니다. 이 필드는 인증 플로우의 보안을 위해 필수적입니다.

이렇게 바꾸고 나니, postMessage로 들어온 데이터가 조금이라도 덜 온전하면 processAuthenticationApi 호출 자체가 발생하지 않습니다. 확장 프로그램 등 외부 요인으로 들어오는 불완전한 메시지가 더 이상 인증 플로우를 흔들지 못하게 된 것이고, 결과적으로 연동 과정의 안정성이 눈에 띄게 좋아졌습니다. 단순한 버그 수정이라기보다, 수신 단계에서의 방어선을 한 겹 더 두른 개선에 가까웠습니다.

postMessage 이벤트 리스너와 보안 강화

메시지 핸들러는 window.addEventListenerpostMessage 이벤트에 연결됩니다. 이때 event.origin 검증까지 함께 넣어두면, 예상한 출처에서 온 메시지만 처리할 수 있어 보안이 한층 단단해집니다. 아래 예시는 일반적인 postMessage 패턴에 origin 검증과 최소한의 구조 검증을 더한 형태입니다.

// 인증 콜백 컴포넌트 파일 내 스크립트 섹션
import { onMounted, onUnmounted } from "vue";
import { useAppContext } from "@/composables/app";
 
// handleAuthMessage 함수 (위에서 개선된 After 버전)
async function handleAuthMessage(messageData: any) {
  // 개발 환경에서 디버깅 목적으로만 사용 (프로덕션 환경에서는 민감 정보 로깅 주의)
  // console.log('Received message data:', messageData);
 
  // 1. 필수 필드 존재 여부 및 타입 검증
  if (
    !messageData ||
    typeof messageData !== "object" ||
    !messageData.token_type ||
    !messageData.access_token ||
    !messageData.expires_in ||
    !messageData.state
  ) {
    console.warn(
      "PostMessage: Missing or invalid required authentication data.",
    );
    // TODO: 프로덕션 환경에서는 사용자에게 의미 있는 오류 메시지를 제공하거나 로깅 시스템에 기록합니다.
    return;
  }
 
  // 2. 추가적인 데이터 형식 및 내용 유효성 검증 (예: 토큰 형식, 만료 시간 유효성 등)
  // if (!isValidTokenFormat(messageData.access_token) || !isValidExpiry(messageData.expires_in)) {
  //   console.warn('PostMessage: Authentication data format or value is invalid.');
  //   return;
  // }
 
  try {
    const res = await processAuthenticationApi(messageData);
    const { $notification } = useAppContext();
    $notification.show({
      title: "연동 완료",
      message: "외부 서비스 연동이 성공적으로 완료되었습니다.",
      type: "success",
    });
    // window.close(); // 팝업 창을 닫는 로직은 필요에 따라 추가
  } catch (error) {
    console.error("Failed to process authentication:", error);
    const { $notification } = useAppContext();
    $notification.show({
      title: "연동 실패",
      message: "외부 서비스 연동에 실패했습니다. 다시 시도해주세요.",
      type: "error",
    });
  }
}
 
// postMessage 이벤트 리스너
function receiveMessage(event: MessageEvent) {
  // 핵심 보안 검증: 메시지의 출처(origin) 확인
  // 중요: 'https://auth-provider.example.com' 부분을 실제 외부 서비스의 도메인으로 <strong>반드시</strong> 변경해야 합니다.
  const ALLOWED_ORIGIN = "https://auth-provider.example.com";
  if (event.origin !== ALLOWED_ORIGIN) {
    console.warn(
      `Unauthorized message origin: ${event.origin}. Expected: ${ALLOWED_ORIGIN}`,
    );
    return;
  }
 
  // 데이터 구조가 예상과 다른 경우를 대비한 추가적인 검증
  // handleAuthMessage에서 더 상세한 검증을 수행하므로, 여기서는 최소한의 필수 필드만 확인합니다.
  if (
    !event.data ||
    typeof event.data !== "object" ||
    !event.data.access_token
  ) {
    console.warn(
      "PostMessage: Received invalid data format, or missing critical fields.",
      event.data,
    );
    return;
  }
 
  // 유효성 검증을 통과한 메시지만 handleAuthMessage로 전달
  handleAuthMessage(event.data);
}
 
// 컴포넌트 마운트 시 리스너 등록, 언마운트 시 해제
onMounted(() => {
  window.addEventListener("message", receiveMessage, false);
});
 
onUnmounted(() => {
  window.removeEventListener("message", receiveMessage, false);
});
 
// processAuthenticationApi 함수는 백엔드와 통신하는 API 호출 함수라고 가정
async function processAuthenticationApi(data: any) {
  // 중요: 백엔드에서는 전달받은 인증 데이터를 반드시 서버에서 재검증해야 합니다.
  //   - 토큰의 유효성 (만료, 변조 여부)
  //   - 사용자의 실제 요청과 일치하는지 (예: CSRF 토큰 확인)
  //   - 데이터의 무결성 (서명 검증)
  // 실제 API 호출 로직 (예: apiClient.post('/api/auth/link', data))
  // 개발 환경에서만 로깅: 프로덕션에서는 민감 정보를 로깅하지 않도록 주의
  // console.log('Calling processAuthenticationApi with data:', data);
  return new Promise((resolve) =>
    setTimeout(() => resolve({ success: true }), 1000),
  ); // 모의 응답
}

위 코드는 postMessage 이벤트 리스너의 전체 흐름을 보여줍니다. receiveMessagewindow.addEventListener('message', ...)로 등록돼 모든 메시지를 받지만, 가장 먼저 event.origin을 검사해 허용된 출처만 통과시키도록 했습니다. ALLOWED_ORIGIN과 다른 출처에서 온 메시지는 즉시 무시하고 경고만 남기므로, 위장된 메시지로 인한 공격 가능성을 줄일 수 있습니다.

출처 검증을 통과한 뒤에는 event.data의 형태를 한 번 더 확인하고, 최소한의 필드(access_token)가 없으면 걸러냅니다. 그 다음에야 handleAuthMessage로 넘겨 더 촘촘한 검증을 진행합니다. 또한 Vue 생명주기에 맞춰 onMounted에서 등록하고 onUnmounted에서 해제해, 불필요한 리스너가 남지 않도록 했습니다.

오류 발생 빈도와 확장 프로그램별 분석

검증 로직을 강화하기 전, 로그를 약 2주간 수집해 불완전한 postMessage 유입 패턴을 분석했습니다. 전체 인증 시도 대비 비정상 메시지는 4~6% 정도를 차지하고 있었는데, 흥미로운 건 원인이 한두 가지로 뚜렷하게 갈렸다는 점입니다.

가장 많은 비중을 차지한 건 비밀번호 관리 확장 프로그램이었습니다. 전체 비정상 메시지의 절반 가까이(2~3%)가 여기서 나왔는데, 이 확장들은 로그인 폼을 감지하면 자체적으로 postMessage를 보내는 동작이 있었습니다. 인증 팝업의 콜백 페이지를 로그인 폼으로 오인하면서, access_token만 들어 있는 불완전한 메시지를 반복적으로 쏘고 있었던 겁니다.

그다음으로는 광고 차단 확장이 1~2%를 차지했습니다. 이쪽은 팝업 콜백 페이지의 스크립트 실행 자체에 간섭해서, 정상적인 메시지인데 state 값이 누락된 채로 전달되는 패턴이었습니다. 나머지 0.5~1%는 보안이나 VPN 확장 등에서 발생했는데, message.data가 문자열로 변환되거나 아예 빈 객체로 들어오는 등 형태가 제각각이었습니다.

검증을 강화한 뒤에는 이런 비정상 메시지가 handleAuthMessage에 도달하기 전에 전부 걸러졌고, 불필요한 API 호출이 95% 넘게 줄었습니다.

적용 결과 및 얻은 교훈

이번 작업은 "에러가 났으니 고쳤다"에 그치지 않고, 외부 입력을 다루는 지점의 가정을 다시 세우는 과정이었습니다. 변경 이후에는 확장 프로그램 등으로 인해 불완전한 메시지가 들어와 인증 오류로 이어지는 케이스를 더 이상 확인하지 못했습니다. 사용자 입장에서는 연동이 더 안정적으로 느껴지고, 운영 측면에서는 불필요한 실패/재시도를 줄일 수 있었습니다.

정리하면, 이번 이슈에서 얻은 교훈은 아래 세 가지였습니다.

  1. postMessage 통신 시 잠재적 위험에 대한 인지: postMessage는 강력한 통신 도구이지만, 외부에서 오는 데이터는 언제든 변조되거나 불완전할 수 있다는 점을 항상 경계해야 합니다.
  2. 데이터 검증의 중요성: 외부로부터 전달받는 모든 데이터는 그 유효성을 철저히 검증해야 합니다. 단순히 특정 필드의 존재 여부만으로 판단하는 것은 위험하며, 핵심적인 모든 필드가 유효한 상태로 전달되었는지 확인하는 것이 필수적입니다.
  3. 안정성 향상을 위한 구조 개선: 기능 구현만큼이나 예외 상황과 보안 취약점을 고려한 데이터 검증 로직 추가는 애플리케이션의 전반적인 안정성을 높이는 데 결정적인 역할을 합니다. 이러한 선제적인 구조 개선은 장기적으로 애플리케이션의 신뢰도를 높이고 유지보수 비용을 절감하는 효과를 가져옵니다.

외부 입력이 들어오는 구간의 검증과 방어 로직은 기능 구현만큼 중요한 영역입니다.

참고 자료