セキュリティを検討する:ElectronでWebブラウザを作る(その3)

Electronアプリを作成する手習いの第3回として、前回作成したセキュリティのチェック項目のうち、Content-Security-Policyとiframe sandboxについてテスト実装します。

さらに操作イベントが扱えるようなwebviewによるサンドボックス化を考えます。

はじめに

前回、Electronのアプリを作成するにあたってのセキュリティのチェックリストを作成しました。

今回はそのうち、Content-Security-Policyとiframe sandboxについてテスト実装して動作を見たいと思います。

尚、本稿の環境は以下でございます。

  • Windows10:ver.1607
  • Node.js:v6.9.1
  • npm:3.10.8
  • Electron:1.4.10

Electronのバージョンが異なる場合、本稿の内容が当てはまらない可能性が多々ありますのでご注意ください。

Content-Security-Policyを入れる

BrowserWindowにて開かれるwindow.htmlのhead内に、最初は以下の記述をしました。

<meta http-equiv="Content-Security-Policy" content="default-src 'self';" />

すると、node_modules/electron/からの深いところのweb-view.jsの中、innerHTMLにスタイルを代入しているところでエラーが発生しました。

ということで、「style-src 'self' 'unsafe-inline';」を追加し、以下のような記述にしました。

 <meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline';" />

また’unsafe-eval’を指定していない状態でも特にeval()がエラーにはなるということはありませんでしたので、evalの無効化のコードは明示的に入れることにします。

iframeによるサンドボックス化

結果を先に書きますと、iframeによるサンドボックス化はあきらめました。

本章ではそれに至るトライアルを記します。

ソース

最初にソースと、前々回からの変更点を書きます。ディレクトリ構成や各ソースの説明などは前々回をご参照ください。

main.js

'use strict';

const Electron = require('electron');
const App = Electron.app;
const BrowserWindow = Electron.BrowserWindow;

var MainWin = null;

function CreateWindow () {
    MainWin = new BrowserWindow({
        width: 800,
        height: 600
    });
    MainWin.setMenu(null);
    MainWin.loadURL( 'file://' + __dirname + '/window.html');
    MainWin.webContents.openDevTools();

    MainWin.on('closed', ()=> {
        MainWin = null;
    });
}

App.on('ready', CreateWindow);

App.on('window-all-closed', ()=> {
    if (process.platform !== 'darwin') {
        App.quit();
    }
});

App.on('activate', ()=> {
    if (MainWin === null) {
        CreateWindow();
    }
});

前々回との違いは、デバッグのために16行をコメントから戻したところです。

また18,25,31行のインライン関数は「()=>」を使うように宗旨変えしました。

window.html

<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline';" />
    <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0">
    <title>glasspot</title>
    <link rel="stylesheet" href="window.css">
    <script src="window.js" charset="utf-8"></script>
</head>
<body>
<div id="window">
    <iframe id="header-frame" sandbox="allow-same-origin" ></iframe>    
    <div id="view-container">
        <webview id="view" src="" ></webview>
    </div>
</div>
</body>
</html>

4行目にContent-Security-Policy、5行目にviewportを追加しました。

そしてテキストバーのみのヘッダーを次のheader.htmlに移し、その部分をiframeにしています。(12行)

header.html

<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="Content-Security-Policy" content="default-src 'self';" />
    <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0">
    <title>header</title>
    <link rel="stylesheet" href="window.css">
</head>
<body>
    <header id ="header">
        <input id="input-url" type="text" />
    </header>
</body>
</html>

iframeの中に表示されるヘッダー部分のHTMLです。

inputがあるだけです。

こちらのContent-Security-Policyにはstyle-srcへの指定を入れていません。今後エラーが出るようなコードを入れた段階で改めて指定しようと思います。

window.css

* {
    margin:0;
    padding:0;
    border: 0 none;
}
html, body, #window {
    width: 100%;
    height: 100%;
}
#header-frame {
    width: 100%;
    height: 22pt;
    display: block;
}
#header {
    width: 100%;
    height: 22pt;
    background-color: lightgrey;
    font-size: 12pt;
}
input {
    margin: 4px;
    border: 1px solid darkgrey;
    width: calc(100% - 12px);
}
#view-container {
    width: 100%;
    height: calc(100% - 22pt);
}
#view {
    width: 100%;
    height: 100%;
} 

