セキュリティ項目の実装:ElectronでWebブラウザを作る(その4)

Electronアプリを作成する手習いの第4回として、前回考えたwebviewによるサンドボックス化を実装します。そして残りのセキュリティ項目を実装します。

はじめに

前回は、前々回に作成したセキュリティのチェックリストの仮実装を進めたところ、Javascriptの実行を許さないiframeのサンドボックスでは、ユーザーの操作によるイベントの発火自体がエラーになってしまうことがわかりました。

そこで苦肉の策としてwebviewでのサンドボックス化を考えました。

今回はそのwebviewサンドボックスと残りのセキュリティ項目をテスト実装します。

いつもの

本稿の環境は以下です。

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

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

ソース

これまでのようにソースを示します。あまり変化がないソースもそのまま示しますので、適当にスキップしてください。(ディレクトリ構造などは第一回をご参照ください。)

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();
    }
});

package.jsonのmainに書いている、アプリのエントリーです。次のwindow.htmlを開いています。

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">
    <webview id="header-frame" ></webview>    
    <div id="view-container">
        <webview id="view" preload="preload.js"></webview>
    </div>
</div>
</body>
</html>

ヘッダ部を表示するwebviewタグ(id=”header-frame”)と、コンテンツを表示するwebviewタグ(id=”view”)の2つだけです。後者では、preload属性に”preload.js”を指定しています。

preloadに指定したスクリプトは、ページ内の他のスクリプトの実行前にロードされます。webviewタグのnodeIntegrationオプションのある無しに関わらず、nodeのAPIが使えます。

preload.js

'use strict';
//  Disable eval and Buffer.
window.eval = global.eval = global.Buffer = function() {
    throw new Error("Can't use eval and Buffer.");
}

そのスクリプトですが、evalとBufferを明示的に無効にしているだけです。

window.css

* {
    margin:0;
    padding:0;
    border: 0 none;
}
html, body, #window {
    width: 100%;
    height: 100%;
}
#header-frame {
    width: 100%;
    height: 22pt;
}
#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%;
}

window.htmlと次のheader.htmlに共通のCSSです。

前回は12行の下に「display:block;」が書いてあり、iframeをブロック要素にしていました。

webviewは内部的にdisplay:flexにしている、ということで、それと競合する記述は削除しました。

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>

テキストボックスが一つあるだけの、ヘッダ部のHTMLです。

前回からの変更はありません。

window.js

'use strict';
window.eval = global.eval = function() {
    throw new Error("Can't use eval.");
}

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

window.addEventListener('load', ()=> {
    HeaderFrame = document.getElementById('header-frame');
    HeaderFrame.preload = 'header.js';
    HeaderFrame.src = 'header.html';
    HeaderFrame.addEventListener( 'did-finish-load', Initialize, false );
    HeaderFrame.addEventListener('ipc-message', (event) => {
        if('url-input'===event.channel) {
            View.setAttribute('src', event.args[0]);
        }
    });
}, false);

function Initialize() {
    View = document.getElementById('view');
    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>');
//    HeaderFrame.openDevTools();
    HeaderFrame.send('url-input', 'https://translate.google.com');
}

windows.htmlのプロセスで実行されるスクリプトです。

HTMLのロード終了後に実行される10行にて、変数HeaderFrameにwebviewのエレメントを取得し、以降それに対して以下の操作をしています。

  • preloadのスクリプトにheader.jsを設定。
  • webviewに読み込むソースにheader.htmlを設定。
  • header.htmlロード完了後に実行する関数Initializeを設定。
  • header.jsからメッセージが来たときの処理関数の設定。

特に最後の14行から18行が今回の肝です。

webviewのプロセスからメッセージが来たときは、一律にそのエレメントのipc-messageイベントになります。引数のオブジェクトの、メンバchannelにチャネル名が、メンバargsにメッセージの引数が配列で入ります。

ここでは「url-input」というチャネルにメッセージがあったときに、その引数をURL文字列として、ビューのwebviewのsrc属性にそのURLを指定しています。(結果、ビューにそのページが表示されます。)

また、レンダラーのプロセスからwebviewのプロセスにメッセージを送っているのが28行です。第一引数がチャネル名、それ以降がメッセージの引数です。チャネル名は受信と同じ「url-input」ですが、違う名前にしたほうがわかりやすかったですね。

header.js

'use strict';
//  Disable eval and Buffer.
window.eval = global.eval = global.Buffer = function() {
    throw new Error("Can't use eval and Buffer.");
}

const Electron = require('electron');
const IpcRenderer = Electron.ipcRenderer;
var Urlin = null;   //  element of input text

window.addEventListener('load', ()=> {
    Urlin = document.getElementById('input-url');
    Urlin.addEventListener("keypress", (event)=>{
        if(13!=event.keyCode) return;
        Urlin.blur();
        IpcRenderer.sendToHost('url-input', Urlin.value);
    }, false);
}, false);

