Backlog へのうっかりアップロードを止める拡張機能 backlog-toolkit を作った — UI 抑止と declarativeNetRequest を併用し、ブロック要因を帰属させる
Backlog の添付ファイル機能は、課題・コメント・Wiki・共有ファイルの各所から呼び出せ、ドラッグドロップや画像ペーストでも反応します。この入り口の多さは、通常業務では便利な一方で、「本来は添付してはいけない資料をうっかり落としてしまう」事故が起こりやすい面でもあります。アクセス権や DLP の設定が整うまでの間に、「人為的な取り違えをブラウザ側で先に止める」ための PoC が欲しくなり、Chrome と Microsoft Edge 向けの非公式拡張機能として backlog-toolkit を作りました。PR は codenote-net/backlog-toolkit#1 です。
拡張機能は Nulab 非公認の個人プロジェクトで、現時点では proof of concept の位置づけです。本記事ではこの PoC を設計する際に判断した点をまとめます。
追加した機能の概要
- 設定済み Backlog スペースのみで動作する、Manifest V3 の Chrome / Edge 拡張機能
- 動的に差し込まれる
input[type="file"]を含めて、ファイル入力・添付ボタンを非表示かつ無効化する - ドラッグドロップとファイル/画像ペーストを横取りする。テキストのみのペーストは通す
/api/v2/space/attachment・/AddAttachmentAsync.action・/AddWikiAttachmentAsync.action・/r/files/addSharedFileの POST を declarativeNetRequest でブロックするdeclarativeNetRequestFeedbackとonRuleMatchedDebugを使い、「拡張機能自身のルールが実際にマッチしたこと」だけを通知の根拠にするconfig.local.jsonとdist/を.gitignoreに入れ、プライベートなスペース名をコミットに残さない- ローカルの Chrome demo(127.0.0.1:4173)、設定バリデーション、自動テスト、
docs/chrome-verification.mdの検証記録を同梱する
UI 抑止と通信ブロックを別レイヤで両方かける
Backlog のアップロード導線は、ファイル入力要素だけではなく、独自の添付ボタンや、エディタ内のドロップゾーン、クリップボードからの画像ペーストと複数あります。UI 側だけを抑止しても、ドラッグドロップやキーボードショートカットから別経路でアップロードが走る可能性があり、通信側だけをブロックしても「見た目は添付したつもりで、サーバーには届いていない」という紛らわしい挙動が残ります。
PoC では、両方のレイヤを別々にかける構成にしました。
- UI レイヤ: content script で
input[type="file"]と設定済みの添付コントロールを非表示・無効化し、ドロップ・ペースト・クリック・changeをaddEventListener(type, handler, true)の capture フェーズで止める - 通信レイヤ: Manifest V3 の declarativeNetRequest で、設定されたオリジンと
uploadPathsの組に POST が来たらblockする
content script 側では、Backlog の SPA 的な差し替えでファイル入力が後から挿入されるケースに合わせ、MutationObserver で DOM 変更を監視します。ただし、observer のマイクロタスク内で DOM を書き戻すと再入が起きやすいので、requestAnimationFrame 側に出し、「不変の DOM を再書き換えしない」ことも明示しました。
ドロップとペーストは、DataTransfer.types に Files が入るか、DataTransferItem.kind が file のアイテムがあるときだけブロックします。クリップボードがテキストとファイルの混在だった場合も、ファイルを含む時点でペーストイベント全体をブロックします。
declarativeNetRequest の regexFilter と requestMethods で POST だけ塞ぐ
Manifest V3 では、以前の webRequest によるブロッキングではなく、宣言的な declarativeNetRequest ルールを使います。本 PoC では、scripts/config.mjs でオリジンごと・パスごとにルールを生成しました。
export function makeRules(config) {
return config.origins.flatMap((origin) => config.uploadPaths.map((path) => ({
id: 0,
priority: 1,
action: { type: 'block' },
condition: {
regexFilter: `^${escapeRegex(origin)}${escapeRegex(path)}/?(\\?.*)?$`,
isUrlFilterCaseSensitive: true,
requestMethods: ['post'],
},
}))).map((rule, index) => ({ ...rule, id: index + 1 }));
}ここで意識したのは次の 3 点です。
regexFilterを使い、オリジンとパスを別々にエスケープして組み立てる。urlFilterのパターン言語は便利ですが、クエリ文字列の有無やトレーリングスラッシュを一度に捌くには regex のほうが見通しがよいrequestMethods: ['post']を付けて GET のダウンロード経路を塞がない。添付のダウンロードや Wiki のサムネイル取得は通したいisUrlFilterCaseSensitive: trueを明示する。Backlog の API パスは実測で大文字小文字が固定で、曖昧マッチにする理由がない
初期の設定例には /api/v2/space/attachment の公式 API だけを入れていましたが、実際の Chrome 検証で課題・コメント・Wiki・共有ファイルの各経路が別の非公開パスを叩くことが分かり、/AddAttachmentAsync.action・/AddWikiAttachmentAsync.action・/r/files/addSharedFile を実測で追加しました。この経路は Nulab の公開 API ではないため、将来の UI 変更で変わる可能性があります。本 PoC が「完全なアップロード防止」ではないと README で明記したのは、この種の経路変化を根拠にしています。
オリジンの検証を 1 スペース・HTTPS・パスなしに絞る
拡張機能の対象スペースは、config.local.json に明示する契約にしました。scripts/config.mjs のバリデーションは次を要求します。
- HTTPS であること
- ホスト名が
backlog.com・backlog.jp・backlogtool.comのいずれかのサブドメイン 1 段であること - 正規化後の
url.originが入力文字列と完全一致すること - ポート指定がないこと(demo の
http://127.0.0.1:4173を除く) uploadPathsが/から始まり、..を含まないパスだけであること
const backlog = url.protocol === 'https:' &&
/^[a-z0-9-]+\.(backlog\.(com|jp)|backlogtool\.com)$/.test(url.hostname);
const local = demo && origin === 'http://127.0.0.1:4173';
if ((!backlog && !local) || url.origin !== origin || url.port && !local) {
throw new Error(`Unsupported origin: ${origin}. ...`);
}ワイルドカードや末尾スラッシュを受け取り、警告なしに正規化して通す経路は入れていません。正規化後のオリジンが入力と一致しなければ例外にします。利用者には明示的な URL を要求し、config.local.json と dist/ は .gitignore でリポジトリから除外しています。これは、OSS のテンプレートリポジトリでスペース名が意図せずコミットされるのを避けるためです。
onRuleMatchedDebug で自分のルールのマッチだけを帰属させる
拡張機能側で「アップロードがブロックされました」という通知をユーザーに見せるとき、どの事象を根拠にするかで挙動が変わります。素朴に実装すると、ネットワークエラー全般やサーバー側の 400 系まで「拡張機能が止めた」と誤って見せかねません。
本 PoC では、declarativeNetRequestFeedback 権限と onRuleMatchedDebug イベントを使い、「拡張機能自身の upload_blocking ruleset がマッチしたとき」だけを通知の根拠にしました。
chrome.declarativeNetRequest.onRuleMatchedDebug?.addListener((info) => {
if (info.rule.rulesetId !== 'upload_blocking' || info.request.tabId < 0) return;
chrome.tabs.sendMessage(info.request.tabId, {
type: 'backlog-toolkit:upload-blocked',
}, { frameId: Math.max(info.request.frameId, 0) }).catch(() => {
// ...
});
});onRuleMatchedDebug は Chrome のドキュメントに明記のとおり、unpacked 拡張機能でのみ発火します。この PoC が「完全な通知の帰属」を保証できるのは unpacked でロードしているときに限る、という点も README とコメントに書きました。Chrome Web Store 配信版で同等の帰属を実現するには、別の通知設計が必要になります。
content script 側では、ルールマッチの通知を 500 ms 以内の二重発火でまとめ、通知のトーストと、添付コントロール脇の恒常的な説明文の両方を更新します。「別のタブ」や「拡張機能のインストール前に開かれていた画面」など listener がいないフレームに対する sendMessage は、.catch(() => {}) で握りつぶす設計にしました。
「サーバー認可の代替」ではないことを README と権限で明示する
このツールは、あくまでブラウザ側の人為的事故の低減が目的で、サーバー側の認可の代替ではありません。README では、無効化できないセキュリティ境界ではないことを明示しています。
- 拡張機能の無効化・削除、別ブラウザや curl 等の直接 API クライアントの利用、モバイルアプリ経由では当然ながら素通りする
- 未リストのアップロード経路、非 POST のアップロードプロトコル、独自エディタ、shadow DOM、クロスオリジンのフレームは、カバー対象外または保証対象外
- 既存の添付ファイルは削除しない
拡張機能が要求する権限も最小化しています。declarativeNetRequestWithHostAccess と declarativeNetRequestFeedback、および config.local.json に書かれた host permissions のみを要求し、バックエンドサーバー・テレメトリ・ストレージ・API 認証情報は持ちません。service worker が転送するのは、確認済みのルールマッチ通知だけで、URL やファイルの中身は送りません。network rule 側もリクエストボディを検査しない設計です。
Chrome 検証と検証記録を docs/chrome-verification.md に残す
PoC といえど、「本物の Backlog スペースで動くか」を検証しない限り、UI 抑止のセレクタや通信ブロックの対象 URL が実環境と合っていることは確認できません。認可されたテストプロジェクトで、Chrome 検証を実施しました。検証記録は docs/chrome-verification.md に残しています。
主なチェックは以下です。
- ローカル demo の 9 チェックが全てパスし、拡張機能を無効化すると期待どおり落ちる
- 課題の添付通信(
/AddAttachmentAsync.action)が Chrome の Network で(blocked:other)0 B になり、課題は送信されない - コメント添付も同じ経路で 0 B になり、コメントは送信されない
- Wiki の
/AddWikiAttachmentAsync.actionと共有ファイルの/r/files/addSharedFileを実測で発見し、設定へ追加した上で、拡張機能自身のルールマッチ通知で確認する - ネイティブのファイル入力、設定済みの課題・コメント・Wiki・共有ファイルのコントロールが非表示かつ無効化されている
- 課題テキストのペーストとコメントのプレビューは通常どおり動く。画像の clipboard ペーストはブロック通知が出て挿入されない
- ナビゲーションとリロード後も Wiki・共有ファイルのコントロールは制限されたまま
- 無害な共有ファイルフィクスチャはダウンロードでき、バイト列が一致する
- 設定していない Backlog スペース(および production ビルドでの localhost)には影響しない
- 自動回帰テスト 8 件がパスする。4 つのパス、オリジン・パスの境界、POST のみブロック、通知のルーティングを検証する
ネットワーク傍受の確認は、UI 抑止と独立に成立することを示すために、生成物側だけを一時的に書き換え、「ネイティブのファイルコントロールを残したまま通信ルールとルールマッチ通知だけが効く」状態で行いました。ソース側の防護は変更していません。確認後は通常ビルドを戻し、拡張機能を再読み込み、課題ページも再読み込みしています。
テスト中に作成した一時的な添付オブジェクト(課題フォーム上の未保存添付、Wiki と共有ファイルの発見用の無害なテキストフィクスチャ)は、検証後に削除しました。課題フォーム上の未保存添付については、サーバー側のオブジェクトクリーンアップそのものは検証していません。
残課題
本 PoC は「完全なアップロード防止」ではなく、以下は明示的に未検証または未対応です。
- 課題の実際の作成・編集、コメント投稿、Wiki 本文の保存までの end-to-end。現在の実スペース検証はいずれも送信前で止めてある
- 実 UI の一般的な通信失敗が「拡張機能によってブロックされた」と誤帰属されないことの手動確認。通知ソースと無関係ルールの挙動は自動テスト側でカバー済み
- 設定していない別の認証済み Backlog スペース。exact-origin の分離は自動テストと localhost での検証でカバー済み
- ネイティブ OS のドラッグドロップ。実スペースでは著者の手動確認のみで、自動化側では独立に確認していない。ローカル demo の合成ドロップ検証はパス済み
- ドキュメントのアップロード経路、追加のアップロード経路、Microsoft Edge 向けの別途検証
設定は、検証に使ったプロジェクトに限らず、設定したスペース全体に適用されます。今回の PoC はこの範囲での「うっかり防止」のためのツールです。
以上、Chrome 拡張機能 backlog-toolkit で Backlog の誤アップロードを UI と通信の両方で止める PoC を実装した、現場からお送りしました。