7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Chrome拡張でbackgroundからコピーをする方法とChrome拡張のセキュリティについて

Last updated at Posted at 2023-08-02

2023年8月現在、 chrome.offscreendocument.execCommand("copy") を使うのが良いのではないかと思う。

拡張機能のアイコンをクリックすると、クリップボードに copy this text という文字列がコピーされるサンプル。

manifest.json
{
    "manifest_version": 3,
    "name": "Background Copy",
    "version": "1.0.0",
    "action": {
        "default_title": "Background copy"
    },
    "permissions": ["offscreen", "clipboardWrite"],
    "background": {
        "service_worker": "background.js"
    }
}
background.js
// オフスクリーンページを作成する。
// https://developer.chrome.com/docs/extensions/reference/offscreen/#example-maintaining-the-lifecycle-of-an-offscreen-document
let creating;
async function setup() {
    const url = chrome.runtime.getURL("offscreen.html");
    /*
    // for Chrome >=116.
    const contexts = await chrome.runtime.getContexts({
        contextTypes: ["OFFSCREEN_DOCUMENT"],
        documentUrls: [url],
    });
    if (contexts.length>0) {
        return;
    }
    */
    // for Chrome <=115.
    for (const client of await clients.matchAll()) {
        if (client.url===url) {
            return;
        }
    }

    if (creating) {
        await creating;
    } else {
        creating = chrome.offscreen.createDocument({
            url: "offscreen.html",
            reasons: ["CLIPBOARD"],
            justification: "for clipboard",
        });
        await creating;
        creating = null;
    }
}

chrome.action.onClicked.addListener(async () => {
    await setup("offscreen.html");
    await chrome.runtime.sendMessage("copy this text");
});
offscreen.html
<!DOCTYPE html>
<html>
    <head>
        <title>offscreen</title>
        <script src="offscreen.js"></script>
    </head>
    <body>
        <textarea id="text"></textarea>
    </body>
</html>
offscreen.js
chrome.runtime.onMessage.addListener(message => {
    text.value = message;
    text.select();
    document.execCommand("copy");
});

作ったもの

今開いているページのタイトルとURLをコピーするChrome拡張。プレインテキストとMarkdownがある。

こういう拡張は他にもいっぱいあるのだけど、クリックして出てきたメニューから形式を選択などではなく、ワンクリックでコピーできる。また、 chrome://version/ などのコンテントスクリプトがインジェクションできないようなページでも動く。

動機

Chrome拡張はけっこう怖い。権限の強いChrome拡張だと、 google.com ドメインで好きなスクリプトを動かせる。Chrome拡張を公開していると「あなたのChrome拡張を売らないか?」というメールが届くという話も聞く。Chrome拡張は自動でアップデートされるので、買った悪い人はevilなスクリプトを仕込むのだろう。

ということで、使っていたChrome拡張をオフにしていった。残ったのがページのタイトルとURLをコピーするChrome拡張。OGPに対応したページやチャットツールは増えているけれど、まだまだ対応していないものもある。特に社内限定のページだと外からは読めないのでどうしようもない。どこかにリンクを貼るときにはタイトルが付いていると親切。

自分で作れば安心。しかし、全く同じものを作るのも芸が無い。使っていたChrome拡張はアイコンをクリックするとメニューが出てきて、いくつかの形式が選べる。そのうちプレインテキストとMarkdownしか使っていなかったので、ワンクリックでコピーできる2個の拡張を作ることにした。

ページのタイトルとURLをコピーするだけなので簡単なはず……と思ったら、意外と難しかったので、この記事で共有する。

ページのタイトルとURLの取得

記事タイトルのコピーとはあまり関係が無いが、コピーするものをどうやって得るのかという話。

コンテントスクリプトをインジェクションすれば取れる。 document.titledocument.URL 。しかし、セキュリティ的には不安になる。自分のために自分で作っているから別に良いのだけど、どうせなら他の人にも使ってほしいし、権限は抑えたい。また、 chorme:// のようにコンテントスクリプトをインジェクションできないページもある。

chrome.tabs でも取れる。

tab = (await chrome.tabs.query({active: true, lastFocusedWindow: true}))[0] でアクティブなウィンドウのアクティブなタブが取得できて、これの tab.titletab.url

ただし、 tabs 権限が必要。権限が無いと、 tab は取得できるが、 tab.title などが含まれていない。Chrome拡張の権限の欄には「閲覧履歴の読み取り」と書かれる。ちょっと怖い。実際、悪意があれば、裏で動き続けて閲覧履歴を取得し続けることもできるので、不必要に強い権限。

そこで activeTab 権限。

The "activeTab" permission gives an extension temporary access to the currently active tab when the user invokes the extension - for example by clicking its action. Access to the tab lasts while the user is on that page, and is revoked when the user navigates away or closes the tab.

権限は、実際は権限を使っていても拡張の利用者には表示されないものがあり、 activeTab もその一種。権限のところに何も書かれない。安心。ユーザーとして考えると、これで良いのかという気もするが……。

これで、上記の chrome.tabs.query でページタイトルなどが取得できるようになるのかと思ったけど、ならない。 chrome.action.onClicked.addListener(tab => {})tab には入っている。こちらからクエリするのはNGで、Chromeが渡してくるものはOKということだろうか。

chrome.tabs.query は chrome:// ページのタイトルなどは取得できないが、 onClicked の引数の tab なら入っている。場合によっては、 tabs 権限よりも強いらしい。

ページのタイトルとURLの取得にはこれを使うのが一番良いだろう。