IpcRenderer.on('url-input', (event, s_url)=>{
    Urlin.value = s_url;
});

header.htmlのプロセスにて実行されるスクリプトです。preloadで指定していますのでnode.jsのAPIが利用できます。

前回のwindow.jsより、テキストボックス(Urlin)に関する操作をこちらに移動しました。

3-5行はpreload.jsと同じく、evalとBufferを無効にしています。

7,8行にて、プロセス間通信のためのオブジェクトをIpcRendererに取得しています。

テキストボックスでキーが押されたときの処理が14-16行です。16行にてsendToHost メソッドを使い、window.jsのプロセスにメッセージでURL文字列を送っています。

20-22行では、逆に、window.jsのプロセスからのメッセージを受け取っています。onメソッドの第一引数がチャネル名、第二引数がコールバック関数ですが、そのコールバック関数の第二引数以降がメッセージの引数になります。

ここでは全てのメッセージで一つの引数しか使っていませんが、メッセージには複数の引数を指定できます。

実行して確認

ここまで書いて、ようやく第一回と同じ機能が、割りとセキュアに実装できました。(微妙な表現ですが…。)

実行して、同様の動作をすることを確認しました。

課題

コードの量としては多くはないのですが、Web記事などを参照しながらゆっくりと試作をしてみて、課題が見えてきました。

サンドボックス化の範囲

まず、ここまで「ヘッダー部を丸ごとサンドボックス化」してきました。

目的のものが一般的なWebブラウザであれば、外部リソースを流し込むところは、URLを表示するテキストボックスと、ページのタイトルを表示するタイトルバー或はタグです。

ですので(プロセス間通信のような)難しいことをせずに、この2つの部分だけをサンドボックス化すればよいのでしょう。

しかし天邪鬼といいますか「ヘッダーそのものが隔離されている」という構造が何か面白いのでこのまま進めようと思います。

エスケープ

現在のところエスケープの処理は実装していません。

タイトルを表示する部分はHTMLエスケープですが、実際のところwebviewのAPIがタイトルをエンコードして返すのか、そうでないのかわかりません。実装するときに調べてみることになります。

或は単に、内部でHTMエスケープをしてくれるjQueryのtext()メソッドを使うだけかもしれません。(この可能性が高い…。)

またURLのテキストボックスに入るのはURLだけですので、エスケープというよりはURLとしての検証をするのでしょう。

プロセス間通信のライブラリ化

今回書いてみたレンダラーとwebviewのプロセス間通信では、それぞれにおいて書き方が、微妙に異なりました。

このあたりは、CORBAのなどリモートプロシージャコールっぽく書けるようにライブラリ化すると見通しがよいかもしれません。

初期化完了の同期

今回のサンプルだけでも、レンダラーのHTMLとwebviewが2つと、3つの初期化が走ります。初期化完了の同期をちゃんと考えたほうが良いですね。

さらに上記ライブラリ化の仕方によっては、さらに複雑になりそうです。(スタブを動的に登録とか。)

ユーザー操作イベント

前回、今回とテキストボックスに対するキー入力イベントは、addEventListenerで登録しました。しかしもうヘッダー内でのスクリプトの実行を有にしましたので、HTMLのonkeypress属性などに関数コールを書いてよいのかもしれません。

前節の問題と関連して、初期化が完全に終わるまでユーザーの操作を有効にしないことまでを考えると、以下の2方式が考えられます。

  • やはり初期化が終わって最後にaddEventListenerでイベントリスナーを登録する。
  • あるいはHTMLでは操作部をdisableに書いておき、初期化が終わったらenableにする。

(今のところ後者ですかね。)

コンテナはいらない?

window.htmlなんですけど、bodyの下の「<div id=”window”>」ってコンテナと、viewの上の「<div id=”view-container”>」ってのはいらなかったですね。

Webページですと、ブラウザの幅が大きい時に、ページの幅をそこまで広げないようにbodyの下にコンテナを入れます。(他にも理由があるかも。)

しかしアプリですと、ウィンドウのサイズ自体を制限できますし、中身はほぼ「width:100%」です。なので、bodyの下のコンテナは必要ありません。

view-containerの方ですが、当初はコンテナの中に複数のwebviewを開きタブブラウザにするイメージでした。しかし今は、レンダラーのウィンドウ自体をタブで切り替えるイメージです。なのでview-containerも必要なし。

最初からそう思っていれば、CSSはもう少しだけシンプルでした。

まとめ

以上、webviewによるサンドボックスとその他のセキュリティ項目の実装でした。

今回でようやくフィージビリティスタディが終わった感じです。

今回のコードもgithubに上げましたのでご参照ください。セキュリティチェックリストもまとめておきました。

次回は、アプリの色の話を一回挟んで、ヘッダー部のデザインをしたいと思います。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です