GAS『Cross-Origin Error』の原因と対処法

GAS『Cross-Origin Error』の原因と対処法

ブラウザから別オリジンのGoogle Apps Script(Webアプリ)や外部サイトへアクセスしたとき、CORS/同一生成元ポリシー、X-Frame-Options、Mixed Contentなどの制限でブロックされる。Apps ScriptはHTTPレスポンスヘッダを自由に設定できないため、UIの配置と通信方式の設計で回避するのが確実。

発生条件(症状の切り分け)

・外部サイトのフロントエンド(example.com)から、GASのWebアプリURL(script.google.com)にfetch/XHRでアクセス

・オリジンが異なるためプリフライト(OPTIONS)が発生し、CORSヘッダ不足で失敗

・GASのHTMLを他ドメインでiframe埋め込み → X-Frame-Options違反やpostMessage未実装で通信不可

・HTTPSページからHTTPにアクセス → Mixed Contentでブロック

・Cookie/認証つきのクロスオリジン → ブラウザの制約で遮断

結論先取り:設計の方針

・「GASのUIはGASで出し、サーバ呼び出しはgoogle.script.runで完結」=同一オリジン化

・外部サイトからGASを直叩きしない。必要なら「プロキシ(Cloud Run/Functions等)」を用意

・どうしてもブラウザ直叩きなら「プリフライトが要らないシンプルリクエスト」に寄せる

・埋め込みはHtmlServiceのXFrameOptions許可+postMessageで橋渡し

対処1:HtmlService + google.script.run で同一オリジン化

function doGet() {
  return HtmlService
    .createHtmlOutputFromFile('index')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.SAMEORIGIN); // 基本は同一オリジン
}

// index.html(GASが配信するUI内でサーバ関数を呼ぶ)
<script>
  function sendData() {
    google.script.run
      .withSuccessHandler(resp => console.log(resp))
      .serverSide({foo: 'bar'});
  }
</script>

・GAS配信のHTML内ならCORSは発生しない。最も安定する構成。

対処2:プリフライト回避(Simple Requestに寄せる)

・HTTPメソッドはGET/POSTのみ。

・ヘッダはカスタムを付けない(Authorization等を使わない)。

・Content-Typeは「application/x-www-form-urlencoded」「text/plain」「multipart/form-data」のいずれか。

・BodyはURLエンコードかFormDataで渡し、GAS側ではe.parameterで受け取る。

/** GAS側:form-urlencodedを受け取る */
function doPost(e) {
  var p = e.parameter || {};
  var name = p.name || '';
  return ContentService.createTextOutput('ok:' + name)
    .setMimeType(ContentService.MimeType.TEXT);
}

/** フロント側:プリフライトを起こさないPOST */
fetch('https://script.google.com/macros/s/DEPLOY_ID/exec', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({ name: 'Alice' }) // 文字列化必須
}).then(r => r.text()).then(console.log);

・カスタムヘッダやapplication/jsonを外すと、多くのケースでOPTIONSが発生しない。

対処3:iframe埋め込み+postMessage(画面は外部、処理はGAS)

・GAS側ページをiframeで読み込み、親⇄子をpostMessageで連携。

・GASページはXFrameOptionsを許可(ALLOWALL / SAMEORIGIN)。

// GAS側
function doGet() {
  return HtmlService.createHtmlOutputFromFile('embed')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

// 親ページ(外部サイト)
<iframe id="gas" src="https://script.google.com/macros/s/DEPLOY_ID/exec"></iframe>
<script>
  const frame = document.getElementById('gas').contentWindow;
  window.addEventListener('message', ev => {
    if (ev.origin.endsWith('script.google.com')) {
      console.log('from GAS:', ev.data);
    }
  });
  // 送信
  frame.postMessage({cmd:'ping'}, 'https://script.google.com');
</script>

・XHRは使わず、表示+メッセージ通信で連携するアプローチ。

対処4:プロキシを用意(Cloud Run/Functions等でCORSヘッダ付与)

・外部サイト →(ブラウザ)→ プロキシ(自社ドメイン・CORS許可)→(サーバ間)→ GAS/他API。

・プロキシはAccess-Control-Allow-Origin/Methods/Headers/Credentials等を正しく返す。

・Apps Script側はUrlFetchAppでプロキシからサーバ間で呼ぶ設計に切替えるのも手。

対処5:GASから外部APIへはサーバ側(UrlFetchApp)で実行

・ブラウザではなくGASサーバ側で外部APIを呼び出せば、CORSは関係ない(サーバ間通信)。

・クライアントはgoogle.script.runでGASを叩き、結果のみを受け取る。

function callExternal() {
  var res = UrlFetchApp.fetch('https://api.example.com/data');
  return res.getContentText();
}

function doGet() {
  return HtmlService.createHtmlOutput('<script>google.script.run.withSuccessHandler(alert).callExternal()</script>');
}

対処6:Mixed Contentと資格情報の落とし穴

・HTTPSページからHTTPにアクセスするとMixed Contentでブロック。双方HTTPSに統一。

・cookie/credentialを伴うクロスオリジンfetchは厳しい。ブラウザ制約とCORSが揃って必要。

対処7:GASでCORSヘッダを付与できない前提で設計

・ContentService/HtmlServiceから任意レスポンスヘッダを設定するAPIはない。

・「GASにCORSヘッダを付ければ解決」は基本不可能。前述の同一オリジン化/プロキシ/プリフライト回避で対処。

デバッグ:実際に送って結果を可視化

/** 受信ログを確認(何が届いているか) */
function doPost(e) {
  console.log(JSON.stringify({
    type: e && e.postData && e.postData.type,
    length: e && e.postData && e.postData.length,
    params: e && e.parameter
  }));
  return ContentService.createTextOutput('ok').setMimeType(ContentService.MimeType.TEXT);
}

・ブラウザのネットワークタブでRequest/Response/Consoleのエラー種別(CORS/X-Frame/Mixed)を確認。

NG→OK早見表

・NG:外部サイトからGASにapplication/json+カスタムヘッダでPOST → OK:x-www-form-urlencodedでシンプルPOST

・NG:外部SPAがGASをXHR直叩き → OK:Cloud Run等のプロキシ経由でCORS許可

・NG:GASのページを他ドメインにiframe埋め込み、内部でXHR → OK:XFrameOptions許可+postMessageで連携

・NG:フロントから直接外部API → OK:GAS(サーバ)経由でUrlFetchAppし、結果だけ返す

最小テンプレ(シンプルPOSTでプリフライト回避)

/** GAS */
function doPost(e) {
  var p = e.parameter || {};
  return ContentService.createTextOutput('hello ' + (p.name || ''))
    .setMimeType(ContentService.MimeType.TEXT);
}

/** フロント(別ドメイン) */
const url = 'https://script.google.com/macros/s/DEPLOY_ID/exec';
fetch(url, {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({ name: 'Taro' })
}).then(r => r.text()).then(console.log).catch(console.error);

チェックリスト(導入前に)

・UIはGAS側で提供できるか?できるならgoogle.script.runに寄せる

・外部サイトから直叩きが必要か?必要ならプリフライト回避の条件を満たしているか

・iframe埋め込みならXFrameOptionsとpostMessageの設計があるか

・プロキシ(Cloud Run/Functions/Workers)の用意でCORSを自前制御できるか

・HTTPS/同一ドメインで統一できているか(Mixed Content回避)

・Cookie/認証を絡めたクロスオリジン要求が本当に必要か(設計変更の余地を検討)