CSP で制限されたページで Mapbox GL JS を使うには

この記事は Mapbox Advent Calendar 2024 の5日目の記事です。

コンテンツセキュリティポリシー (CSP)

コンテンツセキュリティポリシーとは、特定の種類の攻撃による影響を軽減するために追加できるセキュリティのしくみです。クロスサイトスクリプティング (XSS) をはじめとする様々なウェブセキュリティ脆弱性への対策として有効です。レスポンスヘッダーや <meta> 要素にポリシーを指定することで有効になります。

Mapbox 公式ドキュメントでも、Mapbox GL JS を使う際に指定すべき CSP ディレクティブが明示されています

ディレクティブ ソース 説明
worker-src blob: WorkerSharedWorkerServiceWorker スクリプトのソースを blob: に限定
child-src blob: ウェブワーカーと <frame><iframe> などの要素を使用して読み込んだコンテンツのソースを blob: に限定
img-src data: blob: 画像やファビコンのソースを data:blob: に限定
connect-src https://*.tiles.mapbox.com https://api.mapbox.com https://events.mapbox.com fetch()XMLHttpRequest などのスクリプトインターフェイスを使用して読み込むことができる URL を限定

厳格な CSP 環境では

各ディレクティブに少なくとも上記のソースが指定されていれば Mapbox GL JS は問題なく動作するというものですが、このうち worker-srcchild-src で必要な blob: の指定は、環境によっては許可されていない場合があります。

Content Security Policy Level 2 仕様の 4.2.2.1 の項に記載がありますが、blob: のような URL が指すコンテンツは、レスポンスボディから派生したり、Document コンテキストで実行されたりするため、安全でない可能性があるためです。blob: を許可することは、'unsafe-eval' と等価とされています。eval() がなんとなく危険、というのは聞いたことがありますよね?

Mapbox GL JS は通常、1つの JavaScript ファイル内でウェブワーカーを生成し、Blob を使って読み込むため、このような環境ではそのままでは動作しません(ウェブワーカーは UI 操作を邪魔することなくバックグラウンドでタイルのローディングなどを行うために使われる)。

そこで、mapbox-gl.js の代わりにウェブワーカーコードを除いた mapbox-gl-csp.js ファイルを使用し、ワーカーファイル mapbox-gl-csp-worker.js へのパスを別途手動で設定する必要があります。ワーカーファイルは同一オリジンポリシーに従わなければならないことに注意してください。つまり、mapbox-gl-csp-worker.js はそれを読み込むページと同じオリジンから提供されなければなりません。

<script src="https://api.mapbox.com/mapbox-gl-js/v3.8.0/mapbox-gl-csp.js"></script>
<script>
mapboxgl.workerUrl = 'https://api.mapbox.com/mapbox-gl-js/v3.8.0/mapbox-gl-csp-worker.js';
/* ... */
</script>

さらに特殊な環境では

さて、ここまでは公式ドキュメントにも説明がありますし、ほとんどの環境ではこれ以上の対応は必要ないかと思います。しかし、ここからが本番です(笑)。

私の目の前にあるのは、 HTML/CSSJavaScript を与えてやれば <iframe> の中で動的なページを表示できる、いわゆるサンドボックス環境です。ここでは、埋め込みページの <meta> 要素で、child-src 'none'(そして worker-src は無し、つまり child-src にフォールバック)という CSP が適用されており、これは変更することはできません。その上、JavaScript 実行時に渡ってくる window オブジェクトからは、律儀なことに Worker クラスが消されています。意地でもウェブワーカーを使わせないぞという強い意志を感じます。

