WebアプリケーションにおけるCORS(Cross-Origin Resource Sharing)の理解と実装

Webアプリケーション開発において、異なるドメイン間でのリソースアクセスは日常的に発生する要件である。しかし、ブラウザのセキュリティ機構である同一生成元ポリシー(Same-Origin Policy)の存在により、デフォルトではこのようなクロスドメインアクセスは制限される。本稿では、まず同一生成元ポリシーの目的在于と重要性を解説し、その後、実際の開発現場で可用されるクロスドメイン解決策を体系的に説明する。

同一生成元ポリシーの必要性

ブラウザが同一生成元ポリシーを実装している背景には、2つの主要なセキュリティ上の脅威を阻止する目的がある。

Cookie窃取による認証バイパス

Webアプリケーションでは、セッション管理や認証状態の確認にCookieが広く利用されている。ユーザーがサービスにログインすると、サーバーは応答ヘッダにSet-Cookieを含め、ブラウザに認証情報を保存させる。次回以降のリクエストにおいて、ブラウザはこのCookieを自動的にリクエストに付与し、サーバーとのセッションを維持する。

同一生成元ポリシーが存在しない状況を仮定しよう。ユーザーがオンラインモールにログインし、购物かごを確認していると仮定する。その後、ユーザーが悪意のある 사이트を訪問した場合、そのサイトはユーザーに気づかれることなく、正規のモールに対してリクエストを送信できる可能性がある。この場合、ブラウザは自動的に認証Cookieを添付するため、攻撃者はユーザーの権限で任意の操作を実行できてしまう。これはCSRF(Cross-Site Request Forgery)攻撃の一形態であり、同一生成元ポリシーがないと深刻なセキュリティリスクとなる。

DOM操作による情報窃取

同一生成元ポリシーは、異なる生成元からのDOM操作も制限している。フィッシング攻撃のシナリオを想象してほしい。ユーザーがオンラインバンキングにログインしようとしているとき、攻撃者が作成した類似の网站にアクセスする可能性がある。攻撃者のサイトは、正規の銀行のログインページをiframeで読み込み、同一生成元ポリシーがなければ、入力フィールドからユーザーの認証情報を窃取できてしまう。

以下は、同一生成元ポリシーが存在しない場合の基本的な攻撃コードである:

<iframe name="bankFrame" src="https://secure-bank.example.com/login"></iframe>
<script>
// フィッシングサイトはこのようにして、正規サイトの入力フィールドにアクセスできる
const targetFrame = window.frames['bankFrame'];
const credentialInput = targetFrame.document.querySelector('input[type="password"]');
const usernameInput = targetFrame.document.querySelector('input[type="text"]');

// 窃取した情報を外部のサーバーに送信
sendStolenData({
  username: usernameInput.value,
  password: credentialInput.value
});
</script>

このような脅威を防ぐため、同一生成元ポリシーはブラウザの重要なセキュリティ基盤として機能している。ただし、この制限は正当なクロスドメイン通信を完全に禁止するものではなく、適切な 방법을すれば回避可能である。

クロスドメインアクセスの実装手法

以降は、実務で频繁に活用されるクロスドメイン解決策について、顺次的に解説する。演示环境として、前端アプリケーションをhttp://localhost:9099で、后端APIサーバーをhttp://localhost:9971で稼働させる状況を假设する。

JSONP(JSON with Padding)

script要素やimg要素など、一部のHTMLタグはクロスドメイン制限の適用外である。JSONPはこの特性を活用し、スクリプト読み込みによってデータを受信する手法である。

后端実装の例(Koa2フレームワーク使用):

const { createResponse } = require('../utils/response-builder');

class CrossOriginController {
  static async handleJsonpRequest(ctx) {
    const requestParams = ctx.request.query;
    
    // クライアントに識別子を設定
    ctx.cookies.set('authToken', 'abc123');
    
    // コールバック関数名を取得し、JSONデータをに包んで返回
    const callbackName = requestParams.callback;
    const responseData = createResponse(
      { serverMessage: requestParams.message },
      'success'
    );
    
    ctx.body = `${callbackName}(${JSON.stringify(responseData)})`;
  }
}

module.exports = CrossOriginController;

前端でのJSONP呼び出し実装:

/**
 * JSONPリクエストのヘルパー関数
 * @param {string} endpoint - APIエンドポイントURL
 * @param {Object} payload - 送信データ
 * @returns {Promise<Object>} API応答データ
 */
