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

2023年8月現在、 `chrome.offscreen` と `document.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");
});
```

# 作ったもの

https://chrome.google.com/webstore/detail/copy-title-and-url-plaint/dbpmnlkkdalcefmncpljkhgckadejhmm

https://chrome.google.com/webstore/detail/copy-title-and-url-markdo/ckdbokhdddnimjhcknibciolpbebkhpl

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

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

# 動機

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

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

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

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

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

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

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

`chrome.tabs` でも取れる。

https://developer.chrome.com/docs/extensions/reference/tabs/

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

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

そこで `activeTab` 権限。

https://developer.chrome.com/docs/extensions/mv3/manifest/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

https://developer.chrome.com/docs/extensions/reference/clipboard/

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

https://developer.mozilla.org/ja/docs/Mozilla/Add-ons/WebExtensions/API/clipboard

> この 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25276/4c7e0a29-2169-4cf7-13ec-0a12ec91405c.png)

https://developer.chrome.com/docs/extensions/mv3/declare_permissions/

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

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

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

https://developer.mozilla.org/ja/docs/Mozilla/Add-ons/WebExtensions/Interact_with_the_clipboard#%E7%89%B9%E5%AE%9A%E3%81%AE%E3%83%96%E3%83%A9%E3%82%A6%E3%82%B6%E3%83%BC%E3%81%AB%E3%81%8A%E3%81%91%E3%82%8B%E7%95%99%E6%84%8F%E4%BA%8B%E9%A0%85

仕様なのか……？

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


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

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

## コンテントスクリプト

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

## ポップアップ

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

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

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

https://developer.chrome.com/docs/extensions/reference/action/#method-openPopup

https://github.com/GoogleChrome/developer.chrome.com/issues/2602

## オプションページ

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

https://developer.chrome.com/docs/extensions/reference/runtime/#method-openOptionsPage

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

## Offscreen

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

https://developer.chrome.com/docs/extensions/reference/offscreen/

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

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

# 審査

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

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

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