[Chrome] SeleniumでBasic認証を突破する

# はじめに
SeleniumでBasic認証が行われているページにアクセスする場合は、以下のようにするとアクセスできます(Chromeの場合)。

```python
driver.get("http://username:password@example.com")
```
https://yoshikiito.net/blog/archives/473/

この時、ユーザ名が例えば、`mail@example.com`のようなメールアドレスの場合は、`@`を`%40`に置き換えて、以下のようにします。

```python
driver.get("http://mail%40example.com:password@example.com")
```

`@`や`#`等、URLの中で特別な意味を持つ文字は、パーセントエンコーディングしてあげる必要があります。

  参考：[Percent-encoding (パーセントエンコーディング) | MDN Web Docs](https://developer.mozilla.org/ja/docs/Glossary/percent-encoding)

# setExtraHTTPHeadersを使ってBasic認証に対応させる(※注意事項あり)
ここからが本題で、以下の投稿で使った[Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/)にある[Network.setExtraHTTPHeaders](https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-setExtraHTTPHeaders)メソッドを使って、Basic認証を突破することに挑戦してみます。

- [SeleniumとHeadless ChromeでページをPDFに保存する](https://qiita.com/mochi_yu2/items/a845e52b8aa677f132bf)
- [【Chrome】Seleniumでページ全体のスクリーンショットを撮る](https://qiita.com/mochi_yu2/items/d1a2c91776eba93c25e5)

`Network.setExtraHTTPHeaders`は、HTTPヘッダを付与することのできるメソッドで以下のように使います。

```python:set-extra-http-headers.py
custom_headers = {
    "X-Custom-Header1": "value1",
    "X-Custom-Header2": "value2",
}
driver.execute_cdp_cmd("Network.enable", {})
driver.execute_cdp_cmd("Network.setExtraHTTPHeaders", {"headers": custom_headers})
```

最初に結論と注意事項としたことを書くと、以下のようになります。
- `setExtraHTTPHeaders`を使って、Basic認証は突破できる
- ただし、認証対象ページ内にある別ドメインのリソース読み込み等で、**ヘッダから認証情報が漏れる可能性がある**

## Basic認証突破の作戦
Basic認証の仕組みについては、下記の記事に詳しく記載されていました。詳しくはリンク先記事をご参照ください。

https://www.atmarkit.co.jp/ait/articles/1608/10/news021.html

Basic認証では「Authorization」ヘッダに認証情報を付けて、ブラウザからサーバに送信します。このヘッダの付与を`setExtraHTTPHeaders`メソッドを使って実現する、というのが今回のBasic認証対応の試みです。

## Basic認証を行うサイトの環境づくり
まずは、検証用にBasic認証がかけられたページを準備します。
Docker公式の[Apacheイメージ](https://hub.docker.com/_/httpd)をベースに作成します。

```dockerfile:Dockerfile
FROM httpd:2.4

RUN echo 'AuthUserFile /usr/local/apache2/htdocs/.htpasswd\n\
AuthGroupFile /dev/null\n\
AuthName "Basic Auth"\n\
AuthType Basic\n\
Require valid-user' > /usr/local/apache2/htdocs/.htaccess

RUN htpasswd -b -c /usr/local/apache2/htdocs/.htpasswd user@example.com secret
RUN sed -i.bak -e "s/AllowOverride None/AllowOverride All/g" /usr/local/apache2/conf/httpd.conf
```

以下のようなHTMLコンテンツも用意しておきます。
jQueryは実際には使っていませんが、後ほどの確認用にCDNからの読み込みコードを入れておきます。

```html:index.html
<html>
<head>
  <title>Basic Auth Page</title>
  <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<body>
  <h1>Secret Page</h1>
</body>
```

上記からDockerのイメージを作成し、`index.html`をドキュメントルートにマウントして起動します。

```console
$ docker build -t basic-auth-apache .
$ docker run -dit --name apache-app -p 8080:80 -v "$PWD/index.html":/usr/local/apache2/htdocs/index.html basic-auth-apache
```

ブラウザで、http://localhost:8080/index.html にアクセスすると、認証ダイアログが表示され、設定したID/パスワードを入力するとページが表示されました。
![basic-auth.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1423540/218e99e4-2c32-84e3-4e8f-a0e8779707d4.png)


## Seleniumコード
`setExtraHTTPHeaders`メソッドを利用して、Basic認証に対応させたページにアクセスするSeleniumのコードは下記です。

```python:cdp-basic-auth.py
import base64
import time
from selenium import webdriver
from webdriver_manager.chrome import ChromeDriverManager

# 与えられた認証情報をもとに、Authorizationヘッダを作成するメソッド
def get_auth_header(user, password):
    b64 = "Basic " + base64.b64encode('{}:{}'.format(user, password).encode('utf-8')).decode('utf-8')
    return {"Authorization": b64}

# Webdriver ManagerでChromeDriverを取得
driver = webdriver.Chrome(executable_path=ChromeDriverManager().install())

# Authorizationヘッダを付与
driver.execute_cdp_cmd("Network.enable", {})
driver.execute_cdp_cmd("Network.setExtraHTTPHeaders", {"headers": get_auth_header("user@example.com", "secret")})
# Basic認証が必要なページにアクセス
driver.get('http://localhost:8080/index.html')
time.sleep(5)

driver.close()
driver.quit()
```

実行すると、Basic認証で保護されたページが表示されました。
![basic_auth_result1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1423540/f36d70cd-9f16-e736-b60a-f19c336cb47b.png)

## 気になっていること
Chrome DevTools Protocolの[Network.setExtraHTTPHeaders](https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-setExtraHTTPHeaders)メソッドの説明には、以下のような記述があります。

> Specifies whether to always send extra HTTP headers with the requests from this page.

この「always」という部分の記述が気になり、
**常にAuthorizationヘッダが送られているのではないか？
(=認証情報が外部のサイトに送信されている状態でないか。)**
という懸念を抱きました。

そこで、リクエスト内容を取得して確認することにしました。

<!--
[先ほどの参考記事](https://www.atmarkit.co.jp/ait/articles/1608/10/news021_2.html)の最後に記載しているように、通常、Webブラウザを利用してBasic認証が行われているページにアクセスした時、その後にサイト内で認証が必要なページにアクセスした場合は、ブラウザがAuthorizationヘッダを自動的に付与してくれるようになっています。
また、ページ内で別ドメインから読み込まれているリソース(例: CDNからのJS/CSS等)にリクエストを送る場合は、Authorizationヘッダを送信しないようです。

今回の`setExtraHTTPHeaders`メソッドの場合は、
**常にAuthorizationヘッダが送られているのではないか？(=認証情報が外部のサイトに送信されている状態でないか。)**
という懸念を抱きました。
-->
### リクエスト内容の取得
リクエスト内容の取得については、下記の記事を参考にさせていただきました。

https://blog.motikan2010.com/entry/2019/11/20/%E3%80%90Python%E3%80%91Selenium%E5%81%B4%E3%81%8B%E3%82%89%E3%83%8D%E3%83%83%E3%83%88%E3%83%AF%E3%83%BC%E3%82%AF%E6%83%85%E5%A0%B1%E3%82%92%E5%8F%96%E5%BE%97%E3%81%99%E3%82%8B

Basic認証の検証用ページにアクセスし、その後、別サイトにアクセスするという流れの時のリクエスト内容（URLとAuthorizationヘッダの値）をファイルに記録するコードです。

```python:get-network-log.py
import base64
import time
import json
from selenium import webdriver
from webdriver_manager.chrome import ChromeDriverManager
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities

# 与えられた認証情報をもとに、Authorizationヘッダを作成するメソッド
def get_auth_header(user, password):
    b64 = "Basic " + base64.b64encode('{}:{}'.format(user, password).encode('utf-8')).decode('utf-8')
    return {"Authorization": b64}

# ネットワークに関するログを取得するための設定
capabilities = DesiredCapabilities.CHROME
capabilities['goog:loggingPrefs'] = { 'performance': 'ALL' }
# Webdriver ManagerでChromeDriverを取得
driver = webdriver.Chrome(
    executable_path=ChromeDriverManager().install(),
    desired_capabilities=capabilities
)

# Authorizationヘッダを付与
driver.execute_cdp_cmd("Network.enable", {})
driver.execute_cdp_cmd("Network.setExtraHTTPHeaders", {"headers": get_auth_header("user@example.com", "secret")})
# Basic認証が必要なページにアクセス
driver.get('http://localhost:8080/index.html')
time.sleep(5)
# 別のサイトにアクセス
driver.get('https://qiita.com/')

# ネットワーク情報からリクエストURL・Authorizationヘッダの値を抽出し、ファイルに書き出す
with open('network.log', 'w') as f:
    for entry_json in driver.get_log('performance'):
        entry = json.loads(entry_json['message'])
        if entry['message']['method'] != 'Network.requestWillBeSent' :
            continue
        print(entry['message']['params']['request']['url'],
              # Authorizationヘッダがない場合は、「No Authorization header」と出力する
              entry['message']['params']['request']['headers'].get('Authorization', "No Authorization header"),
              file=f)

driver.close()
driver.quit()
```

### 確認結果
実行すると次のような結果が得られました。
`localhost`のコンテンツ以外にも、HTMLから読み込んでいるCDNのjQuery取得のリクエスト、別サイト（Qiita)へのアクセス時にも認証情報が付与されてしまっていることがわかりました。

```
http://localhost:8080/index.html Basic dXNlckBleGFtcGxlLmNvbTpzZWNyZXQ=
https://code.jquery.com/jquery-3.6.0.min.js Basic dXNlckBleGFtcGxlLmNvbTpzZWNyZXQ=
http://localhost:8080/favicon.ico Basic dXNlckBleGFtcGxlLmNvbTpzZWNyZXQ=
https://qiita.com/ Basic dXNlckBleGFtcGxlLmNvbTpzZWNyZXQ=
・・・（省略）・・・
```

上記から、`setExtraHTTPHeaders`メソッドでAuthorizationヘッダを設定する際の注意事項として、

- **Authorizationヘッダの認証情報が外部のサイトに送信される**

という点に注意が必要です。
一応、`setExtraHTTPHeaders`メソッドの`headers`パラメータに`{}`を指定するとカスタムヘッダの付与はされなくなるようです。

```python
# Authorizationヘッダを付与
driver.execute_cdp_cmd("Network.enable", {})
driver.execute_cdp_cmd("Network.setExtraHTTPHeaders", {"headers": get_auth_header("user@example.com", "secret")})
# Basic認証が必要なページにアクセス
driver.get('http://localhost:8080/index.html')
time.sleep(5)
# カスタムヘッダの付与をクリア
driver.execute_cdp_cmd("Network.setExtraHTTPHeaders", {"headers": {}})
# 別のサイトにアクセス
driver.get('https://qiita.com/')
time.sleep(5)
```

上記のコードで実行すると、QiitaへのAuthorizationヘッダの送信は止まりました。
同じページ内で、別ドメインからのjQueryのJSファイル読み込みについては、試した範囲では回避はできませんでした。

```
http://localhost:8080/index.html Basic dXNlckBleGFtcGxlLmNvbTpzZWNyZXQ=
https://code.jquery.com/jquery-3.6.0.min.js Basic dXNlckBleGFtcGxlLmNvbTpzZWNyZXQ=
http://localhost:8080/favicon.ico Basic dXNlckBleGFtcGxlLmNvbTpzZWNyZXQ=
https://qiita.com/ No Authorization header
・・・（省略）・・・
```

# おわりに
今回は、`setExtraHTTPHeaders`メソッドを試してみました。Basic認証は一応突破できたものの、ページ内で別ドメインからのリソース読み込みを行っている場合は、認証情報を送信してしまう恐れがあるため、適用場面は限られてしまうのかなと思います。
`setExtraHTTPHeaders`メソッド自体は、他のヘッダ付与にもちろん利用できるので、他の活用があるかもしれません。

# 参考
- [SeleniumでBasic認証有のサイトにログインする](https://yoshikiito.net/blog/archives/473/)
- [HTTP 認証 - HTTP | MDN](https://developer.mozilla.org/ja/docs/Web/HTTP/Authentication)
- [Authorization - HTTP | MDN](https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Authorization)
- [Percent-encoding (パーセントエンコーディング) - MDN Web Docs 用語集: ウェブ関連用語の定義 | MDN](https://developer.mozilla.org/ja/docs/Glossary/percent-encoding)
- [「HTTP」の仕組みをおさらいしよう（その4）](https://www.atmarkit.co.jp/ait/articles/1608/10/news021.html)
- [【Python】Selenium側からネットワーク情報を取得する](https://blog.motikan2010.com/entry/2019/11/20/%E3%80%90Python%E3%80%91Selenium%E5%81%B4%E3%81%8B%E3%82%89%E3%83%8D%E3%83%83%E3%83%88%E3%83%AF%E3%83%BC%E3%82%AF%E6%83%85%E5%A0%B1%E3%82%92%E5%8F%96%E5%BE%97%E3%81%99%E3%82%8B)