X-Frame-Options와 CSP frame-ancestors: 클릭재킹 방어 헤더, 둘 다 보내면 어떻게 될까

iframe 삽입을 막는 두 헤더의 차이를 CSP Level 3과 HTML 표준으로 정리했습니다. 둘을 함께 보낼 때의 우선순위, ALLOW-FROM이 무시되는 이유, meta 태그로는 안 되는 이유를 다룹니다.

개발웹보안

다른 사이트가 내 페이지를 <iframe>에 넣고 그 위에 투명한 버튼을 겹쳐 두면, 사용자는 자기가 무엇을 누르는지 모른 채 내 페이지의 버튼을 누르게 됩니다. 이런 공격을 클릭재킹, 명세 용어로는 UI 리드레싱(UI redressing)이라고 부릅니다. 막는 방법은 브라우저에게 “이 응답은 다른 페이지 안에 표시하지 말라”고 응답 헤더로 알려 주는 것이고, 여기에 쓰이는 헤더가 X-Frame-Options와 CSP의 frame-ancestors 지시어입니다.

X-Frame-Options: 값은 두 개뿐

X-Frame-Options는 2013년 RFC 7034(Informational)로 정리됐지만, 지금 처리 규칙은 WHATWG HTML 표준 7.7절에 있습니다. HTML 표준은 자기 정의가 RFC 7034를 대체한다고 적고 있습니다.

유효한 값은 DENY와 SAMEORIGIN 두 개입니다.

  • DENY: 어떤 페이지 안에도 표시하지 않습니다.
  • SAMEORIGIN: 이 문서를 감싼 문서를 바깥쪽으로 하나씩 올라가며 모두 확인하고, 하나라도 출처(origin)가 다르면 막습니다.

RFC 7034에 있던 ALLOW-FROM https://example.com은 HTML 표준에서 “구현하지 않는다”고 못 박았습니다. 이 값 하나만 보내면 잘못된 값으로 취급되고, 헤더가 없는 것과 똑같이 처리됩니다. 즉 이 경우 X-Frame-Options는 삽입을 제한하지 않습니다.

값이 여러 개일 때도 규칙이 있습니다. SAMEORIGIN, DENY처럼 여러 값 중 하나라도 deny, sameorigin, allowall이면 삽입을 막고, 전부 잘못된 값이면 헤더가 없는 것으로 봅니다. 헤더를 한 줄에 쉼표로 쓰든 여러 줄로 나눠 보내든 결과는 같습니다.

frame-ancestors: 허용할 곳을 소스 목록으로

CSP Level 3의 frame-ancestors는 frame, iframe, object, embed로 이 리소스를 삽입할 수 있는 URL을 제한합니다. 값은 'none', 'self', 또는 https: 같은 스킴이나 https://partner.example 같은 호스트를 공백으로 나열한 소스 목록입니다.

브라우저는 감싼 문서를 바깥쪽으로 하나씩 올라가며, 각 조상 문서의 출처를 URL로 바꾼 값이 이 소스 목록과 일치하는지 확인합니다. 하나라도 일치하지 않으면 막습니다. 일치 여부는 단순한 출처 비교가 아니라 CSP의 스킴·호스트·포트 매칭 규칙으로 판단합니다.

CSP 명세는 'none'이 DENY와, 'self'가 SAMEORIGIN과 “대략 같다”고 설명합니다. 완전히 같지는 않습니다. 예를 들어 포트가 같거나 각 스킴의 기본 포트라면, 'self'는 같은 호스트의 https 쪽도 일치로 봅니다. X-Frame-Options로는 표현할 수 없는 “우리 사이트와 특정 파트너만 허용”도 이쪽에서는 됩니다.

주의할 점이 두 가지 있습니다.

  • frame-ancestors는 default-src로 대체되지 않습니다. default-src 'none'만으로는 frame-ancestors 제한이 생기지 않습니다. 이 정책으로 삽입을 통제하려면 frame-ancestors를 따로 적어야 합니다.
  • <meta http-equiv="Content-Security-Policy">로 넣은 frame-ancestors는 무시됩니다. X-Frame-Options도 RFC 7034 4절에 따르면 HTTP 응답 헤더로 보내야 하며, meta 태그로 선언하면 무시됩니다.

둘 다 보내면

현재 HTML 표준의 처리 알고리즘은 먼저 CSP를 봅니다. 적용(enforce) 중인 정책에 frame-ancestors가 있으면 X-Frame-Options는 읽지 않고 건너뜁니다. CSP 명세도 하위 호환을 유지하며 도입할 수 있도록 frame-ancestors가 X-Frame-Options보다 우선한다고 적고 있습니다.

반대로 Content-Security-Policy-Report-Only로 보낸 frame-ancestors는 적용 중인 정책이 아닙니다. 이때는 X-Frame-Options가 판정에 쓰입니다. 리포트 전용으로 새 정책을 시험하는 동안에도 기존 헤더의 보호가 유지된다는 뜻입니다.

두 헤더를 함께 보내려면 다음처럼 설정할 수 있습니다.

Content-Security-Policy: frame-ancestors 'self'
X-Frame-Options: SAMEORIGIN

frame-ancestors를 적용하는 브라우저는 첫 줄로 판정하고 둘째 줄을 무시합니다. frame-ancestors를 적용하지 않고 X-Frame-Options를 참고하는 클라이언트에는 둘째 줄이 대신 쓰입니다.

파트너를 허용할 때는 첫 줄에 'self' https://partner.example처럼 소스를 추가합니다. 다만 X-Frame-Options에는 호스트를 지정하는 유효한 값이 없으므로 두 헤더가 같은 허용 목록을 표현할 수는 없습니다. 둘째 줄을 SAMEORIGIN으로 두면, 첫 줄을 적용하는 브라우저에서는 파트너 삽입이 허용되지만 X-Frame-Options로 판정하는 클라이언트에서는 막힙니다.

둘째 줄을 ALLOW-FROM으로 바꾸지는 마세요. X-Frame-Options를 참고하는 경우 이 값은 아무 보호도 하지 않습니다. 첫 줄을 적용하는 브라우저는 계속 frame-ancestors로 판정합니다.

motitory.com도 public/_headers에서 정적 응답 전체에 두 헤더를 함께 설정하고 있습니다.

이 헤더가 하지 않는 일

두 헤더 모두 “이 문서가 다른 문서 안에 들어갈 수 있는가”만 정합니다. 최상위 창으로 열리는 경우에는 판정 자체를 하지 않고, 스크립트나 이미지 로딩처럼 CSP의 다른 지시어가 다루는 영역에는 영향이 없습니다.

헤더는 문서 응답마다 붙어야 합니다. 일부 경로에서 빠지지 않았는지 개발자 도구의 네트워크 탭이나 curl -I로 실제 응답을 확인해야 합니다.

블로그로