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

빌드노트

frame-ancestors를 meta에 썼는데 iframe 차단이 안 된다

frame-ancestors는 HTML meta로 전달하면 적용되지 않는다. 두 loopback origin에서 meta·HTTP 헤더·부모 허용 정책을 비교하고, load 이벤트나 준비 메시지의 유무만으로 삽입 차단을 판단하면 틀리는 이유를 확인한다.

HTML의 CSP meta 태그에 frame-ancestors 'none'을 넣었는데도 다른 페이지의 iframe 안에 그대로 보인다면, 삽입되는 자식 HTML 응답의 HTTP 헤더부터 확인한다. frame-ancestors는 meta로 전달하면 적용되지 않는다. 같은 문자열을 Content-Security-Policy 응답 헤더로 보내야 부모 제한이 작동한다.

이는 태그를 늦게 읽어서 생기는 문제로 설명할 수 없다. W3C CSP Level 3의 frame-ancestors 절 (새 탭에서 열림)은 meta에 들어 있는 이 지시어를 무시하도록 정한다. head의 맨 앞으로 옮기는 것으로 해결되지 않는다. 여기서 확인할 것은 정책의 내용과 함께 어느 문서가 어떤 방식으로 받았는가다.

같은 'none'을 meta와 헤더로 나눠 보냈다

실제 서비스 대신 loopback에 합성 부모와 자식 페이지를 만들었다. 부모는 http://127.0.0.1:18740, 자식은 http://127.0.0.1:18741이었다. 호스트가 같아도 포트가 달라 서로 다른 origin이다. 부모가 자식을 iframe으로 열도록 하고, 조건마다 새 브라우저 컨텍스트를 사용했다.

자식에는 정적 문구 CHILD CONTENT와 부모에게 child-ready 메시지를 보내는 짧은 스크립트를 넣었다. meta 조건에서 사용한 HTML의 핵심 부분이다. 주소는 이번 실험의 부모 origin이다.

<head>
  <meta http-equiv="Content-Security-Policy" content="frame-ancestors 'none'">
</head>
<body>
  <p id="marker">CHILD CONTENT</p>
  <script>
    parent.postMessage({type: 'child-ready'}, 'http://127.0.0.1:18740');
  </script>
</body>

부모는 메시지 리스너를 먼저 등록하고 iframe을 열었다. 예상한 자식 origin과 창에서 온 준비 메시지만 세었다. 본문 확인은 부모 스크립트가 다른 origin의 DOM을 읽는 방식이 아니라 브라우저 자동화로 자식 프레임을 직접 관찰했다.

meta 조건에서는 CSP 응답 헤더를 보내지 않았다. 헤더 조건에서는 위 meta 태그를 빼고 자식 응답에 정책을 보냈다. 브라우저가 받은 헤더와 프레임 본문, 메시지 수, 콘솔을 함께 수집한 결과다.

자식의 정책 전달 방식                  CHILD CONTENT  준비 메시지  iframe load
meta: frame-ancestors 'none'            보임           1회          1회
헤더: frame-ancestors 'none'            없음           0회          1회
헤더: frame-ancestors 부모 origin       보임           1회          1회
헤더: frame-ancestors 'self'            없음           0회          1회

meta 조건의 콘솔에는 다음 문장이 남았다.

The Content Security Policy directive 'frame-ancestors' is ignored when delivered via a <meta> element.

반면 헤더의 'none' 조건에서는 콘솔에 frame-ancestors 'none' 위반과 차단이 표시됐고, 자식 문구 대신 브라우저 오류 문서가 들어갔다. 서버가 보낸 자식 응답은 이 경우에도 200이었다. 브라우저 네트워크 기록에서도 해당 응답의 200과 CSP 헤더를 확인했다. HTTP 응답을 받았다는 사실과 iframe 안에서 그 문서를 표시할 수 있다는 판단은 다르다.

허용한 부모 조건에서 자식이 실제 받은 응답 헤더의 필요한 부분은 다음과 같다.

Content-Security-Policy: frame-ancestors http://127.0.0.1:18740

여기에 적는 주소는 자식의 주소가 아니라 자식을 삽입할 부모의 origin이다. 'self' 조건은 자식 자신의 origin을 기준으로 하므로, 포트가 다른 이번 부모는 허용되지 않았다. MDN의 frame-ancestors 설명 (새 탭에서 열림)에서도 이 지시어의 대상을 문서를 삽입할 부모로 설명한다.

네 조건 모두 load는 1회였다. 따라서 iframe.onload가 호출됐다는 이유로 표시 성공을 기록하면 차단된 조건도 성공이 된다. MDN의 iframe 이벤트 설명 (새 탭에서 열림) 역시 내용 로드에 실패해도 load 이벤트가 발생한다고 설명한다.

자식의 frame-src와 default-src는 부모 제한을 대신하지 않았다