10-14行に、iframeのサイズの指定を追加しました。

後々はヘッダー用のCSSは分けるのでしょう。

window.js

'use strict';

var HeaderFrame = null; //  element of header frame
var View = null;    //  element of webview
var Urlin = null;   //  element of input text

window.addEventListener('load', ()=> {
    HeaderFrame = document.getElementById('header-frame');
    HeaderFrame.src = 'header.html';
    HeaderFrame.addEventListener('load', Initialize, false);
}, false);

function Initialize() {
    View = document.getElementById('view');
    Urlin = HeaderFrame.contentDocument.getElementById('input-url');
    Urlin.value = 'https://translate.google.com/';
    View.setAttribute('src',
        'data:text/html,<h1>Hello World!</h1> \
        <p>Please enter only <b>reliable</b> URLs.</p> \
        <p>Electron <script>document.write(process.versions.electron)</script></p>');

    Urlin.addEventListener("keypress", (event)=>{
        KeyPressed(event.keyCode);
    }, false);
}

function KeyPressed(key) {
    if( key!=13) return;
    Urlin.blur();
    View.setAttribute('src', Urlin.value );
}

7行からのloadイベントのリスナーにて、フレームにheader.htmlを読み込み、その読み込みが終了した時点で初期化関数Initializeがコールされます。

この中で、テキストボックスをフレーム経由で取得し(15行)、valueの設定(16行)とkeypressイベントのリスナーの設定(22-24行)をしています。

実行してみる

これを実行してみます。

すると、テキストボックスでキーを押された瞬間に、デベロッパーツールのコンソールに次のようなエラーが表示されました。

Blocked script execution in ‘file:///B:/workspace/glasspot/app/header.html’ because the document’s frame is sandboxed and the ‘allow-scripts’ permission is not set.

フレームにallow-scriptsが設定されていないのでスクリプトの実行をブロックした旨です。どうやらイベントリスナーの設定をwindow.htmlにて行ったとしても、イベントの発火自体がiframe内のheader.htmlでのJavascript実行になるようです。

エラーメッセージを参考に、window.htmlの12行に「allow-scripts」を追加して次のようにすると期待通りに動作します。

<iframe id="header-frame" sandbox="allow-same-origin allow-scripts" ></iframe>

しかしこれでは意味がありません。

第一に、そもそもiframeによるサンドボックス化はクロスサイトスクリプティング(XSS)に対する最後の一網としてJavascriptの実行自体をさせないためのものでした。

第二に、allow-same-originとallow-scriptsの同時使用は危険です。Mozilla Developer Networkiframe要素のページより引用させて頂きます。

埋め込み文書がメインページと同一オリジンを持つ場合、allow-scripts と allow-same-origin の同時使用は、埋め込み文書がプログラムから sandbox 属性を削除することができるようになるため、用いるべきではありません。容認されているとはいえ、sandbox 属性を使わないのと同様に安全ではありません。

さて、どうしたものか…。

再びサンドボックス化を考える

iframeによるサンドボックスは、操作を伴う部分とは両立しないことが分かりました。

「ヘッダー」のように大きくサンドボックス化するのでなく、外部ソースを表示する部分(例えばタブのタイトル表示部)だけを細かくサンドボックス化することも考えられます。しかしオペレーションが複雑になる分、漏れや新たなバグが生じやすいと言えます。最後の一網は大きくかけたいですね。

外部リソースを反映した表示のレイヤをiframe内にし、それをもとにイベントを発火させるための透明レイヤを作って重ねる、という手も考えられます。これまた複雑で新しいバグを作りこみそうです。

そして思いついたのがwebviewでサンドボックス化。

webviewによるサンドボックス化

webviewによるサンドボックス化、といってもiframeをwebviewに置き換えるだけです。もちろんnodeIntegration等のセキュリティを緩くするオプションは付けません。

その理由

