Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Bug]: HMR with MV3 (CSP) #10

Closed
1 task done
jaem1n207 opened this issue Apr 3, 2024 · 3 comments
Closed
1 task done

[Bug]: HMR with MV3 (CSP) #10

jaem1n207 opened this issue Apr 3, 2024 · 3 comments
Assignees
Labels
bug Something isn't working

Comments

@jaem1n207
Copy link
Owner

jaem1n207 commented Apr 3, 2024

Guidelines

Project Version

0.0.1

Platform and OS Version

macOS 14.0, Chromium Engine Version 123.0.6312.87

Affected Devices

MacBook Pro

What happened?

개발 환경에서 HMR이 동작하지 않습니다.

Vite는 코드를 동적으로 가져오는 HMR을 사용하여 외부 코드를 실행할 수 있습니다. 이렇게 하면 코드가 번들로 제공되지 않으므로 이상적인 개발 경험을 얻을 수 있습니다. 그러나 MV3는 보안상의 이유로 모든 외부 코드를 외부 번들에 포함해야하기 때문에 기본적으로 Vite의 HMR을 사용할 수 없습니다.

Steps to reproduce

  1. pnpm dev 명령어를 실행합니다.
  2. pnpm start:firefox 또는 브라우저에서 extensions/ 폴더의 확장 프로그램을 로드합니다.
  3. Popup 페이지를 엽니다.

Expected behavior

로컬 개발을 위해 Popup 페이지에서 HMR을 사용할 수 있어야 합니다.