자식 응답에 frame-src 'none'만 보낸 추가 조건에서는 CHILD CONTENT가 보였고 준비 메시지도 1회 왔다. 이 지시어는 그 문서가 안쪽에 로드할 프레임의 출처를 제한한다. 자식 문서 자체를 누가 바깥에서 삽입할 수 있는지는 frame-ancestors의 대상이다. MDN의 두 지시어 비교 (새 탭에서 열림)

방향을 바꿔 부모 응답에 frame-src 'none'을 넣자, 자식이 부모를 허용하는 헤더를 보내도록 설정돼 있어도 프레임이 차단됐다. 콘솔의 위반 지시어는 이번에는 frame-src였고 브라우저의 자식 응답 기록도 없었다. 부모의 로드 제한과 자식의 삽입 허용은 각각 통과해야 한다.

자식 응답에 default-src 'none'만 보낸 조건도 구별할 필요가 있었다. 자식 문구는 보였지만 준비 메시지는 오지 않았다. 콘솔은 frame-ancestors가 아니라 inline script 실행 차단을 가리켰다. 이 조건에는 script-src가 따로 없어서 default-src가 스크립트 제한에 사용됐고, 메시지를 보내는 코드가 실행되지 않았다.

default-src가 엄격하다고 부모 제한까지 생기지는 않는다. frame-ancestors에는 default-src 대체 적용이 없다는 것이 명세에 명시된 규칙 (새 탭에서 열림)이다. 이 관찰 때문에 준비 메시지가 없다는 사실만으로도 삽입 차단을 단정할 수 없었다. 프레임의 실제 내용과 콘솔이 가리키는 지시어를 함께 봐야 했다.

내 페이지에서는 자식 문서의 응답을 고른다

자신이 관리하는 개발용 페이지에서 확인할 때는 개발자 도구의 Network를 연 상태로 부모 페이지를 다시 연다. iframe의 src가 요청한 자식 HTML 문서를 고르고 Response Headers에서 Content-Security-Policy를 읽는다. 부모 HTML이나 JS 파일의 헤더를 읽고 자식의 보호 정책이라고 판단하지 않는다. 리다이렉트가 있다면 최종 자식 문서 응답까지 확인한다.

  • Response의 HTML에만 frame-ancestors가 있고 응답 헤더에는 없다면, 자식 응답을 만드는 서버나 프록시에서 HTTP CSP 헤더로 전달하도록 수정한다. 이 지시어를 meta로 옮겨서는 부모 제한이 생기지 않는다.
  • 삽입을 모두 막으려면 자식 응답의 정책에 frame-ancestors 'none'을 둔다. 필요한 부모가 있다면 승인한 부모 origin을 명시한다. 위 loopback 주소는 실험값이므로 실제 허용 주소로 바꾼다.
  • 헤더가 있어도 이름이 Content-Security-Policy-Report-Only라면 차단 정책이 아니다. 이는 적용 결과를 관찰하기 위한 헤더다. CSP의 정책 전달 규칙 (새 탭에서 열림)
  • 허용한 부모에서 계속 막히면 Console의 위반 지시어와 부모·자식 양쪽의 응답 정책을 대조한다. frame-src 오류라면 부모가 그 자식을 로드할 수 있는지부터 확인한다.

위 헤더 예시는 frame-ancestors 부분만 표시한 것이다. 기존 CSP의 다른 지시어를 유지하면서 자식 응답에 필요한 부모 제한을 구성한다. CSP 헤더가 여러 개면 각각 적용되므로, 다른 헤더가 여전히 금지하는 부모를 새 허용 헤더 하나로 열 수는 없다. 이 동작은 명세의 여러 정책 적용 설명 (새 탭에서 열림)에 따른 것이며, 이번 실험에서는 단일 CSP 헤더만 비교했다.

직접 실행한 환경은 Windows 11, Python 3.12.10, Playwright 1.59.0, headless Chromium 147.0.7727.15였다. 각 조건에서 부모 페이지의 load 뒤 1초 동안 메시지를 수집하고 프레임 문구와 콘솔을 확인했다. 서버는 두 loopback 주소에만 열었으며 종료 후 포트가 닫힌 것도 확인했다.

실험은 한 단계 iframe에서 정책 전달 방식과 적용 방향을 비교한 것이다. 중첩 프레임은 실행하지 않았다. 문서상 frame-ancestors는 바로 위 부모뿐 아니라 각 조상을 검사하므로, 중첩 구조라면 모든 조상이 허용 범위에 들어가는지도 확인해야 한다. MDN의 조상 검사 설명 (새 탭에서 열림) 다른 브라우저, 실제 로그인 화면, 운영 프록시·캐시, X-Frame-Options와의 조합은 검증하지 않았다. 이 결과를 전체 클릭재킹 방어 검증으로 확대하지 않는다.

← 전체 글 보기