0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

カスタム検索エンジンで「javascript:」を踏ませない!オープンリダイレクトとスクリプト注入を防ぐURLサニタイズ術

0
Posted at

ユーザーが自由に検索エンジンを追加できる機能を作ろうとしたとき、もっとも警戒しなければならないのは 「悪意あるプロトコルの注入」 だ。

「Googleだけでなく、GitHubや社内Wiki、YouTubeを検索バーから一発で叩けるようにしたい」。

自作の新しいタブダッシュボード「ZenithTab」の検索ウィジェットにカスタム検索エンジンの登録機能を実装した際、URLのフォーマットとして以下のようなテンプレート文字列を受け付ける設計にした。

https://github.com/search?q={query}

ユーザーが検索バーにキーワードを入力してEnterを押すと、コードは {query} を入力語句に置き換え、window.open(url)(設定によっては window.location.href = url)で検索結果ページを開く。

この極めてシンプルな機能の中に、v1.10.3 まで潜んでいた穴があった(Issue #51)。

image.png


盲点だった javascript: スキーマの脅威

もし次のようなURLを設定フォームに登録したらどうなるだろうか。

javascript:alert(document.domain)

あるいは、もっと巧妙にパラメータを偽装したスクリプト。

javascript:(function(){/* ユーザーのローカルストレージやブックマークを外部へ送信するコード */})()

この文字列がカスタム検索エンジンとして保存された場合、ユーザーが検索バーから検索を実行した瞬間、コードは次のように動く。

// 脆弱な実装例
const targetUrl = engine.urlTemplate.replace('{query}', encodeURIComponent(query));
window.open(targetUrl, '_blank');

ブラウザは javascript: で始まるURLを「現在のドキュメントの文脈で実行すべきスクリプト」として解釈する。拡張機能のページ(chrome-extension://...)で実行されれば、chrome.storage.local の全データ(暗号化されていないメモや設定)や、bookmarks 権限で読めるブックマークにスクリプトから手が届く。

正確を期すと、Manifest V3 の拡張機能ページには script-src 'self' の CSP が強制されており、javascript: URL のインライン実行はブラウザ側で拒否される可能性が高い。だが data: や blob: や file: のような他のスキームは別の問題を起こしうるし、dev サーバーで動かしているときは CSP がない。「CSP が止めてくれるはずだから」という前提でコードを書くのは、防御を1枚に賭けることだ。

「ユーザーが自分で設定するフォームだから、攻撃されるはずがない」という思い込みも通用しない。

ZenithTabには「設定JSONファイルのエクスポート/インポート機能」がある。攻撃者が魅力的なダッシュボード設定JSONをインターネット上で配布し、それをユーザーが「かっこいいデザインだから」とインポートしてしまえば、被害者のブラウザ内に悪意ある検索エンジンが一瞬で仕込まれてしまうからだ。


厳格なプロトコルホワイトリスト(isSafeHttpUrl)

URLのバリデーションにおいて、「javascript: をNGワードとして禁止する」というブラックリスト方式は絶対にやってはならない。

攻撃者は JAVASCRIPT:, javascript :, \tjavascript:、あるいは data:text/html,...、file:///、blob: など、無数の回避テクニック(Obfuscation)を使ってフィルターをすり抜けてくる。

唯一の正解は、「許可するプロトコル(http: と https:)以外はすべて拒絶する」という完全なホワイトリスト方式だ。

ZenithTabでは、src/utils/url.ts に以下のガード関数を用意している。検索エンジンだけでなく、Web埋め込みウィジェットの <iframe src>、ショートカットの <a href>、RSS やカレンダーの fetch() 先——ユーザー入力が URL になる場所はすべてここを通す。

/**
 * ユーザー入力をリンク・埋め込み・fetch 先にするすべての箇所で共有する URL ガード。
 * `http:` / `https:` 以外は受け付けない: `javascript:`, `data:`, `file:` などが
 * `<iframe src>` や `<a href>` や `fetch()` に届いてはならない。
 */
export function isSafeHttpUrl(value: unknown): value is string {
  if (typeof value !== 'string' || !value.trim()) return false;
  try {
    const protocol = new URL(value).protocol;
    return protocol === 'https:' || protocol === 'http:';
  } catch {
    return false;
  }
}

ブラウザ組み込みの new URL() コンストラクタを活用するのがポイントだ。自作の正規表現でURLをパースしようとすると必ずエッジケースで抜け穴ができるが、ブラウザ標準のURLパーサーを使えば、前後の空白や大文字小文字の揺れも含めて正規化されたプロトコル判定が行える。


{query} を含むテンプレートURLをどう検証するか

ここで1つの技術的課題が生じる。

カスタム検索エンジンのURLには {query} というプレースホルダーが含まれている。
https://example.com/search?q={query}

この文字列をそのまま new URL() に渡した場合、クエリ部分であればパースに成功するが、ホスト名部分に波括弧があったり壊れた構文だったりした場合はどう判定すべきか。

ZenithTabでは、バリデーション時に {query} をあらかじめ無害なダミー文字列(q)に置換した上で isSafeHttpUrl を通すアプローチを採った。スキームを省略して example.com/search?q={query} と書かれた場合は https:// を補う(SearchConfig.tsx)。

/**
 * カスタムエンジンの URL テンプレートを使える形に整える: trim し、スキームが無ければ https:// を補い、
 * `{query}` を含んでいて、埋めた後に http(s) の URL になるものだけを返す(それ以外は null)。
 */
export function normalizeSearchUrlTemplate(input: string): string | null {
  const trimmed = input.trim();
  if (!trimmed.includes('{query}')) return null;
  const withScheme = /^[a-z][a-z0-9+.-]*:/i.test(trimmed) ? trimmed : `https://${trimmed}`;
  return isSafeHttpUrl(withScheme.replace('{query}', 'q')) ? withScheme : null;
}

この判定を通すことで、「{query} が含まれており、かつ置換後に正当な HTTP/HTTPS URL になるもの」だけを設定フォームで受け付けるようにした。フォーム側では {query} が無い/スキームが不正な場合にそれぞれ別のメッセージを出し、「追加」ボタンを無効化する。

さらに、ユーザーがアドレスバーから「検索結果のURL(例: https://github.com/search?q=react)」をそのまま貼り付けた場合に、よくあるクエリパラメータ名(q, query, search など)の値を {query} に自動置換して補正する guessSearchUrlTemplate 関数も用意し、入力欄の blur 時に走らせている。ほとんどの人は {query} テンプレートを手書きしないので、UXの利便性と安全性を両立させる仕掛けだ。


検索実行時のパラメータインジェクション対策

URLの安全性が確認できたら、次は検索実行時の文字列置換だ。

ここでもう1つの落とし穴が「安易な文字列連結」によるURL破壊だ。

// NG: ユーザー入力がそのままURLに埋め込まれる
const url = template.replace('{query}', rawQuery);

もしユーザーが foo&admin=true#hash と入力した場合、URLのクエリパラメータの境界が破壊され、意図しないパラメータが挿入されてしまう。

必ず JavaScript標準の encodeURIComponent を通す。ZenithTab では組み込みエンジンもカスタムエンジンも「クエリを受け取って URL を返す関数」として同じ形に揃えており、どちらもエンコードを経由する。

// 組み込みエンジン
google: { url: (q) => `https://www.google.com/search?q=${encodeURIComponent(q)}` },
// カスタムエンジン
url: (q) => custom.urlTemplate.replace('{query}', encodeURIComponent(q)),

3つの防衛線:フォーム・保存・インポート

今回の Issue で分かったのは、インポート経路には検証があったのに、UI から直接追加する経路には無かったということだ。JSON インポート時は widgetDefinitions.ts の sanitizeConfig が isSafeUrl(engine.urlTemplate.replace('{query}', 'q')) を通していたが、設定フォームの addCustomEngine は {query} の有無しか見ていなかった。

そこで検証を3か所に揃えた。

経路 場所 処理
設定フォーム SearchConfig.tsx addCustomEngine normalizeSearchUrlTemplate で正規化し、null なら追加しない
保存直前 registry.tsx の beforeSave → prepareSearchConfigForSave 保存される customEngines 全体をもう一度走査し、通らないものを落とす
インポート widgetDefinitions.ts sanitizeConfig 従来どおり。同じ置換ルールで検証
/** 保存時の二重チェック: テンプレートが安全な http(s) でないカスタムエンジンは落とす */
export function prepareSearchConfigForSave(config: Record<string, any>): Record<string, any> {
  if (!Array.isArray(config.customEngines)) return config;
  const customEngines = config.customEngines
    .map((engine: CustomSearchEngine) => {
      const urlTemplate = typeof engine?.urlTemplate === 'string' ? normalizeSearchUrlTemplate(engine.urlTemplate) : null;
      return urlTemplate ? { ...engine, urlTemplate } : null;
    })
    .filter((engine): engine is CustomSearchEngine => engine !== null);
  return { ...config, customEngines };
}

beforeSave はウィジェットレジストリが持つ保存前フックで、RSS の URL 補完や Web 埋め込みの URL 検証もここに置いている。共通の設定モーダルにウィジェット固有の処理を足さず、ウィジェット側が自分の保存ルールを宣言する。

仮に細工された設定ファイルが読み込まれても、フォームをすり抜ける未知の経路があっても、保存直前のフックが静かに不正なエンジンを消去する。

image.png
image.png


「入力されたURLを開く」という処理の重み

Webアプリケーションやブラウザ拡張機能において、ユーザーから受け取った文字列を window.open や <a href>、<iframe src> に流し込む処理は、本質的に最も危険な領域だ。

「たかが検索バーだから」「CSP があるから」と油断して isSafeHttpUrl のチェックを怠れば、いつか必ず重大な脆弱性として牙を剥く。

  • プロトコル判定は必ずホワイトリスト(http: / https: のみ)で行い、ユーザー入力が URL になる場所すべてで同じ関数を使う
  • プレースホルダーの置換には encodeURIComponent を徹底する
  • 検証は「入口」だけでなく「保存直前」と「インポート」にも置き、経路ごとの抜けを潰す

この3つの防衛線を構築しておくこと。それこそが、ユーザーの自由なカスタマイズ性とシステムの堅牢性を両立させるための最低限の作法である。


さいごに:ZenithTabについて

本記事で紹介した設計やトラブルシューティングの知見は、すべて自作の新しいタブChrome拡張機能「ZenithTab(ゼニスタブ)」の開発を通して得られたものです。

ZenithTabは、「ブラウザを開くたびに心地よく、作業に集中できる」をコンセプトにした、完全ローカル完結・プライバシー重視のダッシュボード拡張機能です。

  • 自由なグリッド配置: 時計、天気、カレンダー、RSSリーダー、集中タイマー、習慣トラッカー、メモなど、多彩なウィジェットをグリッド上で自由に配置
  • 安心のローカル完結: 外部サーバーへのデータ送信は一切行わず、すべての設定やメモはブラウザ内に安全に保存
  • 細部へのこだわり: ガラスモーフィズム(すりガラス調UI)、ダイナミック壁紙、軽快なキーボードショートカット、そして万が一の誤操作を防ぐ「元に戻す(Ctrl+Z)」や自己修復機能を完備

Chromeウェブストアで無料公開しています。日々の作業効率化や、技術的なUI/UXの触感のお試しとして、ぜひ気軽に使ってみてください!

ソースコードはGitHubで公開しています。「面白い」「役に立った」と思っていただけたら、GitHubのスター(⭐️) や記事への いいね / ストック をいただけると、開発の大きな励みになります!

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?