Deallo

안정성 · 호환성

이메일 본문의 외부 링크가 차단되던 문제 — iframe과 frame-ancestors

Figma 알림 메일의 "Dev Mode에서 검사" 버튼을 눌렀더니 이메일 본문이 통째로 회색 차단 화면으로 바뀌었다. console 에러만 보고 한 번 잘못 짚었고, 사용자(나에게 화면을 보여준 동료)의 한 마디로 진단이 뒤집혔다. 오진 한 번, 코드 리뷰에서 두 번 — 도합 세 번 깨지고서야 끝난 문제다.

증상

우리는 이메일 상세 화면에서 받은 메일 본문을 그대로 렌더한다. 그런데 일부 메일에서만 링크 클릭이 깨졌다.

  • Figma 알림 메일의 "Dev Mode에서 검사" 버튼 클릭 → 본문 영역이 회색 화면으로 변하며 "연결을 거부했습니다" 표시
  • Notion·Linear 같은 다른 SaaS 알림 메일도 동일 패턴
  • 반면 일반 뉴스레터·블로그 링크는 멀쩡히 열림

중요한 관찰 하나. 차단 화면이 떠도 주소창 URL은 우리 이메일 상세 페이지 그대로였다. 새 창이 뜬 게 아니라 본문이 있던 그 자리가 차단 화면으로 바뀌었다.

Console 에러:

Refused to display 'https://www.figma.com/' in a frame because it set
'X-Frame-Options' to 'sameorigin'.
 
Framing 'https://www.figma.com/' violates the following Content Security Policy
directive: "frame-ancestors 'self' vscode-webview: vscode-file:".
The request has been blocked.
 
Unsafe attempt to load URL https://www.figma.com/design/... from frame with
URL chrome-error://chromewebdata/.

잘못 짚은 1차 진단 — sandbox를 의심했다

받은 메일 본문은 <iframe srcDoc=...>로 격자 안에 렌더하고 있었다. 그래서 console 에러의 frame-ancestors 위반과 iframe이라는 단어를 보자마자, 나는 iframe의 sandbox 설정이 부족한 거라고 단정했다.

당시 sandbox 값은 이랬다.

sandbox="allow-same-origin allow-popups"

내 머릿속 시나리오는 이랬다. "버튼을 누르면 새 탭이 열리긴 하는데, 그 새 탭이 부모 iframe의 sandbox를 상속받는다. figma.com은 자기 페이지 안에서 또 다른 iframe들을 쓰니까, 상속된 sandbox 제약 때문에 그 내부 iframe들이 frame-ancestors CSP에 막힌다." 그럴듯했고, 해결책도 한 줄이면 끝나 보였다.

sandbox에 allow-popups-to-escape-sandbox만 추가하면 된다.

지금 보면 이 진단은 에러 메시지가 가리키는 단어만 따라간 추측이었다. console은 "frame-ancestors가 막혔다"는 결과를 말해줄 뿐, 그게 새 탭에서 벌어진 일인지 본문 자리에서 벌어진 일인지는 말해주지 않는데, 나는 거기까지 확인하지 않고 새 탭이 열린다고 가정해버렸다.

진단을 뒤집은 한 마디 — "새 탭이 안 뜨는데?"

화면을 같이 보던 동료가 던진 질문이 진단을 통째로 갈아엎었다.

"스크린샷 보면 새 탭이 아니고 이메일 본문 바디가 그냥 저렇게 바뀌는 것 같은데. 새 탭이면 본문은 그대로 있고 창이 따로 떠야 하는 거 아니야?"

맞는 말이었다. 그 관점으로 같은 스크린샷을 다시 보니 전부 다르게 읽혔다.

관찰1차 진단의 가정실제 의미
주소창 URL이 안 바뀜새 탭이 떠서 부모는 그대로애초에 새 탭이 안 열렸다
본문 iframe 안에 차단 화면새 탭 안의 내부 iframe이 막힘iframe 자체가 figma.com으로 navigate됨
frame URL이 chrome-error://chromewebdata/Chrome이 차단 후 보여주는 에러 페이지

진짜 문제는 sandbox가 아니었다. iframe 안의 링크가 새 탭이 아니라 iframe 그 자신을 외부 사이트로 이동시키고 있었던 것이다. figma.com은 X-Frame-Options: sameorigin과 CSP frame-ancestors로 남의 iframe에 박히는 것을 거부하니까, 그 navigation이 차단 화면으로 끝난 것이다.

일반 블로그가 멀쩡했던 건 운이었다. 그쪽은 frame 제약을 안 걸어둬서 우연히 iframe 안에서도 열렸을 뿐, 구조 자체는 똑같이 틀려 있었다.

다시 진단하고 나서 — 왜 한 줄로 안 끝났나

원인을 "링크를 새 탭으로 보내면 된다"로 좁히고 나니, 이번엔 "그럼 anchor에 target="_blank"만 박으면 되겠네"가 두 번째 안일한 가정이었다. 브라우저 AI 패널로 iframe 동작을 실시간 시뮬레이션하면서 가설을 하나씩 깨봤다.

  1. Nested anchor — Figma·Notion 메일은 클릭 추적 때문에 <a><table>...<img></a>처럼 anchor가 테이블·이미지를 통째로 감싼 복잡한 구조다. querySelectorAll('a')로 잡아 target을 박는 방식이 동적으로 끼어드는 anchor나 깊게 중첩된 클릭 영역을 놓칠 수 있었다
  2. <base> 태그 부재 — srcDoc HTML의 head에 base 태그가 없으면, anchor에 명시 target이 없을 때 브라우저가 기본 target을 무엇으로 볼지가 갈렸다
  3. 타이밍onLoad 시점에 JS로 target을 박기 전에 사용자가 먼저 클릭하는 edge case

