Electronでwebview内の通信をキャプチャする

そうだ、通信をキャプチャしよう

ふとした時にwebview内の通信をキャプチャしたいなと思うことがあると思う。
Proxyを作ってMITMすることでもキャプチャすることは当然出来るのだけど、ElectronではDebuggerを通してChrome DevTools Protocolに従ってコマンドを送信することで通信をキャプチャすることが出来るのでそれを使うことにする。

Debuggerを取得する

<webview>用のDebuggerは、WebContentsを経由して取ることが出来る。
インスタンスを取るだけではDebuggerとしては動作しないので、WebContentsにattachする必要がある。

(let [debugger (-> webview .getWebContents .-debugger)]
  (.attach debugger "1.2"))

Chrome Devtoolsのドキュメントを確認する限り、Chrome DevTools Protocolには1.2と1.3があるようなのだが、Electron v.2.0.8は1.2にのみ対応しているみたいだ(1.3を指定するとエラーが出た)。
なので、attachに指定するProtocol Versionには1.2を指定している。

参考

ネットワークのキャプチャを有効化する

Debuggerをattachしただけではまだネットワークキャプチャは有効化されない。
有効化するためには、Network.enableコマンドを送ってやらなきゃならない。

コマンド送信にはsendCommandメソッドにコマンド名の文字列を渡してやる必要がある。

(let [debugger (-> webview .getWebContents .-debugger)]
  (.sendCommand debugger "Network.enable"))

キャプチャする

前段までで通信キャプチャは有効化されたので、あとは実際にキャプチャするだけとなった。
キャプチャは、Debuggerのmessageイベントのコールバック内で行う。

(defn network-capture-handler
  [debugger event method params]
  ;; 実際のキャプチャ処理
  )

(let [debugger (-> webview .getWebContents .-debugger)]
  (.on debugger "message"
       (fn [event method params]
         (network-capture-handler debugger event method params)))

messageイベントのコールバック関数に渡されるmethod変数にはDevToolsで発生したイベント名が格納されている。 これを基に処理を分岐させるのだが、レスポンスを見るだけなら具体的に関係があるのは以下のイベントになる。

  • Network.responseReceived
  • Network.loadingFinished

Network.responseReceivedがイベント名からしてレスポンスの受信時に発火するイベントのように見えるし実際正しいのだが、レスポンスの受信時に発火するのであってレスポンスの受信完了時に発火するわけではない。
なので、例えばTransfer-Encoded: chunkedでレスポンスが送信されてきている場合には、レスポンスボディを取得することは出来ない。
レスポンスの受信完了時にはNetwork.loadingFinishedイベントが発火される。
ではこれだけ見ていたら良いのか、というとそんなことは無い。 何故ならこのイベントではレスポンスヘッダやURLを取得することが出来ない。
なので、以下のような戦略を採る必要がある。

  1. Network.responseReceivedイベントでレスポンスヘッダやURLを確認してキャプチャしたいレスポンスか確認する。
  2. ボディが見たいリクエストであればrequestIdごとキューに追加する。
  3. Network.loadingFinishedではイベントを発火させたrequestIdがキュー内に存在するか確認する。
  4. キュー内にあるrequestIdが存在するなら、レスポンスボディを”Network.getResponseBody”メソッドをsendCommandすることで取得する。

具体的には以下のようなコードになる。

;; 今回はatomを使ったqueueを利用する
(def api-queue (atom ()))
(def api-endpoint #"http[s]://example.test/api/.+$")

(defn network-response-received-handler
  "Network.responseReceivedイベントを処理する"
  [req]
  (when (= (:method req) "Network.response-Received")
    ;; ↓ここではURLにマッチしたら処理することにする
    (when (re-find api-endpoint (-> (:params req) .-response .-url))
      (let [p (:params req)
            headers (-> p .-response .-headers
                        (js->clj :keywordize-keys true))]
                        ;; :keywordize-keysすると、keyがkeywordになる
                        ;; e.g. {"key" "value"} → {:key "value"}
                        ;; keywordの方が処理しやすいので指定する
        ;; ↓マッチしたresponseをqueueに放り込む
        (swap! api-queue conj {:request-id (-> p .-requestId)
                               :url (-> p .-response .-url)
                               :headers headers
                               :row-response (-> p .-response)})
        ))))

(defn network-loading-finished-handler
  "Network.loadingFinishedイベントを処理する"
  [req]
  (when (= (:method req) "Network.loadingFinished")
    (let [request-id (-> (:params req) .-requestId)
          ;; queueからrequestIdにマッチする要素を取りだす
          record (-> (filter #(= request-id (:request-id)) @api-queue)
                     first)]
      ;; queueの中に一致するレコードがあった場合には処理を行う
      (when record
        (.sendCommand (:debugger req) "Network.getResponseBody"
                      (clj->js {:requestId request-id})
                      (fn [e r]
                        ;; (.-body r)ボディが取得できるので後は適当に処理
                        ))
        ;; queueから削除する
        (swap! api-queue
          (fn [x] (remove #(= request-id (:request-id %)) x)))
        ))))

(defn network-capture-handler
  [debugger event method params]
  (let [req {:debugger debugger
             :event event
             :method method
             :params params}]
    (network-response-received-handler req)
    (network-loading-finished-handler req))

終わりに

これで通信を覗き見ることが出来るようになったので本題に入ることが出来る。
しかしその前にUIをreact-uwpで作り直すか悩むし(Material DesignよりはMetro UI系の方が好きなんだよね)、optimizationをwhitespace以上に上げたときに実行できない問題もいつ片付けるか決めないといけない。
しかし、Electronにせよreagent(Reactをcljs用にラップしたライブラリ)にせよ、普段の業務領域にはかすりもしないところをやっているの楽しい。