それでも Mapbox GL JS を使って地図を表示したいので、色々こねくり回して作り上げたコードがこちらです。これを動かすには、https://api.mapbox.com/mapbox-gl-js/v3.8.0/mapbox-gl-csp-worker.js の中身と、Mapbox アクセストークンをそれぞれ指定の場所に、コピペしておく必要があります。

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src http: https: 'unsafe-inline'; style-src http: https: 'unsafe-inline'; img-src http: https: data:; font-src http: https:; connect-src http: https:; media-src http: https:; object-src 'none'; child-src 'none'; frame-src 'none'">
<title>Mapbox GL JS map</title>
<meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no">
<link href="https://api.mapbox.com/mapbox-gl-js/v3.8.0/mapbox-gl.css" rel="stylesheet">
<script src="https://api.mapbox.com/mapbox-gl-js/v3.8.0/mapbox-gl-csp.js"></script>
<style>
body { margin: 0; padding: 0; }
#map { position: absolute; top: 0; bottom: 0; width: 100%; }
</style>
</head>
<body>
<div id="map"></div>
<script>
mapboxgl.workerClass = (function(window) {
  const WW_CONTEXT_WHITELIST = [
    'createImageBitmap', 'fetch', 'ImageBitmap', 'ImageData',
    'OffscreenCanvas', 'Request', 'AbortController'
  ];

  return function() {
    const me = this;
    let worker_context;

    // Allow main thread to specify event listeners
    const ui_listeners = {};
    this.addEventListener = (event_name, fn) => {
      // listen for events from worker thread
      if (!ui_listeners[event_name])
        ui_listeners[event_name] = [];
      ui_listeners[event_name].push(fn);
    };

    // onmessage handler
    this.addEventListener('message', e => {
      if (typeof me.onmessage !== 'undefined') {
        me.onmessage(e);
      }
    });

    function WorkerGlobalScope() {}

    /**** Worker context accessible to worker *****/
    function WorkerContext() {
      const worker_listeners = {};
      this.addEventListener = (event_name, fn) => {
        // listen for events from UI thread
        if (!worker_listeners[event_name])
          worker_listeners[event_name] = [];
        worker_listeners[event_name].push(fn);
      };

      // onmessage handler
      this.addEventListener('message', e => {
        if (typeof worker_context.onmessage !== 'undefined') {
          try {
            worker_context.onmessage(e);
          } catch (error) {
            triggerEvent(ui_listeners, 'error', error, true);
          }
        }
      });

      this.postMessage = msg => {
        triggerEvent(ui_listeners, 'message', msg);
      };

      this.__processPostMessage = msg => {
        triggerEvent(worker_listeners, 'message', msg);
      };

      this.close = () => {};

      // window context
      for (const p of WW_CONTEXT_WHITELIST) {
        if (typeof window[p] === 'function' && !window[p].prototype) {
          this[p] = window[p].bind(window);
        } else {
          this[p] = window[p];
        }
      }
      this.WorkerGlobalScope = WorkerGlobalScope;
    }
    WorkerContext.prototype = new WorkerGlobalScope();
    worker_context = new WorkerContext();
    this.postMessage = msg => {
      worker_context.__processPostMessage(msg);
    };

    this.terminate = () => {};

    function triggerEvent(listeners_map, event_name, event_data, no_wrapping) {
      const event_obj = no_wrapping ? event_data : {data: event_data};

      if (!listeners_map[event_name]) return;
      for (const listener of listeners_map[event_name]) {
        listener(event_obj);
      }
    }

    const mask = {};

    // worker context
    for (const p in worker_context) {
      mask[p] = worker_context[p];
    }
    // set self context
    mask['self'] = worker_context;

    with(mask) {
      /* ここに https://api.mapbox.com/mapbox-gl-js/v3.8.0/mapbox-gl-csp-worker.js をコピペ */
    }
  }
})(window);

mapboxgl.accessToken = 'ここに Mapbox アクセストークンを入れる';

const map = new mapboxgl.Map({
  container: 'map',
  center: [139.7670, 35.6814],
  zoom: 16,
  pitch: 60
});
</script>
</body>
</html>

これ、一体何をしているかというと、Worker クラスが存在しないので、mapbox-gl-csp-worker.js を内蔵する擬似 Worker クラスを作って、メインスレッドで動かしてしまうという力技です。ウェブワーカー未対応のブラウザで Worker コードを動かすためのこのライブラリを参考にしました。

ウェブワーカーと違ってマルチスレッドで動かないのでややぎこちないですが、動作としてはほぼ問題ないのではないでしょうか。惜しいのは、Mapbox GL JS v3 で導入された 3D ランドマークに使われている batched-model が表示されないところですね。これはローディングに WebAssembly コードを使っているため、script-src 'unsafe-eval' が必要になってしまいます(これ、ウェブワーカーとして実行する場合には制限にかからないのですが、どういうしくみでしょうね)。

このテクニックを使用した、続きの記事を書きたいと思いますので、お楽しみに!