MV3 마이그레이션 가이드의 CSP 섹션에 따르면 MV3의 콘텐츠 보안 정책은 script-src, object-src, worker-src 지시어를 허용하며 아래 값을 포함할 수 있습니다:

  • self
  • none
  • wasm-unsafe-eval
  • 압축해제된 확장 프로그램일 경우: 모든 localhost 소스, (http://localhost, http:127.0.0.1, 또는 이러한 도메인의 port)

현재 개발환경에서 manifest의 content_security_policy 속성에는 다음 값을 포함합니다:

{
  ...
  content_security_policy: {
    extension_pages: isDev
      ? // this is required on dev for Vite script to load
        `script-src 'self' http://localhost:${port}; object-src 'self'`
      : "script-src 'self'; object-src 'self'",
  },
}

Attachments

다음은 manifest를 생성하는 코드와 모든 페이지에 삽입하는 스크립트, 그리고 HMR을 가능하게 추가로 삽입되는 스크립트입니다:

// scripts/utils
import process from 'node:process';
import { resolve } from 'node:path';

export const port = Number(process.env.PORT || '') || 3303;
export const r = (...args: string[]) => resolve(__dirname, '..', ...args);
export const isDev = process.env.NODE_ENV !== 'production';
export const isFirefox = process.env.EXTENSION === 'firefox';

// manifest.ts
import fs from 'fs-extra';
import type { Manifest } from 'webextension-polyfill';
import type PkgType from '../package.json';
import { isDev, isFirefox, port, r } from '../scripts/utils';

export async function getManifest(): Promise<Manifest.WebExtensionManifest> {
  const pkg = (await fs.readJSON(r('package.json'))) as typeof PkgType;
  const pkgName = pkg.displayName || pkg.name;

  // update this file to update this manifest.json
  const manifest: Manifest.WebExtensionManifest = {
    manifest_version: 3,
    name: isDev ? `(Debug) ${pkgName}` : pkgName,
    version: isDev ? '0.0.1' : pkg.version,
    description: pkg.description,
    action: {
      default_icon: './assets/icon-512.png',
      default_popup: './dist/popup/index.html',
    },
    options_ui: {
      page: './dist/options/index.html',
      open_in_tab: true,
    },
    background: isFirefox
      ? {
          scripts: ['dist/background/index.mjs'],
          type: 'module',
        }
      : {
          service_worker: './dist/background/index.mjs',
        },
    icons: {
      16: './assets/icon-512.png',
      48: './assets/icon-512.png',
      128: './assets/icon-512.png',
    },
    permissions: ['tabs', 'storage', 'activeTab'],
    host_permissions: ['<all_urls>'],
    content_scripts: [
      {
        matches: ['<all_urls>'],
        js: ['dist/contentScripts/index.global.js'],
      },
    ],
    web_accessible_resources: [
      {
        resources: ['dist/contentScripts/style.css', 'dist/sidebar/index.html'],
        matches: ['<all_urls>'],
      },
    ],
    content_security_policy: {
      extension_pages: isDev
        ? // this is required on dev for Vite script to load
          `script-src 'self' http://localhost:${port}; object-src 'self'`
        : "script-src 'self'; object-src 'self'",
    },
  };

  return manifest;
}
// contentScriptHMR.ts
import browser from 'webextension-polyfill';

import { isFirefox, isForbiddenUrl } from '~/env';

// Firefox fetch files from cache instead of reloading changes from disk,
// hmr will not work as Chromium based browser
browser.webNavigation.onCommitted.addListener(({ tabId, frameId, url }) => {
  // Filter out non main window events.
  if (frameId !== 0) return;

  if (isForbiddenUrl(url)) return;

  // inject the latest scripts
  browser.tabs
    .executeScript(tabId, {
      file: `${isFirefox ? '' : '.'}/dist/contentScripts/index.global.js`,
      runAt: 'document_end',
    })
    .catch((error) => console.error(error));
});
// generate stub index.html files for dev entry
import chokidar from 'chokidar';
import fs from 'fs-extra';
import { execSync } from 'node:child_process';
import path from 'node:path';
import { isDev, log, port, r } from './utils';

/**
 * Stub index.html to use Vite in development
 */
async function stubIndexHtml() {
  const views = ['options', 'popup', 'background', 'sidebar'];

  for (const view of views) {
    await fs.ensureDir(r(`extension/dist/${view}`));
    let data = await fs.readFile(r(`src/${view}/index.html`), 'utf-8');
    data = data
      .replace('</head>', '<script type="module" src="/dist/refreshPreamble.js"></script></head>')
      .replace('"./main.tsx"', `"http://localhost:${port}/${view}/main.tsx"`)
      .replace('<div id="app"></div>', '<div id="app">Vite server did not start</div>');
    await fs.writeFile(r(`extension/dist/${view}/index.html`), data, 'utf-8');
    log('PRE', `stub ${view}`);
  }
}

// This enables hot module reloading
async function writeRefreshPreamble() {
  const data = `
    import RefreshRuntime from "http://localhost:${port}/@react-refresh";
    RefreshRuntime.injectIntoGlobalHook(window);
    window.$RefreshReg$ = () => {};
    window.$RefreshSig$ = () => (type) => type;
    window.__vite_plugin_react_preamble_installed__ = true;
  `;

  await fs.ensureDir(r('extension/dist'));
  await fs.writeFile(path.join(r('extension/dist/'), 'refreshPreamble.js'), data, 'utf-8');
}

function writeManifest() {
  execSync('npx esno ./scripts/manifest.ts', { stdio: 'inherit' });
}

writeManifest();

if (isDev) {
  writeRefreshPreamble();
  stubIndexHtml();
  chokidar.watch(r('src/**/*.html')).on('change', () => {
    stubIndexHtml();
  });
  chokidar.watch([r('src/manifest.ts'), r('package.json')]).on('change', () => {
    writeManifest();
  });
}

Screenshots or Videos

image

Additional Information

@jaem1n207 jaem1n207 added the bug Something isn't working label Apr 3, 2024
@jaem1n207 jaem1n207 self-assigned this Apr 3, 2024
@jaem1n207
Copy link
Owner Author

jaem1n207 commented Apr 4, 2024

http://* 구문을 사용하면 https://localhost:80https://localhost:443표준 포트로만 사용합니다.

따라서 이 문제를 해결하기 위해 script-src 지시어에 https://localhost:3303 host-source를 추가했고 정상적으로 동작했습니다.

참고: http://* source는 http://*, https://*를 모두 포함하므로 CSP3를 지원하는 브라우저는 안전하지 않은 http:를 안전한 https://로 업그레이드합니다. 따라서 기존에 사용하던 http://localhost:3303http://localhost:3303https://localhost:3303을 모두 허용합니다.
그런데 어째서 https://로 직접 바꿔주니 동작하게 된 건지 여전히 의문입니다... 근본적인 해결에 도달할 때까지 추가적인 정보를 아래에 계속 작성하겠습니다.

"content_security_policy": {
  "extension_pages": "script-src 'self' https://localhost:3303; object-src 'self'"
}

@jaem1n207
Copy link
Owner Author

혹시 몰라 확장 프로그램을 재업로드한 뒤 실행해보면 아래와 같은 CSP를 위반했다는 오류가 출력됩니다.
image

  • 다시 http://localhost:3303로 바꿔주니 동작합니다. 재업로드해도 잘 동작하구요.. 어찌보면 당연한건데 왜 됐다 안 됐다 하는 건지 정확한 원인을 파악해보려 합니다.

@jaem1n207
Copy link
Owner Author

관련 이슈의 2023.05.08 날짜에 등록된 댓글을 통해 확인할 수 있었습니다.
크롬 브라우저는 MV2에서 MV3로 완전한 마이그레이션을 계획하고 있으며 이 과정에서 CSP 정책에 버그가 몇 가지 있었던 것 같습니다. 기존(2022년 4월)엔 localhost에 동적 가져오기를 사용하려고 하면 CSP 위반이 발생했었고, 2022년 12월쯤에 개발 모드의 확장 프로그램에 대해 localhost 스크립트를 허용하지 않던 문제가 해결된 것을 확인할 수 있었습니다.
버그에 대한 해결사항은 chromium 문서에서 확인할 수 있으며, w3c github issue에서도 확인할 수 있듯이 CSP를 설정하는 content_security_policy 객체에 localhost를 수동으로 추가하면 localhost를 허용 목록에 추가할 수 있게 되어 HMR이 정상 동작한다고 합니다.
여전히 100% 확신이 드는 건 아니지만 처음부터 버그가 아니라 단순히 그때의 특정 환경 탓이었던 것 같습니다. 실제로 다른 컴퓨터에서 똑같은 파일을 실행하면 CSP 에러가 안 났었으니까요...

{
  ...
  content_security_policy: {
    extension_pages: isDev
      ? // this is required on dev for Vite script to load
        `script-src 'self' http://localhost:${port}; object-src 'self'`
      : "script-src 'self'; object-src 'self'",
  },
}

위처럼 정의해 둔 CSP 정책의 의미를 차근차근 분석하고 이슈를 마무리하겠습니다:

  • extension_pages: 확장 프로그램의 페이지에 적용될 CSP를 적용합니다.
  • script-src: 스크립트 소스의 출처를 지정합니다. 어디서 스크립트를 로드할 수 있는지를 정의합니다.
  • self: 현재 출처에서만 스크립트를 로드할 수 있음을 의미합니다. 즉, 확장 프로그램 자체의 코드나 리소스에서만 스크립트를 가져올 수 있습니다.
  • http://localhost:3303: 개발 모드에서는 localhost의 특정 포트에서도 스크립트를 로드할 수 있게 허용하겠다는 의미입니다. 개발 중에 로컬 서버에서 스크립트를 로드하기 위해 설정한 값입니다.
  • object-src: <object>, <embed>, <applet> 태그 등을 통해 로드될 리소스의 출처를 지정합니다. 여기서 self로 설정되어 있어, 현재 출처에서만 이러한 리소스를 로드할 수 있습니다.

종합적으로 의미를 분석해보면 확장 프로그램의 페이지에서 실행할 수 있는 스크립트와 오브젝트 리소스의 출처를 제한하는 정의입니다. 개발 모드에선 현재 확장 프로그램의 코드(self)와 (HMR을 위해) 로컬 서버(http://localhost:3303)에서 스크립트를 로드할 수 있습니다. 개발 모드가 아닐 때는 오직 확장 프로그램 자체의 코드에서만 스크립트와 오브젝트 리소스를 로드하겠다는 의미입니다.

따라서 이 모든 건 보안을 강화하기 위한 조치로, 외부 출처에서 잠재적으로 위험한 코드를 로드하는 것을 방지하기 위한 작업입니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

No branches or pull requests

1 participant