결론은 JS 한 줄로는 부족하고, 서로 다른 케이스를 각각 막는 다층 방어가 필요하다는 거였다.

#방어선막는 케이스
1head 맨 앞에 <base target="_blank">모든 anchor의 기본 target을 새 탭으로. 중첩·동적 anchor까지 자동 적용
2모든 anchor에 JS로 target="_blank" 박기anchor에 target="_self"가 명시돼 base를 무시하는 케이스 개별 덮어쓰기
3sandbox allow-popups-to-escape-sandbox새 탭이 부모 iframe의 sandbox 제약을 상속하지 않게 해, 외부 사이트가 자기 iframe 구조를 정상 로드

처음에 한 줄이면 끝이라던 allow-popups-to-escape-sandbox는 틀린 게 아니라 셋 중 하나였을 뿐이다. 원인을 sandbox로 오해했던 게 문제지, 그 한 줄 자체는 세 번째 방어선으로 살아남았다.

base 태그에서 두 번 더 깨졌다 — 코드 리뷰

base 태그를 "없으면 추가"가 아니라 "무조건 맨 앞에 prepend"로 짜뒀던 게 화근이었다. 리뷰에서 두 번 지적받았고, 둘 다 내가 안 본 케이스였다.

HTML 스펙상 <base>는 head에 첫 번째 것 하나만 유효하다. 그래서 무조건 우리 base를 맨 앞에 끼우면, 원본 메일에 이미 있던 base를 무력화시킨다.

1차 (봇 리뷰). 인용 메일을 처리하는 다른 경로에서는 if (!querySelector('base'))로, base가 이미 있으면 새로 안 넣고 skip하고 있었다. 원본에 <base target="_self">가 박혀 있으면 그게 살아남아 일부 링크가 _self로 동작했다.

// Before — 있으면 그냥 두고 skip
if (!doc.head.querySelector('base')) {
  // 새 base 추가
}
 
// After — 있으면 target만 덮어쓰기, 없을 때만 새로 생성
const existingBase = doc.head.querySelector('base')
if (existingBase) {
  existingBase.setAttribute('target', '_blank')
} else {
  // 새 base 추가
}

2차 (동료 리뷰). 본문 렌더 경로도 같은 보존 처리가 필요하다는 지적이었다. 원본에 <base href="https://cdn.example.com/"> 같은 게 있으면, 우리 base가 첫 번째가 되면서 원본 base href가 죽고 상대경로 이미지·링크가 깨진다(스펙상 첫 번째 base만 유효하므로).

검증하려고 테스트를 짰더니, 1차 진단 때 "어차피 sanitizer가 base를 지우니 항상 새로 넣어도 된다"고 믿었던 전제가 틀렸다는 것까지 드러났다. DOMPurify의 기본 허용 태그에 <base>가 없어 sanitize 단계에서 제거되긴 하는데, 그 제거 동작에 기대 base href를 살릴 방법이 아예 없던 것이다.

// 원본 <base href="https://cdn.example.com/"> 입력 시
expect(bases[0].getAttribute('href')).toBe('https://cdn.example.com/')
// AssertionError: expected null to be 'https://cdn.example.com/'
//   Received: null  ← sanitize가 base를 통째로 날려버림

그래서 base를 지우는 게 아니라 보존하는 쪽으로 방향을 틀었다. DOMPurify 설정에서 <base> 태그와 href 속성을 허용 목록에 추가하고, 본문·인용 두 경로 모두 "있으면 target만 덮어쓰고 없으면 새로 생성" 패턴으로 통일했다.

const SANITIZE_CONFIG = {
  WHOLE_DOCUMENT: true,
  ADD_TAGS: ['style', 'meta', 'title', 'base'], // +base
  ADD_ATTR: ['href'],                           // +href
  FORBID_TAGS: ['script', 'iframe', 'form', 'input', 'textarea'],
}

<base href="javascript:..."> 같은 공격 벡터가 걸렸는데, DOMPurify의 기본 ALLOWED_URI_REGEXPjavascript:·data: 같은 unsafe scheme을 알아서 차단해서, base를 허용 목록에 넣어도 href URL은 sanitize 단계에서 검증된다는 걸 확인하고 넘어갔다.

세 번 깨지고 남은 것

  • console 에러는 결과의 한 단면이지 사용자가 보는 상태가 아니다. 나는 "frame-ancestors 위반"이라는 메시지를 새 탭에서 벌어진 일로 가정해 오진했다. "본문이 통째로 바뀐다, 새 창이 안 뜬다"는 화면 관찰 한 마디가 가정을 깨고 올바른 진단으로 데려갔다. 막혔을 때 에러 텍스트보다 실제 화면 상태를 먼저 본다
  • 남의 페이지는 내 iframe에 안 들어온다. X-Frame-Options·frame-ancestors는 정상 보안이다 — 외부 링크는 iframe 안이 아니라 새 탭(top-level)에서 열려야 한다. "어떤 링크는 되고 어떤 링크는 안 되는" 차이는 목적지 사이트의 frame 정책 때문이지, 우연히 되던 케이스에 속으면 안 된다
  • "한 줄이면 끝"이라는 직감은 보통 케이스를 덜 본 신호였다. sandbox 한 줄, target 한 줄, base prepend 한 줄 — 세 번 다 "이거면 충분하다"고 했다가 nested anchor·명시 target·원본 base href에서 깨졌다. 각 케이스를 별개로 막는 다층 방어가 결국 답이었다