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

빌드노트

X-Frame-Options가 DENY인데 iframe에 페이지가 뜨는 이유

DENY보다 enforcing CSP의 frame-ancestors가 우선한다. 두 loopback 출처에서 Report-Only와 부모 거절 조건을 비교해, 요청 수신과 실제 표시를 구분하고 의도한 부모 정책을 고르는 방법을 설명한다.

자식 페이지의 응답에 X-Frame-Options: DENY가 있는데도 iframe 안에 표시된다면, 같은 응답의 Content-Security-Policy를 함께 확인한다. 강제 적용되는 CSP에 frame-ancestors가 있으면 X-Frame-Options는 무시된다. 그 지시어가 현재 부모를 허용한다면 DENY가 함께 있어도 표시될 수 있다. W3C CSP Level 3의 두 헤더 관계 (새 탭에서 열림)에 명시된 규칙이다.

여기서 강제 적용되는 CSP는 Content-Security-Policy 응답 헤더로 전달된 정책을 말한다. 이름이 Content-Security-Policy-Report-Only이면 이 우선 규칙의 조건을 충족하지 않는다. 또 CSP 헤더가 있다는 사실만으로 충분하지 않다. 그 안에 frame-ancestors가 있는지까지 읽어야 한다.

부모가 아니라 자식 HTML의 응답을 읽는다

자신이 관리하는 개발 화면에서 개발자 도구의 Network를 열고 부모 페이지를 다시 연다. iframe의 src에 해당하는 자식 HTML 문서 요청을 골라 Response Headers를 확인한다. 리다이렉트가 있으면 최종 자식 문서 응답을 본다. 부모 HTML이나 이미지 응답의 DENY는 이 판단의 대상이 아니다.

다음은 이번 별도 실험에서 자식이 실제로 받은 두 헤더다. 이 조합에서는 허용된 부모에서 표시되는 것이 관찰됐다.

X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors http://127.0.0.1:21626

이 주소는 자식 주소가 아니라 자식을 삽입할 부모의 origin이다. 실험의 자식은 http://127.0.0.1:21627이었고 부모는 포트가 다른 http://127.0.0.1:21626이었다. 자기 응답과 비교할 때도 부모 주소의 스킴·호스트·포트를 맞춰 읽는다. 실험 주소를 실제 서비스의 허용 목록에 복사할 이유는 없다.

두 헤더를 더 엄격한 값끼리 합치는 방식으로 해석하면 “DENY가 있으니 전부 차단”이라는 잘못된 결론에 이른다. 이 조합의 프레임 허용 여부는 CSP의 frame-ancestors가 결정한다.

헤더 조합만 바꿔 표시와 요청 수신을 비교했다

실제 외부 사이트 대신 두 loopback 서버에 새 부모·자식 페이지를 만들었다. 새 프로필의 headless Chromium 147.0.7727.15에서 같은 부모가 같은 자식 경로를 iframe으로 열도록 했다. 조건마다 쿼리 값으로 사례를 구분하고 새 페이지에서 실행했다. 부모에는 프레임 로드를 막는 CSP나 sandbox를 두지 않았다.

자식에는 CHILD VISIBLE로 시작하는 정적 문구와 실행 확인용 코드를 넣었다. 코드는 두 번의 animation frame 콜백 뒤 부모에게 준비 메시지를 보내고, 자식 서버의 확인 주소를 한 번 요청했다. 부모는 예상한 origin과 자식 창에서 온 메시지만 셌다. 표시 여부는 메시지에만 의존하지 않고 자식 프레임의 문구·가시성과 화면 캡처를 함께 확인했다. 각 조건의 관찰 창은 iframe의 load 뒤 1초였다.

  • DENY만 전송: 자식 문구가 보이지 않았고 준비 메시지와 실행 확인 요청도 없었다. 콘솔은 X-Frame-Options의 deny를 차단 이유로 표시했다.
  • 부모를 허용하는 enforcing frame-ancestors와 DENY: 자식 문구가 보였다. 준비 메시지와 실행 확인 요청이 각각 1회였다. 위 헤더 예시가 이 조건이다.
  • 같은 허용 값을 Report-Only로 전송하고 DENY 유지: 자식 문구가 보이지 않았고 메시지와 실행 확인 요청도 없었다. 콘솔은 이번에도 X-Frame-Options의 deny를 가리켰다.
  • enforcing frame-ancestors를 'self'로 바꾸고 DENY 유지: 자식 문구가 보이지 않았다. 이번 콘솔의 차단 이유는 frame-ancestors 'self' 위반이었다. 자식 자신의 origin을 뜻하는 self에는 포트가 다른 부모가 포함되지 않았다.

측정 장치의 대조군도 두었다. 두 보안 헤더를 모두 빼면 문구가 보이고 메시지·실행 확인 요청이 각각 1회였다. 반면 CSP를 img-src 'self'만 담은 헤더로 바꾸고 DENY를 유지하면 차단됐다. CSP의 존재 자체가 DENY를 무효화하는 것은 아니었다.

