본문 바로가기
C.W.K.
Stream
Lesson 05 of 06 · published

Iframe과 CSP — Panel에서 외부 content 안전하게 load

~13 min · side-panel, csp, iframe, frame-src, web-accessible-resources

Level 0Extension 입덕
0 XP0/56 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Side panel은 그냥 extension HTML page, popup을 다스리는 같은 MV3 content security policy가 다스려. Lesson 5는 규칙과 안전한 escape valve — 뭘 직접 렌더 가능, 뭐가 iframe 필요, hole 안 만들고 frame-src 넓히는 법."

Default extension-pages CSP

MV3가 extension 안 모든 HTML page (popup, side panel, options page, full-tab page)에 strict default Content Security Policy와 함께 ship:

script-src 'self'; object-src 'self';

허용하는 것:

  • Extension에 bundle 된 JavaScript file (<script src="panel.js">).
  • Extension에 bundle 된 stylesheet.
  • 이미지 / font / 대부분의 다른 passive resource는 어디서든.
  • Fetch / XHR은 어떤 URL 로든.

금지하는 것:

  • Inline script (<script>...</script>)와 inline event handler (onclick="foo()").
  • eval, new Function, string argument 가진 setTimeout — 다 runtime에 ban.
  • Extension 바깥 어디서든 <script src="https://cdn.example.com/lib.js"> load.
  • Non-extension URL 가리키는 <iframe> — CSP 안 넓히면 차단.

Remote-script ban이 MV3의 flagship 보안 변경이야. Remote frame은 default extension CSP가 알아서 안전하게 만들어 주지 않아. 의도적으로 embed할 origin은 좁은 frame-src allowlist에 넣고, frontend probe나 fetch에 필요한 origin은 connect-src로 따로 잠가. Framing은 양쪽 계약이라 extension CSP가 허용해도 상대 response가 ancestor를 거절하면 끝이고.

CSP 넓히기 — frame-src

Panel에 iframe 허용하려면 manifest에 content_security_policy.extension_pages entry 추가. 형식은 single CSP string. script-src와 object-src baseline 다시 진술하고 frame-src 추가:

{
  "content_security_policy": {
    "extension_pages": "script-src 'self'; object-src 'self'; frame-src 'self' https://www.youtube.com https://*.figma.com;"
  }
}

이제 <iframe src="https://www.youtube.com/embed/...">가 panel.html 안에서 동작. 다른 origin은 여전히 차단. List를 가능한 한 tight 하게 유지 — frame-src의 모든 domain이 그 site가 나중에 compromise 되면 잠재 공격 surface.

흔한 use case

  • 문서나 도움말. 내가 관리하는 도메인에 올려 둔 문서를 그대로 띄우는 거야. 도움말을 extension 안에 넣고 버전마다 갱신하는 것보다 훨씬 싸게 먹혀.
  • 남의 서비스 화면. Notion 페이지, Figma 파일, YouTube 강의 같은 것들. 하나하나 frame-src에 명시적으로 적어 줘야 해.
  • Authentication redirect. 일부 auth flow가 third-party identity provider 통해 redirect. iframe 패턴이 새 tab spawn 없이 panel 안에서 redirect 처리.
  • Sandboxed extension content. 자체 chrome-extension:// page (다른 path 가진) 가리키는 iframe이 민감 코드 wall off — user-provided HTML/markdown 표시할 때 script-of-self 권한 없이.

제어하는 page sandbox

마지막 케이스 — untrusted user content 렌더링 — 엔 extension iframe과 sandbox manifest key 결합:

{
  "sandbox": {
    "pages": ["sandbox.html"]
  }
}

sandbox.html이 chrome.* API access 없이 unique origin에서 돔. 부모 panel이 거기 postMessage 가능, 안의 어떤 코드도 user clip storage 닿을 수 없다고 신뢰. Source content가 본질적으로 임의인 rich markdown 이나 HTML clip 표시 패턴.

Web accessible resources — 역방향

위 iframe 이야기는 panel이 외부 content load 하는 것. 반대로 host page가 extension page를 embed하게 하려면 web_accessible_resources를 사용해:

{
  "web_accessible_resources": [
    {
      "resources": ["injected.js", "badge.html"],
      "matches": ["https://*.github.com/*"]
    }
  ]
}

List 된 origin이 어떤 extension file load 허용되는지 선언. Extension file 가리키는 <script> 태그 inject 하는 content script (Lesson 3의 legacy bridge 패턴)와 page가 extension iframe embed 하게 허용하는 extension에서 사용.

기본 CSP는 일부러 빡빡하게 잠가 놓은 거야. 정말 필요한 것만 하나씩 열어. inline event handler도, eval도, 아무 iframe origin도 설계상 막혀 있어. 막혔으면 그 아래 깔린 패턴을 고쳐야지, CSP를 꺼서 뚫으려 들지 마.
'unsafe-eval' 추가 안 함. Chrome Web Store는 'unsafe-eval' 이나 'unsafe-inline'으로 CSP를 푼 extension을 반려해. 드물게 정당한 사정이 있긴 한데 (옛날 bundler가 eval로 WebAssembly를 싣는 경우 같은 거), 요즘은 거의 다 우회할 길이 있어. eval이 필요해 보인다면 십중팔구 어떤 dependency가 build 대상을 잘못 고른 거야. 그걸 고쳐.
frame-src는 계약의 절반뿐이야. Extension page가 iframe을 시도할 수 있게 할 뿐, remote response가 X-Frame-Options나 자기 CSP frame-ancestors로 embedding을 거절할 수 있어. 직접 통제하는 embed route를 쓰고 임의 login page를 frame하지 마.

