2017年11月28日火曜日

WebExtensionを用いてスクリーンキャプチャを行うAPIを構築する

WEBブラウザにおいて, 現在表示中のWEBページを画像化(スクリーンキャプチャ)する手段はセキュリティの観点から存在しない. そのため, html2canvas等のHTMLDOMのレンダラを用いて無理やり画像化する方法が一般的である.

一方WebExtension機構を利用してよいのであれば, 不可能ではない. そこで本記事では通常のスクリプトコンテキストからWebExtensionを利用可能なアドオンコンテキストを介してスクリーンキャプチャ画像を入手する方法を探る.

※ブラウザとしてはFireFoxを利用した. Chrome等の他のブラウザでは適宜コードを読み替える必要がある.

なお, 本記事で作成したアドオンは使い方によっては深刻なセキュリティリスクを孕むため, ローカル(私用アドオン)利用を原則とし, 一般公開してはならない.


方針


WebExtension仕様で作成したアドオン動作環境を含めると, WEBページには
  • windowコンテキスト(通常のスクリプト)
  • content_scriptコンテキスト
  • backgroundコンテキスト
の3つのスクリプトの動作コンテキストがある.

この内, 実際にスクリーンキャプチャを行うのはbackgroundコンテキストである. そのため, windowコンテキストがキャプチャ画像を入手するには

処理リクエスト→window→content_script→background→content_script→window→キャプチャ画像

と言ったように相互に通信する必要がある.

なお実装に関わる細かな注意点についてはコードの全体像を見るのが早道である.

留意点

  • 殆どが非同期処理となるため, Promiseオブジェクト/async関数/await構文を活用すると良い. 
  • スクリーンキャプチャを外部に抽出する行為には重大なリスク(典型的には個人情報等を含んだキャプチャ画像が任意のサイトに送信可能となってしまう)が含まれるため, スクリプトが実行される範囲を出来る限り制限するように各種APIを活用する.

実行結果

本アドオンで取得したスクリーンキャプチャ

アドオンプロジェクトの全貌


ファイル構成は次の通り
dir
├manifest.json
├content.js
└bg.js

上記構造を「about:debugging」で表示したアドオンのデバッグページから読み込むことで, windowコンテキストからキャプチャ画像にアクセスする準備が整う.
参考: 初めての WebExtension

manifest.json

{
 "manifest_version": 2,
 "name": "enableScreenShot",
 "version": "1.0",
 "description":  "スクリーンショットのテスト",
 "icons": {
  "48": "favicon.png"
 },
 "content_scripts": [
  {
   "matches": ["http://localhost/*"],
   "js": ["content.js"]
  }
 ],
 "background": {
  "scripts": ["bg.js"]
 },
 "permissions": [
  "<all_urls>"
 ]
}

  • content_scripts/matchesにはアドオンを動作させたいサイトのパターンを指定する.
  • スクリーンキャプチャを有効化するにはpermission値に"<all_urls>"を指定する.

test.htm/uses.js

アドオンを利用するWEBページ側の(windowコンテキストの)スクリプト

<!DOCTYPE html>
<html>
<head>
<!--XSSの抑制(出来ればサーバー側で設定する)-->
<meta http-equiv="Content-Security-Policy" content="script-src localhost">
<script src="uses.js"></script>
</head>
<body>
<input id="btn" type="button" value="post"/>
<img id="img" width="200" height="200"/>
</body>
</html>

"use strict";
//NOTE:絶対にXSSが混入しないことが前提
//content.jsの内容が有効となるのを待つ
document.addEventListener("DOMContentLoaded", e => {
 {
  //content.jsとはmessagePortを介して通信を行う
  const chan = new MessageChannel();
  window.postMessage(
   {port: chan.port2, type: "screenshot"}, 
   "http://localhost",//NOTE:*で十分だが自身へのmessageであることを明確化 
   [chan.port2]
  );
  const port = chan.port1;
  let waiting = false;
  //公開するAPI
  var capture = (x, y, w, h) => {
   if(waiting){throw "Previous capturing is not completed.";}
   waiting = true;
   return new Promise((resolve, reject) => {
    port.onmessage = e => {
     waiting = false;
     resolve(e.data.url);
    };
    //content.jsにキャプチャを要求
    port.postMessage({x: x, y: y, w: w, h: h});
   })
  };
 }
 btn.onclick = async e => 
  img.src = await capture(0, 0, 200, 200);
});

  • セキュリティの観点からContent Security Policyを用いてスクリプトの実行範囲を制限する.
  • content_scriptコンテキストとはデータの授受をmessage通信にて行う. なお, messageイベントそのものはwindow/content_scriptコンテキストの両方でリッスン出来るため, window.postMessageメソッドを介したデータの授受はバグの温床となる. そのため, MessageChannelオブジェクトを用いて専用のメッセージ通信経路を作ると良い.
    参考: MessageChannel
  • APIさえ定義できてしまえば単純な非同期処理としてキャプチャ処理を記述できる.

content.js

