🐡

(macOS)Safariで任意のWeb APIに接続できない状況を擬似的に再現する

2023/02/21に公開

はじめに

記事の投稿にあたり、以下の環境で動作確認を行いました。

  • macOS Monterey 12.6.3
  • Safari 16.3

要件

以下のWebサイトがあると仮定します。

  • 普段はhttps://example.com/api/v2/(プライマリAPI)を呼び出している。
  • システム障害など何らかの理由でプライマリAPIに接続できない場合はhttps://api.example.net/v1/(セカンダリAPI)を呼び出す。
  • プライマリAPIとセカンダリAPIどちらも接続できない場合は障害告知のtwitterフィードを表示する。

これを踏まえて以下の要件を満たす方法を考えます。

  • 2パターンの状況を再現したい。
    1. プライマリAPIのみ接続できない
    2. プライマリAPIとセカンダリAPIどちらも接続できない
  • デプロイされているHTMLやJavaScriptに変更を加えてはならない。
  • SafariのWeb Inspectorを開いてfetchXMLHttpRequestの挙動を上書きしてはならない。
  • APIサーバーに変更を加えて500 Internal Server Errorを返したりコネクションのタイムアウトを再現したりさせる必要はない。
  • あくまでJavaScriptのfetchXMLHttpRequestが失敗する状況を再現できればよい。
  • https://example.com/api/*のように接続できないエンドポイントをワイルドカードで指定したい。

方法

macOSあるいはSafariの標準機能でURLにもとづいたアクセス制限が実現できれば嬉しいのですが、今回の要件を満たすことはできませんでした。そこでSafari Web Extension(ブラウザ拡張機能)を作ることにします。

拡張機能といっても大袈裟なものではありません。browser.declarativeNetRequest.updateDynamicRules()を実行するだけの小さな拡張機能です。

拡張機能の作り方については以下の記事で説明していますので参考にしてください。

実装

ここからはSafari Web Extensionのテンプレートがビルドできたものとして説明を進めます。

Resources/manifest.json

まずはmanifest.jsonを編集します。修正するのはpermissionsフィールドのみです。

{
  "manifest_version": 3,
  "default_locale": "en",
  "name": "__MSG_extension_name__",
  "description": "__MSG_extension_description__",
  "version": "1.0",
  "icons": {
    "48": "images/icon-48.png",
    "96": "images/icon-96.png",
    "128": "images/icon-128.png",
    "256": "images/icon-256.png",
    "512": "images/icon-512.png"
  },
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "js": [
        "content.js"
      ],
      "matches": [
        "*://example.com/*"
      ]
    }
  ],
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "images/toolbar-icon-16.png",
      "19": "images/toolbar-icon-19.png",
      "32": "images/toolbar-icon-32.png",
      "38": "images/toolbar-icon-38.png",
      "48": "images/toolbar-icon-48.png",
      "72": "images/toolbar-icon-72.png"
    }
  },
  "permissions": [
    "declarativeNetRequest",
    "activeTab"
  ]
}

Resources/background.js

続いてbackground.jsを編集します。declarativeNetRequestを利用してHTTPリクエストを遮断します。

const rule = {
  id: 1,
  priority: 1,
  action: {
    type: "block"
  },
  condition: {
    urlFilter: "https://example.com/api/*"
  }
};

const rules = [rule];

browser.declarativeNetRequest.updateDynamicRules({
  removeRuleIds: rules.map((rule) => rule.id),
  addRules: rules,
});

動作確認

manifest.jsonとbackground.jsの編集が終わったら、Command + Rでプロジェクトのビルドを行ってください。その後、Safariを起動してurlFilterで指定したパターンに一致するリクエストが遮断されていることを確認してください。

addRulesに渡すrules配列の要素数に制限はありません。リクエスト遮断のパターンはいくつでも追加できます。

以上で要件を満たすことができました。今回はブラウザ拡張のUIを作り込むことが目的ではないため説明はここで終わります。

注意点

background.jsのremoveRuleIdsに注目してください。

browser.declarativeNetRequest.updateDynamicRules({
  removeRuleIds: rules.map((rule) => rule.id),
  addRules: rules,
});

もしremoveRuleIdsを省略すると、最後に適用されたルールが残り続けます。Command + QでSafariのプロセスを終了して再起動してもリセットされず残り続けます。

また、removeRuleIdsは無効なidを無視します。例えば最後に適用されたルールのidが[1, 2, 3]の場合、removeRuleIds[2, 3, 4]を渡すと[2, 3]のルールだけが削除されます。idが1のルールは削除されずに残り続けます。idが4のルールは存在しないので無視されます。

