この記事は Mapbox Advent Calendar 2024 の5日目の記事です。
コンテンツセキュリティポリシー (CSP)
コンテンツセキュリティポリシーとは、特定の種類の攻撃による影響を軽減するために追加できるセキュリティのしくみです。クロスサイトスクリプティング (XSS) をはじめとする様々なウェブセキュリティ脆弱性への対策として有効です。レスポンスヘッダーや <meta>
要素にポリシーを指定することで有効になります。
Mapbox 公式ドキュメントでも、Mapbox GL JS を使う際に指定すべき CSP ディレクティブが明示されています。
ディレクティブ | ソース | 説明 |
---|---|---|
worker-src |
blob: |
Worker 、SharedWorker 、ServiceWorker スクリプトのソースを 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-src
、child-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/CSS と JavaScript を与えてやれば <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'
が必要になってしまいます(これ、ウェブワーカーとして実行する場合には制限にかからないのですが、どういうしくみでしょうね)。
このテクニックを使用した、続きの記事を書きたいと思いますので、お楽しみに!