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 으로 띄운다.- 직접 연
https://anitack.unwiki.net/→https://www.unwiki.net/games/anitack/로 302. -
www.unwiki.net,unwiki.net의 iframe 은 그대로 동작한다. - 다른 사이트가 iframe 으로 넣으면 브라우저가 막는다.
- 직접 연
- 리다이렉트: 요청 헤더
Sec-Fetch-Dest가document일 때만 302. - 임베드 제한: 응답 헤더
Content-Security-Policy: frame-ancestors ....
원리
Sec-Fetch-Dest
브라우저는 요청이 어디에 쓰이는지를 Sec-Fetch-Dest 헤더로 보낸다.
| 접속 방식 | | 처리 |
| 주소창 입력, 링크 클릭, 북마크, 새 탭 | | 302 리다이렉트 |
| <iframe src=...> | | 그대로 응답 |
| 게임이 불러오는 JS, wasm, 이미지, | | 그대로 응답 |
| 헤더를 보내지 않는 옛 브라우저(Safari 16.4 미만), 봇 | (없음) | 그대로 응답 (fail-open) |
- 값이 정확히
document일 때만 리다이렉트하므로 리소스 요청과 iframe 은 영향을 받지 않는다. -
Referer로 판단하지 않는다. 개인정보 설정에 따라 빠지고, 본 사이트에서 링크를 눌러 들어온 경우와 iframe 을 구분하지 못한다. - 같은 주소가 헤더에 따라 다르게 응답하므로
Vary: Sec-Fetch-Dest를 붙이고, 영구 캐시되는 301 대신 302 를 쓴다.
frame-ancestors
리다이렉트만으로는 다른 사이트의 iframe 을 막지 못한다(Sec-Fetch-Dest: iframe 이므로). 응답에 다음 헤더를 붙인다.
-
X-Frame-Options는 허용 출처를 하나만 적을 수 있어 쓰지 않는다. -
'self'는 앱이 자기 출처의 iframe 을 쓰는 경우를 위해 둔다. -
frame-ancestors는 <code><meta></code> CSP 로는 동작하지 않는다. 반드시 응답 헤더로 보낸다.
경로와 쿼리 유지 (?path=)
초대·공유 링크(/play?room=…, /join/ABCD?via=qr)가 깨지지 않게 연 주소를 함께 넘긴다.
- 경로가
/이면 문서 주소만, 그 밖은?path=<encodeURIComponent(pathname + search)>를 붙인다.
#…)는 서버로 오지 않지만, Location 에 해시가 없으면 브라우저가 원래 해시를 리다이렉트한 주소에 그대로 잇는다. ?path= 와 해시를 다시 iframe 주소로 되돌린다(부모 페이지 참고). 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 를 지원하지 않는다.
- GitHub 연동 자동 빌드,
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)와 저장소가 직접 열었을 때와 같게 쓰인다.
경로마다 다른 문서로 보내기
도구 모음처럼 경로마다 문서가 따로 있으면 첫 경로 조각으로 문서를 고른다.
| 직접 연 주소 | 리다이렉트 |
| | |
| | |
| | |
| | |
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
- Cloudflare:Pages
- Cloudflare:Workers
- Content-Security-Policy
- iframe
- Service Worker
- Progressive Web App
Favorite site
- Sec-Fetch-Dest - HTTP | MDN
- CSP: frame-ancestors - HTTP | MDN
- Get started · Cloudflare Pages Functions
- Routing (_routes.json) · Cloudflare Pages Functions
- Headers (_headers) · Cloudflare Pages
- Single Redirects · Cloudflare Rules
- Robots meta tag (indexifembedded) · Google Search Central