• #Chrome拡張機能
  • #セキュリティ
  • #sessionStorage
  • #chrome.storage.local
  • #Twitter
  • #SQLite
開発chrome-extension-xメモ

Chrome拡張のsessionStorage脱却とTwitterアーカイブのSQLite化

ストレージのセキュリティ調査中に、sessionStorageの中身が他の拡張のcontent scriptから丸見えになっていることに気づいた。ブラウザのDevToolsでApplicationタブを開き、sessionStorageの項目を眺めていたら、この拡張が書き込んだ値に別の拡張からもアクセスできる構造が目に入った。ここから、残っていたsessionStorage参照をすべてchrome.storage.localに書き換える作業が始まった。


sessionStorageの何が問題だったか

同一ページ上のcontent scriptは境界がない

Chrome拡張のcontent scriptは、注入先のWebページのDOM・localStorage・sessionStorageを共有する。つまり、自分の拡張がsessionStorageに書き込んだ値を、同じページに注入された別の拡張のcontent scriptがそのままwindow.sessionStorage.getItem()で読み取れる。

APIのエンドポイントやサービス名など、外部に見せたくない情報がsessionStorageに入っていれば、悪意ある拡張がそれを吸い出すシナリオが成り立つ。

chrome.storage.localは拡張ごとに隔離される

一方、chrome.storage.localはChrome拡張のAPI層で管理されており、拡張ごとに完全に分離されたストレージ空間を持つ。他の拡張からは物理的にアクセスできない。

sessionStorage     → ページ単位で共有、content script間で丸見え
chrome.storage.local → 拡張単位で隔離、他の拡張からアクセス不可

この拡張は実はchrome.storage.localを主に使っていた。だが、sessionStorageへの参照が数箇所に残っていた。そこを潰す作業に入った。


移行作業: 4ファイルのsessionStorage参照を書き換える

残存箇所の洗い出し

プロジェクト全体をsessionStorageでgrepしたら、4ファイルがヒットした。

ファイル用途
storage.jsストレージアクセスの抽象化レイヤー
export.jsブックマークのCSV/Sheets出力
bridge.jscontent scriptとbackground間のメッセージブリッジ
import.js外部データの取り込み処理

getServiceNameの同期→非同期化

移行で一つ引っかかったのがgetServiceName関数だった。sessionStorageのgetItem()は同期関数だが、chrome.storage.local.get()はPromiseを返す。つまりgetServiceNameをasync化する必要がある。

// Before: 同期で値を取得
function getServiceName() {
  return sessionStorage.getItem('serviceName') || 'default';
}

// After: chrome.storage.local は非同期
async function getServiceName() {
  const result = await chrome.storage.local.get('serviceName');
  return result.serviceName || 'default';
}

呼び出し元を確認したところ、すべてasync関数の内部から呼ばれていた。await getServiceName()に書き換えるだけで済んだ。呼び出しチェーンを遡って同期→非同期の連鎖書き換えが発生する最悪のパターンにはならなかった。

テストのモック定義を更新

テストではsessionStorageのモックをchrome.storage.localのモックに差し替えた。chrome.storage.local.getがPromiseを返すように定義し直す。

// Before
global.sessionStorage = {
  getItem: vi.fn(),
  setItem: vi.fn(),
};

// After
global.chrome = {
  storage: {
    local: {
      get: vi.fn().mockResolvedValue({}),
      set: vi.fn().mockResolvedValue(undefined),
    },
  },
};

修正後、全135テストを走らせた。すべてグリーン。


デッドコードの発見: setExportQueue

移行作業でコードを読み歩いていたら、setExportQueueという関数が目に止まった。storage.jsで定義されてエクスポートもされているが、プロダクションコードから一度も呼ばれていない。テストコードからだけ参照されていた。

grepで呼び出し元を全件洗い出して確認した。import文を含めてもテストファイルしかヒットしない。おそらく初期の設計段階で作られ、実装が進む中で別のアプローチに切り替わったが、関数定義だけが残ったのだろう。

今回は削除せずコメントで「未使用」と注記だけ残した。次のリファクタリングで消す。


Twitterアーカイブ → SQLite取り込み

1.6GBのZIPを展開する

同日、Twitterから届いたアーカイブファイル(約1.6GB)の処理にも着手した。ZIPを展開すると、ツイートデータがJavaScript形式(window.YTD.tweet.part0 = [...])で格納されている。先頭の代入文を削ってJSONとしてパースする、いつものTwitterアーカイブ処理パターンを踏んだ。

11,669件のツイートをSQLiteに格納

パースしたJSONから各ツイートのID、投稿日時、本文、リプライ先、リツイート元などを抽出し、SQLiteのテーブルに流し込んだ。

CREATE TABLE tweets (
  id TEXT PRIMARY KEY,
  created_at TEXT,
  full_text TEXT,
  in_reply_to_status_id TEXT,
  retweet_count INTEGER,
  favorite_count INTEGER
);

11,669件のINSERTが数秒で完了した。SQLiteにデータが入ると、年度別・月別のツイート数やリプライ率をSQLで一発で引ける。

年度別サマリーの生成

SQLiteに入ったデータから年度別の投稿数をCOUNT + GROUP BYで集計し、マークダウンのサマリーファイルを生成した。投稿頻度が年ごとにどう変化したかが一覧で見える形になった。

前日に取り込んだXブックマーク8,251件と合わせて、Twitterでの活動データがほぼすべてローカルのSQLiteに集約された。


今日の学び

  • sessionStorageは拡張間で丸見え。content scriptが注入されるページのWeb Storage APIは全拡張で共有される。chrome.storage.localを使えば拡張ごとに隔離される
  • 同期→非同期の変更は呼び出し元から確認。今回はすべてasync関数内だったので影響が限定的だった。呼び出しチェーンを先に洗い出す手順が被害範囲の見積もりに直結する
  • grepで歩くとデッドコードが見つかる。目的外の発見がリファクタリングの種になる
  • Twitterアーカイブは先頭の代入文を削ればJSON。この変換パターンを覚えておけば、次回のアーカイブ取り込みは手が迷わない