const jsonpClient = async (endpoint, payload) => {
  const serializeData = (data) => {
    return Object.entries(data)
      .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
      .join('&');
  };
  
  return new Promise((resolve, reject) => {
    const callbackId = `jsonp_callback_${Date.now()}`;
    const scriptElement = document.createElement('script');
    
    // コールバック関数をグローバルに定義
    window[callbackId] = (response) => {
      document.body.removeChild(scriptElement);
      delete window[callbackId];
      resolve(response);
    };
    
    // スクリプトのsrcを設定してリクエストを送信
    const params = serializeData(payload);
    scriptElement.src = `${endpoint}?${params}&callback=${callbackId}`;
    document.body.appendChild(scriptElement);
  });
};

// 利用例
jsonpClient('http://localhost:9871/api/jsonp', {
  message: 'helloJsonp'
}).then(response => {
  console.log('JSONP応答:', response);
});

JSONPは実装が简单であるが、GETリクエストのみをサポートするという本质的な制限がある。また、コールバック関数名を外部から指定できるため、JSONPの応答を恶意のあるスクリプトとして実行される风险も存在する。

動的iframeとフォームによるPOSTリクエスト

JSONPがGETのみをサポートするのに対し、POSTメソッドを使用する必要がある 경우에는、動的に生成するiframeとフォームを組み合わせる手法が有効である。

后端の简单なPOSTハンドラ:

const { createResponse } = require('../utils/response-builder');

class CrossOriginController {
  static async handleIframePost(ctx) {
    const postedData = ctx.request.body;
    console.log('受信したデータ:', postedData);
    
    ctx.body = createResponse(
      { receivedData: postedData },
      'success'
    );
  }
}

module.exports = CrossOriginController;

iframeを経由したPOSTリクエストの前端実装:

/**
 * iframeを使用したPOSTリクエスト送信
 * @param {string} targetUrl - 送信先URL
 * @param {Object} postData - 送信データ
 * @returns {Promise<void>}
 */
const postViaIframe = async (targetUrl, postData) => {
  const iframeName = `post_frame_${Math.random().toString(36).slice(2)}`;
  const hiddenIframe = document.createElement('iframe');
  
  hiddenIframe.name = iframeName;
  hiddenIframe.style.display = 'none';
  document.body.appendChild(hiddenIframe);
  
  return new Promise((resolve) => {
    hiddenIframe.addEventListener('load', () => {
      console.log('POSTリクエストが完了しました');
      document.body.removeChild(hiddenIframe);
      resolve();
    });
    
    const formElement = document.createElement('form');
    formElement.action = targetUrl;
    formElement.method = 'POST';
    formElement.target = iframeName;
    formElement.style.display = 'none';
    
    // データを入力フィールドに変換してフォームに追加
    Object.entries(postData).forEach(([key, value]) => {
      const inputElement = document.createElement('input');
      inputElement.type = 'hidden';
      inputElement.name = key;
      inputElement.value = String(value);
      formElement.appendChild(inputElement);
    });
    
    document.body.appendChild(formElement);
    formElement.submit();
    document.body.removeChild(formElement);
  });
};

// 利用例
postViaIframe('http://localhost:9871/api/iframePost', {
  content: 'helloIframePost'
}).then(() => {
  console.log('送信完了');
});

この手法は、同一生成元ポリシーのiframe例外规定を利用している。form要素のtarget属性にiframeの名前を指定することで、フォームの提交結果をiframe内で処理し、页面迁移を回避できる。

CORS(Cross-Origin Resource Sharing)

W3C标准として策定されたCORSは、サーバー侧のヘッダ设定によってクロスドメインアクセスを許可する最も标准的な手法である。CORSリクエストは、简单リクエスト(Simple Request)と非简单リクエスト(Preflighted Request)の2种类に分類される。

简单リクエストの处理

简单リクエストは、以下の条件を満たすHTTPリクエストである:

  • HTTPメソッドがGET、HEAD、またはPOSTのいずれかであること
  • 手动設定可能なヘッダがAccept、Accept-Language、Content-Language、Content-Type(ただし特定の值のみ)、Last-Event-IDに制限されること
  • Content-Typeヘッダがapplication/x-www-form-urlencoded、multipart/form-data、またはtext/plainのいずれかであること

简单リクエストの后端実装例:

const { createResponse } = require('../utils/response-builder');

class CrossOriginController {
  static async handleSimpleCors(ctx) {
    const queryParams = ctx.request.query;
    
    // ワイルドカードを使用した全生成元からのアクセス許可
    ctx.set('Access-Control-Allow-Origin', '*');
    ctx.cookies.set('authToken', 'simple-request-token');
    
    ctx.body = createResponse(
      { responseMessage: queryParams.message },
      'success'
    );
  }
}

module.exports = CrossOriginController;

