editormd におけるクロスドメイン画像アップロードのエラー回避と同一生成元ポリシー対策

現象と発生原理

エディタ拡張ライブラリ「editormd」は、標準の実装において画像アップロードに非同期フォーム送信(target属性で隠しiframeを指定)を採用しています。同環境内での運用時は正常に動作しますが、メディアストレージやAPIエンドポイントが別ドメイン・別ポート・別プロトコルで構成されている場合、iframe内の通信結果を親ウィンドウから直接読み取りしようとする際にブラウザのセキュリティ機構が介入します。

この時点で以下のようなランタイム例外が検出されます。

Error: Uncaught DOMException: Blocked a frame with origin 「https://app.example.com」 from accessing a cross-origin frame.

これはWeb標準の同一生成元ポリシー(Same-Origin Policy)による強制制限です。iframeは事実上独立した実行コンテキストであり、親ページ側で`contentDocument`や`contentWindow`経由で子フレームのDOM木や内部変数を参照する場合、オリジン構成(ホスト名・ポート番号・プロトコル)が完全一致しないと暗黙的にブロックされます。ライブラリ公式にもクロスドメイン対応用のクエリパラメータ仕様が存在しますが、実際のDOM操作ロジック内に残っている同一生成元前提の処理が障害となるケースが頻繁に報告されています。

アーキテクチャの再設計方針

既存の`iframe.onload`検知後に`body.innerText`を文字列解析してJSONオブジェクト化するフローは、クロスオリジン環境ではそもそも実行段階で停止します。解決策としては、ネットワーク層でのクロスオリジンアクセス制御ではなく、親コンテキスト内で完結する非同期通信パスへ書き換えるのが確実です。これにより、CSRFトークンの動的注入やステータスコードの明示的なハンドリングが可能となり、レガシーな`eval()`展開に伴う脆弱性リスクも解消できます。

以下は、ライブラリの内部アップロードハンドラを対象に、モダンなFetch APIとPromiseチェーンを用いて再構成した実装例です。変数名・制御構造・データフローを刷新し、外部フレームへのDOM依存を完全に排除しています。

1. フォームレンダリング部分のパッチ

// settings オブジェクトからアップロード関連設定を取得
const mediaSettings = editorConfig.imageUploadOptions;
const csrfValue = document.querySelector('[name="csrf_token"]')?.value || '';

// フォーム要素の生成ロジックをモジュール化
function createUploadFormContainer(targetIframeName) {
  const formMarkup = `
    <form action="${mediaSettings.uploadEndpoint}" 
          target="${targetIframeName}" 
          method="POST" 
          enctype="multipart/form-data" 
          class="editor-media-form">
      <input type="hidden" name="_csrf" value="${csrfValue}" />
      <label for="file-selector">${lang.fileSelect}</label>
      <div class="input-wrapper">
        <input type="file" id="file-selector" name="media-file" accept="image/*" required />
        <button type="submit" class="btn-submit">${lang.submit}</button>
      </div>
    </form>`;
  return formMarkup;
}

2. アップロード実行および結果受信ハンドラ

async function handleMediaSubmission(event) {
  event.preventDefault();
  
  const submitBtn = event.target.querySelector('.btn-submit');
  submitBtn.disabled = true;
  
  // iframe への転送はせず、FormData を親コンテキストで直接処理
  const formData = new FormData(event.target);
  const filePayload = formData.get('media-file');
  
  try {
    const apiResponse = await fetch(mediaSettings.uploadEndpoint, {
      method: 'POST',
      headers: {
        'X-CSRF-TOKEN': document.querySelector('[name="_csrf"]').value
      },
      body: filePayload
    });
    
    if (!apiResponse.ok) throw new Error(`HTTP ${apiResponse.status}`);
    
    const payload = await apiResponse.json();
    
    // レスポンス構造に合わせて UI フィールドを更新
    if (payload.status === 'success') {
      updateEditorField('[data-image-url]', payload.assetUrl);
    } else {
      displayErrorMessage(payload.detail ?? 'Upload failed');
    }
  } catch (networkError) {
    console.error('Media transfer interrupted:', networkError);
    displayErrorMessage('Network communication error');
  } finally {
    submitBtn.disabled = false;
  }
}

function updateEditorField(selector, value) {
  const inputEl = document.querySelector(selector);
  if (inputEl) inputEl.value = value;
}

function displayErrorMessage(msg) {
  const dialogNode = document.querySelector('.media-dialog');
  const errorArea = dialogNode?.querySelector('.status-alert');
  if (errorArea) {
    errorArea.textContent = msg;
    errorArea.style.display = 'block';
  }
}

3. イベント購読および初期化統合

function initializeMediaHandler(dialogRootElement) {
  const formNode = dialogRootElement.querySelector('form.editor-media-form');
  if (!formNode) return;
  
  // リスナ登録
  formNode.addEventListener('submit', handleMediaSubmission);
  
  // ファイル選択時プレビュー用ローディング状態切替
  const fileInput = formNode.querySelector('input[type="file"]');
  fileInput.addEventListener('change', (e) => {
    setLoadingState(e.target.files.length > 0);
  });
}

function setLoadingState(isActive) {
  const overlay = document.querySelector('.dialog-loader');
  if (overlay) overlay.classList.toggle('visible', isActive);
}

実装のポイント

  • コンテキスト統合: iframe 内のレスポンス監視を廃止し、イベントバブリング経由で親ページ上の FormData を直接取得する設計に変更しました。
  • CORS 安全な通信: `fetch` API と `FormData` の組み合わせにより、自動でマルチパートエンコーディングが適用され、CORS プリフライトリクエストが必要な複雑なヘッダー定義を必要としません。
  • セキュリティ強化: 認証情報を hidden input から抽出して `headers` に明示的に付与し、古い`JSON.parse(string)` や `eval()` による動的評価を排除しました。
  • 状態管理の分離: UI 描画更新(値の代入)とネットワーク通信(リトライ・エラーハンドリング)を責任領域ごとに分割し、保守コストを低減しています。

タグ: editormd Cross-Origin-Resource-Sharing Same-Origin-Policy javascript Fetch-API

5月22日 02:11 投稿