Skip to content
Search

Cloudflare:IframeOnlyRedirect

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

How to make an app deployed to a subdomain with Cloudflare Pages/Workers work only inside an iframe on a page of the main site. Opening it directly from the address bar 302-redirects to the corresponding page on the main site, and only allowed sites can embed it in an iframe.

About

  • Example: a game such as anitack.unwiki.net is shown in an iframe on the https://www.unwiki.net/games/anitack/ page.
  • Applied just by adding files to the repository, without any separate dashboard settings. Pages integrated with GitHub picks it up as soon as you commit.
  • Two things are used together.
    • Redirect: 302 only when the request header Sec-Fetch-Dest is document.
    • Embed restriction: response header Content-Security-Policy: frame-ancestors ....

How it works

Sec-Fetch-Dest

The browser sends what a request will be used for in the Sec-Fetch-Dest header.

Access method

Sec-Fetch-Dest

Handling

Address bar input, link click, bookmark, new tab

document

302 redirect

<iframe src=...>

iframe

Respond as is

JS, wasm, images, fetch loaded by the game

script, image, empty, etc.

Respond as is

Old browsers that don't send the header (Safari below 16.4), bots

(none)

Respond as is (fail-open)

  • Since it redirects only when the value is exactly document, resource requests and iframes are not affected.
  • It does not decide based on Referer. It gets dropped depending on privacy settings, and cannot distinguish an iframe from arriving by clicking a link on the main site.
  • Because the same URL responds differently depending on the header, add Vary: Sec-Fetch-Dest, and use 302 instead of 301, which is cached permanently.

frame-ancestors

The redirect alone cannot block iframes on other sites (because they send Sec-Fetch-Dest: iframe). Add the following header to the response.

Content-Security-Policy: frame-ancestors 'self' https://unwiki.net https://www.unwiki.net
  • X-Frame-Options is not used because it can list only one allowed origin.
  • 'self' is kept for cases where the app uses iframes from its own origin.
  • frame-ancestors does not work with <code><meta>

    </code> CSP. It must be sent as a response header.

Preserving path and query (?path=)

Pass along the opened URL so that invite/share links (/play?room=…, /join/ABCD?via=qr) don't break.

Cloudflare Pages

functions/_middleware.ts

Place it at the repository root (not the build output directory). Pages compiles it automatically after the build.

/** 게임을 품은 위키 문서. */
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

Without it, every request (including hashed bundles and icons) goes through Functions and uses up the free quota (100,000 per day). Exclude static files and send only page paths to the function. With Vite, placing it in public/ copies it to the build output (dist/).

{
  "version": 1,
  "include": ["/*"],
  "exclude": ["/assets/*", "/favicon.svg", "/icon-192.png", "/site.webmanifest"]
}
  • Deep SPA paths (/play, /join/…) are also documents, so keep include as /* and remove static files with exclude.
  • Up to 100 rules in total, each up to 100 characters.
  • When you add a new static file to public/, add it to exclude too. It still works if you forget, but function invocations increase, and opening that file from the address bar gets redirected.

Caveats

  • Don't add CSP with _headers. According to the Cloudflare documentation, _headers is not applied to responses generated by Pages Functions. Add CSP in the middleware. (Use _headers only for static files, such as cache rules.)
  • Deployment methods
    • GitHub-integrated automatic builds, wrangler pages deploy: functions/ is included automatically.
    • Direct Upload (drag and drop) in the dashboard: does not support Functions.
  • The location of functions/ is relative to the Root directory in the Pages build settings (default is the repository root).
  • If wrangler.json(c) has no pages_build_output_dir (i.e., it's a Workers config), the Pages build ignores that file. However, local wrangler pages dev reads it and the results may differ, so in that case copy only functions/ and the build output to a different directory to check.
  • The *.pages.dev URL and preview deployments (<branch>.<project>.pages.dev) are redirected the same way. Check previews with curl.
  • You can check the build output directory by downloading it with wrangler pages download config<project> and looking at pages_build_output_dir.

Cloudflare Workers (Static Assets)

functions/ is ignored. Do the same thing in the Worker code.

wrangler.jsonc

By default static assets don't go through the Worker, so add the page paths to run_worker_first. Don't include hashed bundles, icons, or sw.js (request quota).

{
  "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>;
  • Responses from env.ASSETS.fetch() come with the _headers rules (Cache-Control, etc.) attached. They are not attached to a 302 that the Worker creates itself.
  • Paths not in the assets (/nope) also reach the Worker, so opening one directly redirects to ?path=%2Fnope.

Service worker (PWA)

  • With a service worker that handles navigation network-first (fetch(event.request)), the server's 302 works as is.
  • Workbox navigateFallback (the vite-plugin-pwa default) answers a returning visitor's navigation with the precached index.html, so it never reaches the server. Put a guard at the very start of the app boot.
// 운영 호스트를 맨 위 창으로 열었으면 위키로 옮기고 부트하지 않는다 (개발 서버·미리보기는 그대로)
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();
}
  • An installed PWA (start_url: "/") goes to the wiki when launched, so it effectively can no longer be used as a standalone app.

Parent page

The side that embeds the iframe (e.g., MkDocs's overrides/partials/embed.html). It restores ?path= and the hash to the iframe URL, and discards values that leave the app origin (//evil.com, /\evil.com, https://…). Queries in the default src (embed=1, lang=ko, etc.) are re-added if missing from the passed URL.

<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>
  • A cross-origin iframe can use only the features listed in allow. List every feature the app uses, such as sensors (accelerometer, gyroscope, magnetometer), screen-wake-lock, camera (QR scanning), and web-share.
  • Don't add an "Open in new tab" link, since it would just redirect back to this page.
  • Subdomains are the same site (eTLD+1), so cookies (SameSite) and storage inside the iframe behave the same as when opened directly.

Sending each path to a different page

When each path has its own page, as with a tool collection, choose the page by the first path segment.

URL opened directly

Redirect

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

www.unwiki.net/tools/ (list page, query discarded)

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 (not slug-shaped)

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)}`;
}

Verification

# 직접 연 문서 → 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
  • In a browser, create and insert an iframe from the developer tools console on another site (e.g., example.com) and you'll see the blocked screen.
  • Passing ?path= on the parent page happens in the browser, not on the server, so actually open it and check the iframe's src.

Limitations

  • Browsers and bots that don't send Sec-Fetch-Dest are not redirected. To keep bots from indexing the app URL on its own, consider X-Robots-Tag: noindex, indexifembedded.
  • If you run the parent site locally (localhost), the iframe is blocked because it's not in frame-ancestors. Add http://localhost:* if needed.
  • Local wrangler dev or pages dev also redirects when opened as the top-level window. Embed it in an iframe or check with curl.
  • Inside a cross-origin iframe, some features such as vibration (navigator.vibrate) may be blocked depending on the browser.

Alternatives

  • Zone rules: A Redirect Rule in the same zone (unwiki.net) (condition any(http.request.headers["sec-fetch-dest"][*] eq "document"), target wildcard_replace(http.request.full_uri, "https://*.unwiki.net/*", "https://www.unwiki.net/games/${1}/")) + a Response Header Transform Rule can handle it without code. It requires dashboard settings, cannot block *.pages.dev, and you need to check in the expression editor whether request header fields can be used in Redirect Rules.
  • Client only: location.replace() when window.top === window.self. You must change the code of each app, and the screen briefly appears before moving on. Use it only as a supplementary measure for apps with a service worker.

See also

Favorite site

author Zer0 sitemap