urlFilterを書き換えたのに古いルールが残り続けている場合はremoveRuleIdsに意図したとおりのidが渡されているか確認してください

urlFilterの詳細

urlFilterで使える特殊文字は4種類あります。

  1. *: ワイルドカードです。任意の長さの文字にマッチします。
  2. |: 左右アンカーです。パターンの先頭で使えばURLの先頭に、末尾に使えばURLの末尾にマッチします。
  3. ||: ドメイン名のアンカーです。パターンの先頭で使えば(サブ)ドメインの先頭にマッチします。
  4. ^: セパレータです。英数字と記号(_-%.)を除外した文字にマッチします。

urlFilterは以下の条件に従って適用されます。

  • urlFilter(ドメイン名のアンカーまたは左アンカー) + パターン + (右アンカー)で構成されます。かっこ内は省略化膿を意味します。
  • urlFilterフィールドを省略すると、すべてのURLにマッチするパターンと見なされます。ただし、urlFilter: ""として空文字を指定することはできません。
  • ||*を指定することはできません。代わりに*を指定してください。
  • urlFilterよりも細かくパターンを指定したい場合はregexFilterで正規表現を指定できます。ただし、両方のフィールドを同時に指定することはできません。
  • urlFilterはASCII文字のみで構成されている必要があります。国際化ドメイン名にマッチさせる場合はpunycode形式でエンコードされた文字列を指定してください。

詳細についてはchrome.declarativeNetRequestを参照してください。

fetch()の挙動

リクエスト遮断中のfetch()呼び出しはどうなるのでしょうか?SafariのWeb Inspectorを起動して、確認してみましょう。

> fetch('https://example.com', {});
> 
> [Error] Resource blocked by content blocker
> 	Console Evaluation (Console Evaluation 1:3)
> 	evaluateWithScopeExtension
> 	(anonymous function)
> ...
> [Error] Fetch API cannot load https://example.com due to access control checks.

上記のように「Resource blocked by content blocker」としてPromiseがリジェクトされます。

(余談)検討したものの見送った方法

要件を満たす方法をいくつか検討しました。それぞれ見送った理由を説明します。

macOSのスクリーンタイム

macOSには標準機能としてコンテンツのフィルタリング機能が搭載されています。設定はシステム環境設定のスクリーンタイムから行えます。この機能を利用すれば特定のWebサイトへのアクセスを制限できます。

しかし、アクセス制限は許可リスト方式でした。また、私が調べた限りワイルドカードは利用できないようでした。つまり、https://example.com/api/*へのリクエストを遮断するにはそのパスを除外してリクエストされる可能性のあるパスをすべて許可する必要があります。

さらに、リクエストのリファラが許可したWebサイトと一致する場合、そのリクエストは遮断できませんでした。例えばhttps://example.comを許可したとします。そのWebサイトからhttps://fonts.googleapis.com/cssを参照している場合、そのリクエストは遮断できません。

他にもアクセス制限中は厄介な挙動をします。SafariのプライベートモードはWebサイト固有のデータが永続化されないので、例えばCookieの挙動を確認する際に便利です。しかし、アクセス制限を有効にするとSafariのプライベートモードが利用できません。毎回手作業で設定を開いてCookieを削除する必要が生じます。

SafariのLocal Overrides

Safariには特定のHTTPレスポンスを任意の内容で上書きできるLocal Overrides機能が搭載されています。この機能を利用すればリクエストが遮断されている状況を再現できます。

ただし、私が調べた限り、この機能は個別のレスポンス単位で設定する前提のようでした。ワイルドカードで指定したパスに一致したら500 Internal Server Errorを返す、といった設定はできないようでした。

サードパーティーのSafari Web Extension

Google Chrome向けの拡張機能はいくつかヒットしましたが、Safari向けかつ今回の要件を満たせるものが見つけられませんでした。また、仮に要件を満たせる拡張機能が見つかったとしてもサードパーティーの拡張機能はセキュリティの点で不安があります。今回の要件を満たすための実装はほとんど手間がかかりませんし、安全性を確認するのに時間を費すなら自作するほうが手間を省けます。

参考資料

  1. Creating a Safari web extension | Apple Developer Documentation.html
  2. Blocking content with your Safari web extension | Apple Developer Documentation
  3. chrome.declarativeNetRequest - Chrome Developers
  4. Local Overrides - WebKit
  5. requestly/modify-headers-manifest-v3 - GitHub

Discussion