简单リクエストの場合、前端侧では特別な处理は必要なく、通常のfetch APIやXMLHttpRequestで请求を送信できる。

非简单リクエストの处理

非简单リクエストは、手动で设定されるHTTPヘッダが比较多いたり、GET/POST/HEAD以外のメソッドを使用したりするリクエストである。这类リクエストでは、実際の请求 전에ブラウザが「プリフライト」(事前確認)リクエストをサーバーに送信する。

非简单リクエストの后端実装:

const { createResponse } = require('../utils/response-builder');

class CrossOriginController {
  static async handlePreflightedCors(ctx) {
    const queryParams = ctx.request.query;
    
    // Cookieを含むリクエストを許可するため、具体的な生成元を指定
    ctx.set('Access-Control-Allow-Origin', 'http://localhost:9099');
    ctx.set('Access-Control-Allow-Credentials', 'true');
    
    // 許可するHTTPメソッドを宣言
    ctx.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
    
    // 許可する手动ヘッダを宣言
    ctx.set('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Custom-Header');
    
    ctx.cookies.set('authToken', 'preflight-token');
    
    ctx.body = createResponse(
      { responseMessage: queryParams.message },
      'success'
    );
  }
}

module.exports = CrossOriginController;

Koa2アプリケーション全体でのCORS設定:

const Koa = require('koa');
const path = require('path');
const serveStatic = require('koa-static');
const parseBody = require('koa-bodyparser');
const routing = require('./routes');
const corsMiddleware = require('koa2-cors');

const app = new Koa();
const PORT = 9871;

app.use(parseBody());
app.use(serveStatic(path.resolve(__dirname, '../dist')));

// CORS middlewareの設定
app.use(corsMiddleware({
  origin: (incomingRequest) => {
    // 特定の生成元のみを許可
    return incomingRequest.headers.origin === 'http://localhost:9099'
      ? incomingRequest.headers.origin
      : '';
  },
  credentials: true,
  allowMethods: ['GET', 'POST', 'DELETE', 'PUT'],
  allowHeaders: ['Content-Type', 'Accept', 'Custom-Header']
}));

app.use(routing.routes());
app.use(routing.allowedMethods());

app.listen(PORT, () => {
  console.log(`[System] API Server running on port ${PORT}`);
});

前端からCookieを含む非简单リクエストを送信する場合:

// 非简单リクエストの发送
fetch('http://localhost:9871/api/cors?message=helloPreflight', {
  method: 'GET',
  credentials: 'include',
  headers: {
    'Custom-Header': 'additional-header-value'
  }
}).then(response => {
  return response.json();
}).then(data => {
  console.log('CORS応答:', data);
}).catch(error => {
  console.error('リクエストエラー:', error);
});

リバースプロキシによる解决方案

Webサーバー(特にNginx)のリバースプロキシ機能を活用すれば、ブラウザからの请求を同一生成元内に集約し、背後にある異なるバックエンドサービスに转发できる。この手法は、客户端侧で特別な处理が不要なため、実装コストが低い。

Nginxの設定例:

server {
    listen 9099;
    server_name localhost;
    
    # /api配下の全てのリクエストをバックエンドサーバーに転送
    location /api/ {
        proxy_pass http://localhost:9871/;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
    
    # 静的ファイル配信
    location / {
        root /var/www/html/dist;
        index index.html;
    }
}

この設定により、http://localhost:9099/api/usersへのリクエストは、自動的にhttp://localhost:9871/usersに转发される。ブラウザにとっては同一生成元からのリクエストとなるため、CORS制限の対象外となる。

前端侧での通常のAPI调用:

// Nginx转发后的API调用
fetch('/api/iframePost', {
  method: 'POST',
  headers: {
    'Accept': 'application/json',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    content: 'helloViaProxy'
  })
}).then(response => response.json())
  .then(data => console.log('プロキシ応答:', data));

ただし、この手法は服务器サイドの設定変更が必要なため、パブリックな第三方API(天気情報など)を客户端から直接呼び出す場合には不向きである。公共APIへのアクセスには、CORS対応の服务器を用意するか、专用のプロキシサービスを间引く必要がある。

異なる窗口间の通信手法

同一生成元ポリシーは異なる生成元间でのDOMアクセスを制限するが、HTML5で導入されたpostMessage APIを使用すれば、安全に異なる窗口间でメッセージを交换できる。

postMessage APIの活用

postMessageは、異なる生成元の页面间でもメッセージ传递を可能にする标准APIである。发送元は、接收先の生成元を明示的に指定できるため、意図しない第三方への情報漏洩を防げる。