javascript自体は実行出来てしまうので、iframeによるサンドボックス化ほどは強力ではありませんが、以下の理由が挙げられます。

  • レンダラープロセスとは隔離される。
  • Content-Security-Policyにて「script-src ‘unsafe-inline’」を指定しない限りは、DOM操作によるスクリプトの挿入はエラーになる。
  • webviewタグ内のコンテンツではwebviewタグは意味をなさないので、「<webview src="data:text/html,<script>...」のようなクロスサイトスクリプティング(XSS)の攻撃もできない。
  • 何よりWebブラウザというアプリの場合、攻撃者からすると、サンドボックスの中とコンテンツが表示されるwebviewとは条件が同じなので、わざわざ前者を攻撃する理由がない。

一点目はよいでしょう。

続く二つは後ほど検討するとして、最後の点から。

こう書いてしまうと逃げのようですが、裏を返すと、サンドボックスの堅牢性/脆弱性は外のコンテンツを表示するための部分と同等、ということになります。将来Electronのバージョンアップによってwebviewの脆弱性が低下すれば、サンドボックスもそれに従います。

Content-Security-Policyの確認

2点目を現在の(iframeのsandboxにallow-scriptsが入っている状態の)ソースで検証してみましょう。

window.jsの関数Initializeの最後に以下のコードを追加します。

    var element = document.createElement('script'); 
    element.innerHTML = 'alert("Hi!")'; 
    HeaderFrame.contentDocument.getElementById('header').appendChild(element);

ヘッダーの最後にalertのスクリプトを挿入しています。

実行すると、期待通りに以下のエラーが表示されました。

Refused to execute inline script because it violates the following Content Security Policy directive: “default-src ‘self'”. Either the ‘unsafe-inline’ keyword, a hash (‘sha256-(以下略)

うーん、Content-Security-Policy、強力且つ必須ですね。

また、上の挿入部分を弄って以下のようにしたところ…

    var element = document.createElement('webview'); 
    element.setAttribute('src', 'data:text/html,<script>alert("Hi!");</script>'); 
    HeaderFrame.contentDocument.getElementById('header').appendChild(element);

インラインスタイルが適応出来ないエラーともう一つ以下のエラーが出ました。

Failed to load ” as a plugin, because the frame into which the plugin is loading is sandboxed.

webviewの処理には行くものの、サンドボックスの中ではプラグインが読み込めないそうです。

webviewの中のwebviewのテスト

webview内でwebviewが有効でないことも一応簡単に確認します。

window.jsの20行、setAttributeでsrcに流し込んでいる初期表示の最後を以下のように変えます。

        <webview nodeIntegration src="data:text/html,Electron <script>document.write(process.versions.electron)</script>"></webview>');

これを実行しても、もちろん「Electron…」以降は表示されません。

念のため、KeyPressed関数の最初に「View.openDevTools();」の行を入れ、良きところでwebviewのデベロッパーツールを表示して、その上でもwebviewタグ内になにもないことを確認しました。

webviewサンドボックスのまとめ

以上により、サンドボックス内にて操作等のイベント発火が必要な場合は、iframeの代わりにwebviewでのサンドボックス化が使えそうなことがわかりました。

しかしwebviewの中は、window.htmlがあるレンダラーのプロセスとは違うプロセスですので、イベントを知らせるのにプロセス間通信を使う必要があります。

また次回テスト実装してみたいと思います。

まとめ

セキュリティ項目を検討しました。

Content-Security-Policyは強力です。ElectronアプリのHTMLではもう必須といえます。

しかし「<webview src="data:text/html,<script>…</script>" />」のようなコードの注入によるスクリプトの実行はブロックできません。(nodeIntegrationを指定できるのでさらに危険。)

そこでDOM操作で外部リソースを流し込む部分は、iframeによるサンドボックス化を考えます。しかし操作の検出が必要な場合、Javascriptが実行できないことによってイベントの発火ができません。

そこで操作の検出もDOM操作も行う部分を隔離する手段として、webviewによるサンドボックス化を考えました。

穴があるかもしれませんが、今のところ最後の一網として有効そうなので、次回に実装してみたいと思います。

今回のソースもgithubに上げておきましたので、よろしければご参照ください。

One thought on “セキュリティを検討する:ElectronでWebブラウザを作る(その3)

  1. Pingback: セキュリティを考える:ElectronでWebブラウザを作る(その2) | 悠雀堂ブログ

コメントを残す