//content script
"use strict";
{
 //bg.jsから得たURLをImageオブジェクトに読み込む
 function load(src){
  return new Promise((resolve, reject)=>{
   const img = new Image();
   img.onload = e => resolve(img);
   img.onerror = e => reject(e);
   img.src = src;
  });
 }
 //キャプチャ画像を加工するcanvas要素
 const canvas = document.createElement("canvas");
 const ctx = canvas.getContext("2d");
 //messagePortを受け取るリスナ
 window.addEventListener("message", function register(e){
  //自サイトからのリクエストであることを確認
  //NOTE:怠ると深刻なセキュリティホールとなる
  if(e.origin != "http://localhost" || e.data.type != "screenshot" || !e.data.port){return;}
  //リスナを解除
  removeEventListener("message", register);
  //messagePortを介して通信を行う
  const port = e.data.port;
  port.onmessage = async e => {
   const data = e.data;
   canvas.width = data.w;
   canvas.height = data.h;
   //bg.jsにアクティブタブのキャプチャ画像を要求する
   const url = await browser.runtime.sendMessage(null);
   //canvas要素で画像を切り抜く
   ctx.drawImage(await load(url), 
    data.x, data.y, data.w, data.h, 
    0, 0, canvas.width, canvas.height);
   //呼び出し元jsに処理結果を返す
   port.postMessage({url: canvas.toDataURL()});
  };
 });
}

  • 初回messageイベントによりwindowコンテキストから送られてきたMessagePortオブジェクトを介して外部と通信する.
  • messageイベントではメッセージの発信者が自サイトであることを確認する. (これが抜けると「本ページをiframeで表示している外部サイトのWEBページ」からのアクセスが有効となってしまう(未確認)→重大なセキュリティホールとなる)
    参考: Window.postMessage()
  • backgroundコンテキストに対してスクリーンキャプチャ(全画面)を要求し, 得られた結果をcanvas要素で加工した上で上記のMessagePortに渡す.

bg.js

//background script
"use strict":
browser.runtime.onMessage.addListener(
 //キャプチャPromiseを返す
 (request, sender, sendResponse) => 
  browser.tabs.captureVisibleTab(null, {format: "png"})
);

  • content_scriptコンテキストからのメッセージを受け, browser.tabs.captureVisibleTab(戻り値Promise)を実行して返す.




追記


windowコンテキストに対するAPIの追加をcontent_scriptコンテキストから行わせる方法があったので少し書き換えてみる.

content.js

//content script
"use strict";
{
 //bg.jsから得たURLをImageオブジェクトに読み込む
 function load(src){
  return new Promise((resolve, reject)=>{
   const img = new Image();
   img.onload = e => resolve(img);
   img.onerror = e => reject(e);
   img.src = src;
  });
 }
 //キャプチャ画像を加工するcanvas要素
 const canvas = document.createElement("canvas");
 const ctx = canvas.getContext("2d");
 //messagePortを受け取るリスナ
 window.addEventListener("message", function register(e){
  //自サイトからのリクエストであることを確認
  //NOTE:怠ると深刻なセキュリティホールとなる
  if(e.origin != "http://localhost" || e.data.type != "screenshot" || !e.data.port){return;}
  //リスナを解除
  removeEventListener("message", register);
  //messagePortを介して通信を行う
  const port = e.data.port;
  port.onmessage = async e => {
   const data = e.data;
   canvas.width = data.w;
   canvas.height = data.h;
   //bg.jsにアクティブタブのキャプチャ画像を要求する
   const url = await browser.runtime.sendMessage(null);
   //canvas要素で画像を切り抜く
   ctx.drawImage(await load(url), 
    data.x, data.y, data.w, data.h, 
    0, 0, canvas.width, canvas.height);
   //呼び出し元jsに処理結果を返す
   port.postMessage({url: canvas.toDataURL()});
  };
 });
 const script = document.createElement("script");
 const source = `
"use strict";
{
 //content.jsとはmessagePortを介して通信を行う
 const chan = new MessageChannel();
 window.postMessage(
  {port: chan.port2, type: "screenshot"}, 
  "http://localhost",//NOTE:*で十分だが自身へのmessageであることを明確化 
  [chan.port2]
 );
 const port = chan.port1;
 let waiting = false;
 //公開するAPI
 var capture = (x, y, w, h) => {
  if(waiting){throw "Previous capturing is not completed.";}
  waiting = true;
  return new Promise((resolve, reject) => {
   port.onmessage = e => {
    waiting = false;
    resolve(e.data.url);
   };
   //content.jsにキャプチャを要求
   port.postMessage({x: x, y: y, w: w, h: h});
  })
 };
}`;
 script.src = URL.createObjectURL(new Blob([source], {type: "text/javascript"}));
 script.onload = e => {
  URL.revokeObjectURL(script.src);
  script.remove();
 };
 document.documentElement.appendChild(script);
}

要するにwindowコンテキストで公開したいスクリプトをblob経由でscript要素から参照可能としてDOMに追加するのである. こうすると, test.htmとuses.jsをよりシンプルに記述できるようになる.

test.htm/uses.js

アドオンを利用するWEBページ側の(windowコンテキストの)スクリプト

<!DOCTYPE html>
<html>
<head>
<!--XSSの抑制(出来ればサーバー側で設定する)-->
<meta http-equiv="Content-Security-Policy" content="script-src 'self' blob:">
<script src="uses.js"></script>
</head>
<body>
<input id="btn" type="button" value="post"/>
<img id="img" width="200" height="200"/>
</body>
</html>

"use strict";
//NOTE:絶対にXSSが混入しないことが前提
//content.jsの内容が有効となるのを待つ
document.addEventListener("DOMContentLoaded", e => {
 btn.onclick = async e => 
  img.src = await capture(0, 0, 200, 200);
});

CSP設定で新たにblob:スキームでのスクリプト読み込みを追加する必要があるが, captureメソッドの定義がアドオン内に隠蔽される.

0 件のコメント:

コメントを投稿