콘텐츠로 이동
Search

Cloudflare:IframeOnlyRedirect

lastmod 2026-10-28created 2026-10-281 edits

Cloudflare Pages/Workers 로 서브도메인에 배포한 앱을 본 사이트 문서의 iframe 안에서만 동작하게 하는 방법. 주소창으로 직접 열면 본 사이트의 해당 문서로 302 리다이렉트하고, 허용한 사이트만 iframe 으로 넣을 수 있게 한다.

About

  • 예: anitack.unwiki.net 같은 게임을 https://www.unwiki.net/games/anitack/ 문서의 iframe 으로 띄운다.
  • 별도의 대시보드 설정 없이 저장소에 파일만 더해 적용한다. GitHub 연동 Pages 는 커밋하면 그대로 반영된다.
  • 두 가지를 함께 쓴다.
    • 리다이렉트: 요청 헤더 Sec-Fetch-Dest 가 document 일 때만 302.
    • 임베드 제한: 응답 헤더 Content-Security-Policy: frame-ancestors ....

원리

Sec-Fetch-Dest

브라우저는 요청이 어디에 쓰이는지를 Sec-Fetch-Dest 헤더로 보낸다.

접속 방식

Sec-Fetch-Dest

처리

주소창 입력, 링크 클릭, 북마크, 새 탭

document

302 리다이렉트

<iframe src=...>

iframe

그대로 응답

게임이 불러오는 JS, wasm, 이미지, fetch

script, image, empty 등

그대로 응답

헤더를 보내지 않는 옛 브라우저(Safari 16.4 미만), 봇

(없음)

그대로 응답 (fail-open)

  • 값이 정확히 document 일 때만 리다이렉트하므로 리소스 요청과 iframe 은 영향을 받지 않는다.
  • Referer 로 판단하지 않는다. 개인정보 설정에 따라 빠지고, 본 사이트에서 링크를 눌러 들어온 경우와 iframe 을 구분하지 못한다.
  • 같은 주소가 헤더에 따라 다르게 응답하므로 Vary: Sec-Fetch-Dest 를 붙이고, 영구 캐시되는 301 대신 302 를 쓴다.

frame-ancestors

리다이렉트만으로는 다른 사이트의 iframe 을 막지 못한다(Sec-Fetch-Dest: iframe 이므로). 응답에 다음 헤더를 붙인다.

Content-Security-Policy: frame-ancestors 'self' https://unwiki.net https://www.unwiki.net
  • X-Frame-Options 는 허용 출처를 하나만 적을 수 있어 쓰지 않는다.
  • 'self' 는 앱이 자기 출처의 iframe 을 쓰는 경우를 위해 둔다.
  • frame-ancestors 는 <code><meta>

    </code> CSP 로는 동작하지 않는다. 반드시 응답 헤더로 보낸다.

경로와 쿼리 유지 (?path=)

초대·공유 링크(/play?room=…, /join/ABCD?via=qr)가 깨지지 않게 연 주소를 함께 넘긴다.

Cloudflare Pages

functions/_middleware.ts

저장소 루트(빌드 출력 디렉터리가 아님)에 둔다. Pages 가 빌드 뒤 자동으로 컴파일한다.

/** 게임을 품은 위키 문서. */
const GAME_PAGE = 'https://www.unwiki.net/games/anitack/';

/** 이 게임을 iframe 으로 품어도 되는 곳. */
const FRAME_ANCESTORS = "frame-ancestors 'self' https://unwiki.net https://www.unwiki.net";

/** Pages 가 넘기는 문맥 중 쓰는 것만 — @cloudflare/workers-types 없이 둔다. */
interface PagesContext {
  request: Request;
  next: () => Promise<Response>;
}

/** 맨 위 창으로 연 문서 요청이면 위키 주소, 아니면 null. */
export function wikiRedirect(request: Request): string | null {
  if (request.headers.get('Sec-Fetch-Dest') !== 'document') {
    return null;
  }
  const {pathname, search} = new URL(request.url);
  const path = pathname + search;
  return path === '/' ? GAME_PAGE : `${GAME_PAGE}?path=${encodeURIComponent(path)}`;
}