メッセージ发送页面(http://localhost:9099/cross-domain-demo):

<template>
  <div class="messenger-container">
    <button @click="dispatchMessage">
      http://crossdomain.local:9099にメッセージを送信
    </button>
    <iframe 
      name="remoteFrame" 
      src="http://crossdomain.local:9099"
      class="hidden-frame"
    ></iframe>
  </div>
</template>

<script>
export default {
  mounted() {
    // メッセージ受信 이벤트 ハンドラ
    window.addEventListener('message', (event) => {
      // 送信元の生成元を 반드시検証する
      if (event.origin === 'http://crossdomain.local:9099') {
        console.log('对方からの応答:', event.data);
      }
    });
  },
  methods: {
    async dispatchMessage() {
      const targetWindow = window.frames['remoteFrame'];
      
      // 第二引数で宛先生成元を制限
      targetWindow.postMessage(
        {
          sender: 'http://localhost:9099',
          query: '那边是否有ID为app的元素?'
        },
        'http://crossdomain.local:9099'
      );
    }
  }
}
</script>

メッセージ受信页面(http://crossdomain.local:9099):

<template>
  <div class="receiver-container">
    <p>我是http://crossdomain.local:9099</p>
    <div id="app">アプリケーションルート</div>
  </div>
</template>

<script>
export default {
  mounted() {
    window.addEventListener('message', (event) => {
      // 生成元検証はセキュリティ上必须
      if (event.origin === 'http://localhost:9099') {
        const sender = event.source;
        const origin = event.origin;
        
        console.log('接收到的消息:', event.data);
        
        // 发送元に応答を返却
        const targetElement = document.getElementById('app');
        sender.postMessage({
          from: 'http://crossdomain.local:9099',
          result: targetElement ? '存在ID为app的元素' : '不存在该元素'
        }, origin);
      }
    });
  }
}
</script>

postMessage使用時の注意事项として、event.originの検証は必ず実装すべきである。これを怠ると、悪意のある第三方からのメッセージを処理してしまう风险が生じる。

document.domainによるサブドメイン間通信

同一メインドメインの異なるサブドメイン间でのiframe通信には、document.domainの设定が有効である。例えば、example.comの配下のdifferent.example.comとchild.example.comは、双方でdocument.domainをexample.comに設定することで、windowオブジェクトへのアクセスが可能となる。

// 双方の页面で以下を設定
document.domain = 'example.com';

// これにより、サブドメイン間のiframeアクセスが许可される
const parentWindow = window.parent;
const siblingFrame = window.frames['sibling-iframe'];

Canvas画像操作時のクロスドメイン问题

Canvas要素にロードされた画像に対してgetImageDataやtoDataURLを呼び出す場合、画像がクロスドメインから로드されたものでると、セキュリティエラー(Tainted Canvas)が発生。这类问题は、サーバー側で適切なCORSヘッダを設定し、クライアント側でcrossOrigin属性を正しく指定することで解决できる。

画像ロードの正しい実装:

const loadImageToCanvas = (imageUrl, canvasElement) => {
  return new Promise((resolve, reject) => {
    const ctx = canvasElement.getContext('2d');
    const img = new Image();
    
    // CORS许可を要求する
    img.crossOrigin = 'Anonymous';
    
    img.onload = () => {
      canvasElement.width = img.width;
      canvasElement.height = img.height;
      ctx.drawImage(img, 0, 0);
      resolve(canvasElement);
    };
    
    img.onerror = reject;
    img.src = imageUrl;
  });
};

// 利用例
const canvas = document.getElementById('imageCanvas');
loadImageToCanvas('https://other-domain.example.com/image.png', canvas)
  .then(() => {
    // 画像データの取得が可能
    const imageData = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height);
    console.log('画像データを取得しました');
  });

服务器侧では、以下のようなCORSヘッダを設定する必要がある:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

まとめ

本稿では、Webアプリケーションにおけるクロスドメインアクセスの理论基础と実践的な解决方案について详述した。同一生成元ポリシーは、ユーザー 보호のための重要なセキュリティ機構であり、これを绕过することは慎重に行う必要がある。

实际の开发においては、用途に応じて適切な手法を選択することが重要である。简单なGETリクエストの場合はJSONPやCORSが适しており、POSTメソッドが必要であればCORSまたはiframe+formの手法が适している。服务器设定の変更が可能な环境下では、リバースプロキシが最も简洁な解决方案となる场合も多い。異なる窗口间の複雑な通信需求には、postMessage APIが强大的なツールとなる。

これらの技術を適切に组合せることで、セキュリティを牺牲にすることなく、モダンなWebアプリケーションの要件を満たすことが可能となる。

タグ: CORS Same-Origin-Policy JSONP javascript nginx

5月16日 10:14 投稿