Code

manifest.json — frame-src 가진 extension_pages CSP, plus sandbox page·json
{
  "manifest_version": 3,
  "name": "ClipDeck",
  "version": "0.7.0",
  "action": { "default_popup": "popup.html" },
  "background": { "service_worker": "background.js" },
  "side_panel": { "default_path": "panel.html" },
  "permissions": ["storage", "tabs", "scripting", "activeTab", "sidePanel"],
  "content_security_policy": {
    "extension_pages": "script-src 'self'; object-src 'self'; frame-src 'self' https://docs.example.com;"
  },
  "sandbox": {
    "pages": ["sandbox.html"]
  },
  "content_scripts": [
    { "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle" }
  ],
  "icons": { "16": "icons/16.png", "48": "icons/48.png", "128": "icons/128.png" }
}
panel.html — third-party iframe + sandboxed extension iframe·html
<!-- panel.html — help doc iframe + sandboxed clip renderer -->
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <link rel="stylesheet" href="panel.css" />
  </head>
  <body>
    <h1>ClipDeck</h1>
    <details>
      <summary>Help</summary>
      <!-- docs.example.com 이 frame-src 에 있어 허용 -->
      <iframe src="https://docs.example.com/clipdeck-help" width="100%" height="200"></iframe>
    </details>

    <div id="list"></div>

    <!-- untrusted-shape clip (rich HTML/markdown) 용 sandboxed renderer -->
    <iframe id="clipRenderer" src="sandbox.html" style="display:none;width:100%;border:0;"></iframe>

    <script src="panel.js"></script>
  </body>
</html>
panel.js — sandboxed renderer에 postMessage·javascript
// panel.js — clip 렌더 위해 sandboxed iframe 에 message
function renderClipInSandbox(clip) {
  const iframe = document.getElementById("clipRenderer");
  iframe.style.display = "block";
  iframe.contentWindow.postMessage(
    { source: "clipdeck-panel", type: "renderClip", clip },
    "*"
  );
}

window.addEventListener("message", (event) => {
  // 자체 sandbox iframe 에서 온 메시지만 신뢰
  if (event.source !== document.getElementById("clipRenderer").contentWindow) return;
  if (event.data?.source !== "clipdeck-sandbox") return;
  if (event.data.type === "rendered" && event.data.heightPx) {
    document.getElementById("clipRenderer").style.height = `${event.data.heightPx}px`;
  }
});

// sandbox.html / sandbox.js 의 안쪽이 clip parse, DOM 으로 렌더 (innerHTML 아님 —
// DOMPurify 나 자체 escape 사용), 렌더된 height 측정, postMessage 로 다시 보냄:
//   parent.postMessage({ source: 'clipdeck-sandbox', type: 'rendered', heightPx: ... }, '*');

External links

Exercise

clipdeck/manifest.json을 첫 번째 code block으로 update (version 0.7.0, content_security_policy.extension_pages가 frame-src에 https://docs.example.com 허용 위해 넓혀짐. sandbox.pages 선언). 두 번째 code block의 iframe markup을 clipdeck/panel.html에 추가. Placeholder sandbox.html에는 <h1 id="out">sandbox alive</h1><script>parent.postMessage({source:'clipdeck-sandbox', type:'rendered', heightPx:40}, '*');</script>를 넣어. Extension reload 하고 side panel 열기 — docs iframe은 placeholder error page 표시 (docs.example.com은 실제 host 아님), sandbox iframe이 load 되고 postMessage 보냄. Panel DevTools의 network tab 열어 docs.example.com 요청이 허용됐는지 확인해. 아니면 CSP error가 찍혔을 거야. CSP가 동작하는지 증명하려면 iframe src를 https://example.com/anything (frame-src에 없음)으로 바꾸고 reload해. iframe이 console의 명확한 CSP violation과 함께 차단되어야 해.
Hint
Panel DevTools console이 manifest update 후에도 Refused to frame ... because it violates the following Content Security Policy directive: frame-src 'self' 보고하면, 두 가지 흔한 원인: (1) manifest 편집 후 extension reload 안 함, 또는 (2) manifest JSON이 문법적으로 깨져 Chrome이 silently default로 fallback (chrome://extensions → Errors 확인). Sandbox.html이 아예 load 안 되면 sandbox.pages 아래 list 됐고 extension 디렉토리의 실제 파일인지 확인. Sandbox postMessage가 sandbox.js가 chrome.* access 시도하면 silently fail — sandbox page는 chrome.* API access 없음, 그게 point. Sandboxed page는 opaque origin이라 그쪽으로 보낼 때 '*'가 필요할 수 있어. 그 경우 event.source와 strict message schema를 검증하고 secret은 보내지 마.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.