export async function onRequest({request, next}: PagesContext): Promise<Response> {
  const location = wikiRedirect(request);
  if (location !== null) {
    return new Response(null, {
      status: 302,
      headers: {Location: location, Vary: 'Sec-Fetch-Dest'},
    });
  }

  const response = await next();
  // 받은 응답의 헤더는 불변일 수 있다 — 베껴서 고친다.
  const headers = new Headers(response.headers);
  const csp = headers.get('Content-Security-Policy');
  headers.set('Content-Security-Policy', csp ? `${csp}; ${FRAME_ANCESTORS}` : FRAME_ANCESTORS);
  headers.append('Vary', 'Sec-Fetch-Dest');
  return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers,
  });
}

public/_routes.json

없으면 모든 요청(해시 번들, 아이콘 포함)이 Functions 를 거쳐 무료 한도(하루 10만 회)를 쓴다. 정적 파일은 빼고 문서 경로만 함수로 보낸다. Vite 라면 public/ 에 두면 빌드 출력(dist/)으로 복사된다.

{
  "version": 1,
  "include": ["/*"],
  "exclude": ["/assets/*", "/favicon.svg", "/icon-192.png", "/site.webmanifest"]
}
  • SPA 의 깊은 경로(/play, /join/…)도 문서이므로 include 는 /* 로 두고 exclude 로 정적 파일을 뺀다.
  • 규칙은 합쳐 100 개, 각 100 자까지.
  • public/ 에 정적 파일을 새로 두면 exclude 에도 더한다. 빠뜨려도 동작은 하지만 함수 호출이 늘고, 그 파일을 주소창으로 열면 리다이렉트된다.

주의

  • _headers 로 CSP 를 넣지 않는다. Cloudflare 문서상 _headers 는 Pages Functions 가 만든 응답에 적용되지 않는다. CSP 는 미들웨어에서 붙인다. (_headers 는 캐시 규칙 등 정적 파일용으로만 쓴다.)
  • 배포 방식
    • GitHub 연동 자동 빌드, wrangler pages deploy: functions/ 가 자동으로 포함된다.
    • 대시보드의 Direct Upload(끌어다 놓기): Functions 를 지원하지 않는다.
  • functions/ 위치는 Pages 빌드 설정의 Root directory 기준(기본은 저장소 루트).
  • wrangler.json(c) 에 pages_build_output_dir 이 없으면(Workers 설정이면) Pages 빌드는 그 파일을 무시한다. 다만 로컬 wrangler pages dev 는 그 파일을 읽어 결과가 달라질 수 있으니, 그때는 functions/ 와 빌드 결과만 다른 디렉터리에 복사해 확인한다.
  • *.pages.dev 주소와 미리보기 배포(<브랜치>.<프로젝트>.pages.dev)도 똑같이 리다이렉트된다. 미리보기는 curl 로 확인한다.
  • 빌드 출력 디렉터리는 wrangler pages download config<프로젝트> 로 받아 pages_build_output_dir 를 보면 확인할 수 있다.

Cloudflare Workers (Static Assets)

functions/ 는 무시된다. Worker 코드에서 같은 일을 한다.

wrangler.jsonc

기본적으로 정적 자산은 Worker 를 거치지 않으므로, 문서 경로를 run_worker_first 에 더한다. 해시 번들, 아이콘, sw.js 는 넣지 않는다(요청 한도).

{
  "name": "std",
  "main": "src/server/index.ts",
  "assets": {
    "directory": "./dist",
    "binding": "ASSETS",
    "run_worker_first": ["/api/*", "/", "/index.html"]
  }
}

Worker

export default {
  async fetch(req, env) {
    const {pathname} = new URL(req.url);
    if (pathname.startsWith('/api/')) {
      return handleApi(req, env); // 기존 API 는 그대로
    }
    const location = wikiRedirect(req); // Pages 와 같은 함수
    if (location !== null) {
      return new Response(null, {status: 302, headers: {Location: location, Vary: 'Sec-Fetch-Dest'}});
    }
    const res = await env.ASSETS.fetch(req);
    const headers = new Headers(res.headers);
    const csp = headers.get('Content-Security-Policy');
    headers.set('Content-Security-Policy', csp ? `${csp}; ${FRAME_ANCESTORS}` : FRAME_ANCESTORS);
    headers.append('Vary', 'Sec-Fetch-Dest');
    return new Response(res.body, {status: res.status, statusText: res.statusText, headers});
  },
} satisfies ExportedHandler<Env>;
  • env.ASSETS.fetch() 의 응답에는 _headers 규칙(Cache-Control 등)이 그대로 붙어 온다. Worker 가 직접 만든 302 에는 붙지 않는다.
  • 자산에 없는 경로(/nope)도 Worker 로 오므로, 직접 열면 ?path=%2Fnope 로 리다이렉트된다.

서비스 워커(PWA)

  • 네트워크 우선(fetch(event.request))으로 탐색을 처리하는 서비스 워커는 서버의 302 가 그대로 동작한다.
  • Workbox navigateFallback(vite-plugin-pwa 기본)은 다시 온 손님의 탐색을 사전 캐시의 index.html 로 답해 서버에 닿지 않는다. 앱 부트 맨 앞에 가드를 둔다.
// 운영 호스트를 맨 위 창으로 열었으면 위키로 옮기고 부트하지 않는다 (개발 서버·미리보기는 그대로)
if (window.top === window.self && location.hostname === 'texdun.unwiki.net') {
  const path = location.pathname + location.search;
  const page = 'https://www.unwiki.net/games/texdun/';
  location.replace((path === '/' ? page : `${page}?path=${encodeURIComponent(path)}`) + location.hash);
} else {
  boot();
}
  • 설치한 PWA(start_url: "/")는 실행하면 위키로 가므로 사실상 단독 앱으로는 쓸 수 없게 된다.

부모 페이지

iframe 을 넣는 쪽(예: MkDocs 의 overrides/partials/embed.html). ?path= 와 해시를 iframe 주소로 되돌리고, 앱 출처를 벗어나는 값(//evil.com, /\evil.com, https://…)은 버린다. 기본 src 의 쿼리(embed=1, lang=ko 등)는 넘겨받은 주소에 없으면 다시 붙인다.

<iframe id="uw-embed-frame" src="https://sphm.unwiki.net/" title="SPHM"
        allow="fullscreen; autoplay; gamepad; clipboard-write; accelerometer; gyroscope; magnetometer; screen-wake-lock; camera; web-share"
        allowfullscreen loading="eager"></iframe>
<script>
  (function () {
    var frame = document.getElementById("uw-embed-frame");
    var path = new URLSearchParams(location.search).get("path");
    if ((!path && !location.hash) || (path && path.charAt(0) !== "/")) return;
    var base = new URL(frame.src);
    var target = new URL((path || base.pathname + base.search) + location.hash, base);
    if (target.origin !== base.origin) return;
    base.searchParams.forEach(function (value, key) {
      if (!target.searchParams.has(key)) target.searchParams.set(key, value);
    });
    frame.src = target.href;
  })();
</script>
  • 다른 출처 iframe 은 allow 에 적은 기능만 쓸 수 있다. 센서(accelerometer, gyroscope, magnetometer), screen-wake-lock, camera(QR 스캔), web-share 처럼 앱이 쓰는 기능을 빠짐없이 적는다.
  • "새 탭에서 열기" 링크는 다시 이 문서로 리다이렉트될 뿐이므로 두지 않는다.
  • 서브도메인끼리는 같은 사이트(eTLD+1)라 iframe 안에서도 쿠키(SameSite)와 저장소가 직접 열었을 때와 같게 쓰인다.

경로마다 다른 문서로 보내기

도구 모음처럼 경로마다 문서가 따로 있으면 첫 경로 조각으로 문서를 고른다.

직접 연 주소

리다이렉트

tools.unwiki.net/, /?q=json

www.unwiki.net/tools/ (목록 문서, 쿼리는 버림)

tools.unwiki.net/json-format

www.unwiki.net/tools/json-format/

tools.unwiki.net/json-format?x=1

www.unwiki.net/tools/json-format/?path=%2Fjson-format%3Fx%3D1

tools.unwiki.net/Bad_Path (slug 꼴 아님)

www.unwiki.net/tools/

const TOOLS_PAGE = 'https://www.unwiki.net/tools/';
const SLUG = /^[a-z0-9-]+$/;

export function wikiRedirect(request: Request): string | null {
  if (request.headers.get('Sec-Fetch-Dest') !== 'document') {
    return null;
  }
  const {pathname, search} = new URL(request.url);
  const slug = pathname.split('/')[1];
  if (!SLUG.test(slug)) {
    return TOOLS_PAGE;
  }
  const page = `${TOOLS_PAGE}${slug}/`;
  const path = pathname + search;
  return path === `/${slug}` ? page : `${page}?path=${encodeURIComponent(path)}`;
}

확인

# 직접 연 문서 → 302, Location 에 위키 주소, Vary: Sec-Fetch-Dest
curl -sI -H 'Sec-Fetch-Dest: document' 'https://sphm.unwiki.net/join/ABCD?via=qr'

# iframe·헤더 없음 → 200 + content-security-policy: frame-ancestors ...
curl -sI -H 'Sec-Fetch-Dest: iframe' https://sphm.unwiki.net/
curl -sI https://sphm.unwiki.net/

# 정적 파일 → 리다이렉트·CSP 없음 (_routes.json 의 exclude)
curl -sI -H 'Sec-Fetch-Dest: document' https://sphm.unwiki.net/favicon.svg
  • 브라우저에서는 다른 사이트(예: example.com)의 개발자 도구 콘솔에서 iframe 을 만들어 넣어 보면 차단 화면이 나온다.
  • 부모 문서의 ?path= 전달은 서버가 아니라 브라우저에서 일어나므로 실제로 열어 iframe 의 src 를 확인한다.

제약

  • Sec-Fetch-Dest 를 보내지 않는 브라우저와 봇은 리다이렉트되지 않는다. 봇이 앱 주소를 단독으로 색인하지 않게 하려면 X-Robots-Tag: noindex, indexifembedded 를 고려한다.
  • 부모 사이트를 로컬(localhost)에서 띄우면 frame-ancestors 에 없어 iframe 이 막힌다. 필요하면 http://localhost:* 를 더한다.
  • 로컬 wrangler dev 나 pages dev 도 맨 위 창으로 열면 리다이렉트된다. iframe 으로 넣거나 curl 로 확인한다.
  • 다른 출처 iframe 안에서는 브라우저에 따라 진동(navigator.vibrate) 같은 일부 기능이 막힐 수 있다.

대안

  • 존 규칙: 같은 존(unwiki.net)의 Redirect Rule(조건 any(http.request.headers["sec-fetch-dest"][*] eq "document"), 대상 wildcard_replace(http.request.full_uri, "https://*.unwiki.net/*", "https://www.unwiki.net/games/${1}/")) + Response Header Transform Rule 로 코드 없이 처리할 수 있다. 대시보드 설정이 필요하고 *.pages.dev 는 막지 못하며, Redirect Rule 에서 요청 헤더 필드를 쓸 수 있는지 표현식 편집기에서 확인해야 한다.
  • 클라이언트만: window.top === window.self 일 때 location.replace(). 앱마다 코드를 고쳐야 하고 잠깐 화면이 보였다 넘어간다. 서비스 워커가 있는 앱의 보조 수단으로만 쓴다.

See also

Favorite site

author Zer0 sitemap