ちなみに、この activeTab 権限と scripting 権限があれば、アクティブなタブにはコンテントスクリプトがインジェクションできる。マジで?

background.js
chrome.action.onClicked.addListener(tab => {
    chrome.scripting.executeScript({
        target: {tabId: tab.id},
        func: () => {
            alert(document.cookie);
        }
    });
});

こういう拡張をインストールして、 google.com で拡張のアイコンをクリックすると、 google.com のcookieが取れる。scripting も権限があってもユーザーには表示されない。

ユーザーとしての認識を改めないといけない。特に権限の注意が出ないなら、インストールしても、アイコンをクリックしても安心だと思っていた。

If the extension is compromised the attacker would need to wait for the user to invoke the extension before obtaining access. And that access only lasts until the tab is navigated or is closed.

拡張が悪いやつの手に渡ったときに、裏で勝手に動いたりしなくて猶予があるから良いだろってことっぽい。良いのか……。

コピーの方法

chrome.clipboard

いかにもクリップボードだが、deprecatedだし、画像をコピーすることしかできない。

この WebExtension API は主に標準の web クリップボード API がクリップボードに画像を書き込めないために存在しています。標準 web API にこの力が備わった時には、この API は非推奨になるはずです。

良く分からないけど、一時的なAPIなのか……?

navigator.clipboard.writeText

今は、普通はこれを使う。

ただ、後述するような使い方をすると、「Document is not focused」というエラーになる。悪用防止のためか、ユーザーの操作に対してコピーするときにしか使えない。 clipboardWrite 権限を付けても同様。ユーザーが「コピー」ボタンをクリックするような使い方なら問題無いが、今回はそうではない。

document.execCommand("copy")

古の方法であり、deprecated。これは、Ctrl+Cを実行するようなもので、別途 <textarea> を用意しておいて、コピーしたい文字列を設定し、選択してから document.execCommand("copy") でコピーができる。ちなみに <textarea>display: none だと動かず、 position: absolute; left: -9999px; top: -9999px のようにして、画面外に飛ばすのが定跡らしい。何ともいまいちな感じだけど、今回はこれを使うしかなさそう。

ドキュメントでは、次のように書かれているが、なぜか権限が無くても動いた。

image.png

脆弱性と言うのでは……?

MDNにも権限は不要と書かれていた。

Chrome の場合:
ユーザーが作成したイベントハンドラーの外でクリップボードに書き込む場合でも、"clipboardWrite" は必要ありません。

仕様なのか……?

ユーザーとしては、クリップボード関係の権限が書かれていなくても、裏でクリップボードにコピーする可能性があると思っておかなければいけないのかもしれない。

コピーをどこで実行するか

document.execCommand("copy")document も、 navigator.clipboard.writeText()navigator も、service workerであるbackground.jsの環境には無い。

コンテントスクリプト

コンテントスクリプトをインジェクションするなら、そこでコピーもするという手がある。しかし、コンテントスクリプトインジェクションはできればしたくない。また、 navigator.clipboard はHTTPのページでは使えない。

ポップアップ

ならば、ポップアップを使えば良いだろう。拡張をクリックしたときにアイコンの下に出てくるやつ。これは普通のHTMLなので、 documentnavigator もある。ポップアップが見えてしまうが、これは悪いことではなく、「コピーしました」とか表示すれば、分かりやすくてむしろ良い。

しかし、 chrome.action.onClicked とポップアップが排他だった。Manifesetでポップアップを指定していても、 chrome.action.onClicked にリスナーを追加すると無効化されてしまう。タイトルとURLの取得のために、 chorme.action.onClicked は使いたい。

スクリプトでポップアップを開くことはできるでしょ? と探すと、 openPopup があった。しかし、dev版だけだった。「ドキュメントにあるのに動かないんだけど?」でドキュメントが「dev版のみ」に修正されたりしていた。

オプションページ

window が無いからポップアップ以外のページを開くこともできないし、他に何か……で、オプションページを chrome.runtime.openOptionsPage で開けることに気が付いた。

オプションページを開いて、オプションページは、コピーしてすぐに自分自身を閉じる。これは動いた。でも、一瞬ページが開くのがだいぶ鬱陶しい。

Offscreen

background.js から拡張内のページを、できればユーザーに見せずに、開ければ良いんだよなぁ……で、ちょうど良いものがあった。 chrome.offscreen。最近追加されたっぽい。

サンプルにクリップボードを扱うものがあるし、用途として合っていそう。そんなことより、 chrome.clipboard を使えるようにしてくれれば良いのにという気がしなくもないが……。

一度に1個のページしか開けず、開いているときに再度開こうとするとエラーになる。すでに開いたページがあるかどうかを調べる必要があって地味に面倒。ちなみに、今手元のChromeは115なので、「Before Chrome 116」の手順を使う必要がある。

審査

Chrome拡張はだいぶ前にも作った覚えがある。たしか、(拡張ごとではなくアカウントごとに)500円くらい払って、後は好きに公開できたはず。

今回公開しようとしたら、ユーザーに表示されないものも含めて、権限ごとに「なぜその権限が必要なのか」を書かされた。審査もされるらしく、すぐには公開されない。1日くらいで公開された。

権限を気にしないでChrome拡張をインストールするのは危険だと昔から思っていたし、今回拡張を作ってみて、「権限を気にしても危ないのでは?」という気もしてきた。でも、その危険性の分、審査があるから、まあ良いのか?

7
3
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
7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?