뒤에서 설명할 전면 차단 조합까지 합쳐 일곱 조건 모두 자식 문서의 GET은 서버에 1회 도착했다. 서버 응답과 브라우저가 기록한 응답 상태는 모두 200이었다. 차단 조건에서는 자식 문서 대신 브라우저 오류 문서가 프레임에 들어갔다. iframe의 load도 일곱 조건 모두 1회였다. 따라서 서버 접근 로그의 200이나 load 이벤트만 보고 “프레임 차단 실패”라고 판단할 수 없다.

Report-Only 조건이 허용 조건처럼 동작하지 않은 이유도 이 비교에서 분명했다. CSP의 정책 전달 규칙 (새 탭에서 열림)에서 Report-Only는 정책을 강제하지 않고 관찰하는 헤더다. 이 실험에서는 별도 보고 수신 주소를 설정하지 않았으며, 보고 전송 성공 여부는 측정하지 않았다.

모두 차단할지, 지정한 부모를 허용할지 먼저 정한다

어느 부모에서도 삽입되면 안 되는 문서라면 기존 enforcing CSP의 frame-ancestors 값을 'none'으로 맞춘다. MDN의 frame-ancestors 문서 (새 탭에서 열림)는 이 값의 의미를 삽입 금지로 설명한다. X-Frame-Options를 함께 유지할 때도 아래처럼 차단 의도가 일치해야 한다. 다음은 최종 자식 응답에서 확인할 정책 부분이다.

Content-Security-Policy: frame-ancestors 'none'
X-Frame-Options: DENY

이 조합도 같은 부모에서 추가로 실행했다. 문구가 보이지 않았고 메시지·실행 확인 요청은 0회였다. 콘솔에는 frame-ancestors 'none' 위반이 남았다. 부모를 허용하던 CSP 값을 바꾸자 앞서 표시되던 자식이 차단된 것이다.

실제 수정은 자식 HTML의 CSP를 만드는 서버·프록시 설정에서 한다. 기존 CSP에 스크립트나 연결 제한 등 다른 지시어가 있다면 그 내용은 보존하고 부모 허용 부분을 고친다. 위 두 줄로 전체 CSP를 통째로 교체하라는 뜻은 아니다.

반대로 승인한 외부 부모에서 보여 줘야 한다면 CSP를 기준 정책으로 삼아 그 부모 origin을 명시한다. DENY를 남겼다는 이유로 해당 부모까지 다시 차단된다고 기대해서는 안 된다. X-Frame-Options의 ALLOW-FROM으로 같은 허용 목록을 표현하려는 수정도 피한다. MDN의 X-Frame-Options 문서 (새 탭에서 열림)는 이 값을 폐기된 지시어로 설명하고 frame-ancestors 사용을 안내한다.

X-Frame-Options를 호환성용으로 병행할지는 지원할 브라우저 범위와 함께 정한다. 두 헤더를 서로 다른 허용 범위로 내보내면서 모든 클라이언트가 같은 결과를 보일 것이라고 가정하지 않는다. CSP의 부모 허용과 DENY의 전면 차단 중 어떤 동작을 의도했는지 먼저 결정해야 설정을 바로잡을 수 있다.

수정 뒤에는 개발자 도구를 연 상태에서 다시 로드해 최종 응답 헤더와 실제 자식 문구 표시를 대조한다. 허용할 부모와 거절할 부모 양쪽에서 확인한다. 예상과 다르면 헤더 이름이 Report-Only인지, frame-ancestors가 빠졌는지, 최종 응답의 다른 CSP가 있는지부터 읽는다. 콘솔이 부모의 frame-src를 가리킨다면 이 글의 자식 응답 우선순위와는 별도로 부모의 로드 제한을 확인한다. MDN의 두 지시어 설명 (새 탭에서 열림)

직접 확인한 범위는 Windows 11, Python 3.12.10, Playwright 1.59.0과 위 Chromium 버전의 한 단계 HTTP iframe이다. 새 프로필과 소유한 페이지만 사용했고 보안 설정을 우회하는 옵션은 추가하지 않았다. 완료 후 브라우저와 두 서버를 종료하고 포트가 닫힌 것도 확인했다.

다른 브라우저·구버전·일반 창 모드, HTTPS, 중첩 프레임과 여러 CSP 헤더의 조합, 운영 프록시·캐시는 실행하지 않았다. 중첩 구조에서는 바로 위 부모만이 아니라 모든 조상이 허용돼야 한다는 것이 MDN에 설명된 규칙 (새 탭에서 열림)이며, 이번 실험의 측정 결과는 아니다. 이 글은 두 응답 헤더가 함께 있을 때의 판단을 다루며 클릭재킹 방어 전체를 검증한 결과는 아니다.

← 전체 글 보기