WordPressでDB接続情報をAWS Secrets Managerから取得してみた

2021.12.29

いわさです。

WordPressではMySQL/MariaDBとの接続が必要になります。
そして、データベース接続情報は、通常wp-config.phpファイルにハードコーディングされます。

これをどうにか出来るものなのか、認証情報のオフロード先といえばAWS Secrets Managerだろうということで、試してみました。

先にまとめ

  • Secrets Managerから取得は出来るがいまいち
    • wp-config.phpはリクエストの度に実行される
    • SecretsManagerでは都度取得するのではなくキャッシュすることを推奨している
    • リクエスト間共有の仕組みがインメモリでは難しいのでファイルか環境変数になってしまいそう
    • 環境変数はMySQL公式ドキュメントでも秘匿情報保存先として非推奨となっている
  • AWSのサービスを使って楽になる道より、従来どおりwp-config.phpの保護をしっかり行う方向に力を注いだほうが良さそう
    • 適切な権限を設定する
    • バージョン管理に秘匿情報が含まれないようにする
    • HTMLルートにインストールしている場合は階層を移動させる

テンプレート

今回は以下のような構成のパブリックEC2+RDSの構成で環境を作成します。
RDS(MariaDB)はローテーションなしのSecretsManagerがアタッチされています。

CloudFormationテンプレートはこちら
Tak1wa/aws-cfn-wordpress-secretrds

インスタンスプロファイルはシークレットの参照権限が付与されています。

WordPressセットアップ

CloudFormationスタックの作成後、出力タブに表示されるURLよりWordPressサイトへアクセスしましょう。
WordPressが使うデータベースのCreateのみしておきます。

sh-4.2$ mysql -h hoge-rds.cpnu9ipu74g4.ap-northeast-1.rds.amazonaws.com -u hoge -p
Enter password:
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 41
Server version: 10.5.12-MariaDB-log managed by https://aws.amazon.com/rds/

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MariaDB [(none)]> CREATE DATABASE wordpress;
Query OK, 1 row affected (0.00 sec)

セットアップが開始されます。
今回はマネジメントコンソールからシークレットを取得し入力します。
このインターフェースを通して、wp-config.phpwp-config-sample.phpから生成されます。

上記によると、

wp-config.php ファイルは、インストールディレクトリのルートにある wp-config-sample.php を必要に応じて編集し、wp-config.php という名前で保存することで手動で作成することもできます。

とのことなので、初回のシークレット手動入力を省く形でユーザーデータでwp-config.phpを事前に生成してしまうことも出来ますね。
あるいは、wp core configでコマンドベースで設定する方法も有効だと思います。

アクセス出来るようになりました。

AWS Secrets Managerから取得するよう変更してみる

セットアップ後のwp-config.php(抜粋)は以下のようになっています。

/var/www/html/wp-config.php

<?php

...

// ** MySQL 設定 - この情報はホスティング先から入手してください。 ** //
/** WordPress のためのデータベース名 */
define( 'DB_NAME', 'wordpress' );

/** MySQL データベースのユーザー名 */
define( 'DB_USER', 'hoge' );

/** MySQL データベースのパスワード */
define( 'DB_PASSWORD', 'J$SA5,O8aqcRS*9)' );

/** MySQL のホスト名 */
define( 'DB_HOST', 'hoge-rds.cpnu9ipu74g4.ap-northeast-1.rds.amazonaws.com' );

/** データベースのテーブルを作成する際のデータベースの文字セット */
define( 'DB_CHARSET', 'utf8mb4' );

/** データベースの照合順序 (ほとんどの場合変更する必要はありません) */
define( 'DB_COLLATE', '' );

...

先程画面から入力した値がハードコーディングされていますね。
AWS Secrets ManagerからRDSへの接続情報を取得するようなコードに変更してみます。

AWS SDK for PHPを使う方法はいくつか用意されています。
今回はComposerを使ってみましょう。

sh-4.2$ pwd
/var/www/html
sh-4.2$ sudo php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
sh-4.2$ sudo php -r "if (hash_file('sha384', 'composer-setup.php') === '906a84df04cea2aa72f40b5f787e49f22d4c2f19492ac310e8cba5b96ac8b64115ac402c8cd292b8a03482574915d1a8') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"
Installer verified
sh-4.2$ sudo php composer-setup.php
All settings correct for using Composer
Downloading...

Composer (version 2.2.1) successfully installed to: /var/www/html/composer.phar
Use it: php composer.phar

sh-4.2$ php -r "unlink('composer-setup.php');"
PHP Warning:  unlink(composer-setup.php): Permission denied in Command line code on line 1
sh-4.2$ sudo php -r "unlink('composer-setup.php');"

上記コマンドはインストーラーのバージョンによって異なるため、以下を必ず確認してください。
Composer

aws/aws-sdk-phpをインストールします。

sh-4.2$ ./composer.phar -V
Composer version 2.2.1 2021-12-22 22:21:31
sh-4.2$ sudo ./composer.phar require aws/aws-sdk-php
Using version ^3.208 for aws/aws-sdk-php
./composer.json has been created
Running composer update aws/aws-sdk-php
Loading composer repositories with package information
Updating dependencies
Lock file operations: 12 installs, 0 updates, 0 removals
  - Locking aws/aws-crt-php (v1.0.2)
  - Locking aws/aws-sdk-php (3.208.7)
  - Locking guzzlehttp/guzzle (7.4.1)
  - Locking guzzlehttp/promises (1.5.1)
  - Locking guzzlehttp/psr7 (2.1.0)
  - Locking mtdowling/jmespath.php (2.6.1)
  - Locking psr/http-client (1.0.1)
  - Locking psr/http-factory (1.0.1)
  - Locking psr/http-message (1.0.1)
  - Locking ralouphie/getallheaders (3.0.3)
  - Locking symfony/deprecation-contracts (v2.5.0)
  - Locking symfony/polyfill-mbstring (v1.23.1)
Writing lock file
Installing dependencies from lock file (including require-dev)
Package operations: 12 installs, 0 updates, 0 removals
  - Downloading symfony/polyfill-mbstring (v1.23.1)
  - Downloading mtdowling/jmespath.php (2.6.1)
  - Downloading ralouphie/getallheaders (3.0.3)
  - Downloading psr/http-message (1.0.1)
  - Downloading psr/http-factory (1.0.1)
  - Downloading guzzlehttp/psr7 (2.1.0)
  - Downloading guzzlehttp/promises (1.5.1)
  - Downloading symfony/deprecation-contracts (v2.5.0)
  - Downloading psr/http-client (1.0.1)
  - Downloading guzzlehttp/guzzle (7.4.1)
  - Downloading aws/aws-crt-php (v1.0.2)
  - Downloading aws/aws-sdk-php (3.208.7)
  - Installing symfony/polyfill-mbstring (v1.23.1): Extracting archive
  - Installing mtdowling/jmespath.php (2.6.1): Extracting archive
  - Installing ralouphie/getallheaders (3.0.3): Extracting archive
  - Installing psr/http-message (1.0.1): Extracting archive
  - Installing psr/http-factory (1.0.1): Extracting archive
  - Installing guzzlehttp/psr7 (2.1.0): Extracting archive
  - Installing guzzlehttp/promises (1.5.1): Extracting archive
  - Installing symfony/deprecation-contracts (v2.5.0): Extracting archive
  - Installing psr/http-client (1.0.1): Extracting archive
  - Installing guzzlehttp/guzzle (7.4.1): Extracting archive
  - Installing aws/aws-crt-php (v1.0.2): Extracting archive
  - Installing aws/aws-sdk-php (3.208.7): Extracting archive
5 package suggestions were added by new dependencies, use `composer suggest` to see details.Generating autoload files5 packages you are using are looking for funding.Use the `composer fund` command to find out more!

Composeの準備が出来たら、wp-config.php上に取得処理を追加します。

wp-config.php

// を使用し、必ず UTF-8 の BOM なし (UTF-8N) で保存してください。

require 'vendor/autoload.php';
use Aws\SecretsManager\SecretsManagerClient;
$client = new SecretsManagerClient(['version'=>'2017-10-17','region'=>'ap-northeast-1']);
$result = $client->getSecretValue(['SecretId'=>'hoge-secret']);
$values = json_decode($result['SecretString'],true);

// ** MySQL 設定 - この情報はホスティング先から入手してください。 ** //
/** WordPress のためのデータベース名 */
define( 'DB_NAME', 'wordpress' );

/** MySQL データベースのユーザー名 */
define('DB_USER', $values['username']);

/** MySQL データベースのパスワード */
define('DB_PASSWORD', $values['password']);

/** MySQL のホスト名 */
define('DB_HOST', $values['host']);

/** データベースのテーブルを作成する際のデータベースの文字セット */
define( 'DB_CHARSET', 'utf8mb4' );

/** データベースの照合順序 (ほとんどの場合変更する必要はありません) */
define( 'DB_COLLATE', '' );

アクセス出来ることを確認しました。

ただし冒頭まとめたように、wp-config.phpはリクエストの度にロードされる共通モジュールです。
このままだとリクエストの度にSecrets Managerからシークレットを取得することになりますね。

これはSecrets Managerのベストプラクティスから外れている状態です。

シークレットを効率的に使用するために、使用するたびにシークレットの値を取得しないでください。

引用元: Secrets Manager のベストプラクティス - AWS Secrets Manager

それどころか、AWS APIのレート制限による障害点になるリスクがあります。

また、WordPressのよくあるチューニングでは1リクエストあたりの処理を軽量化させる手法があり、wp-cron.phpを無効化するなどもよくある手法だと思います。そういった中で、毎度wp-config.phpでAPIアクセスするのはWordPressから見てもよくなさそうですね。
SSGでS3から配信するなど、コンテンツ配信時にApacheへリクエストを送信しないタイプのコンテンツサイトであれば、管理コンソールの利用頻度によってはありなのかもしれない、ってくらいでしょうか。。。

解決のためにセキュアなインメモリ領域などにシークレットを退避しておきたいところですが、PHPだとリクエスト間の共有化が難しいようですね。
Systemdを使ってファイルや環境変数などに保存し、それを利用する形になりそうです。

WordPress + Secret Managerの記事があまり見当たらない理由がわかりました。

データベース接続にIAM認証を使う方法は?

IAM認証を使えるかも調べてみました。
IAM認証を行う場合は、以下が必要です。

  • MySQL/PostgreSQLの特定バージョンである必要がある
  • 許可されているロールからRDSからトークンを取得する
  • 事前に証明書などをセットアップしておく
  • パスワードの代わりにトークンとSSLを使う

今回使っているデータベースがMariaDBということで、IAM認証については利用出来ませんでした。
MySQLで今度試してみます。

利用出来た場合でも、どのタイミングでトークンを取得しどうやって更新していくかを検討する必要がありそうです。この点はSecrets Managerと同じですね。
ただしトークンの有効期間が最大15分なので、こちらについては環境変数やファイル保存をしたとしてもSecrets Managerよりは低リスクかなという印象です。

AWS以外でのアプローチは?

WordPressではwp-config.phpはかなり攻撃者に狙われやすいモジュールです。
代表的な対策として以下は必ず行っておきましょう。

  • 最小限のユーザーへのみしか参照・変更をできないようにする
    • ファイルパーミッションで所有者のみアクセス可能に
  • Webサーバーによって外部公開されないようにする
    • Apacheで外部アクセスを明示的に拒否設定に
    • wp-config.phpをルートディレクトリより1つ上の階層に移動させる。wp-config.phpに限っては1つ上の階層に移動した場合でも共通モジュールから呼び出される。ドキュメントルートの外に配置され、外部公開されるリスクが減る
  • シークレットとともにバージョン管理システムに保存されないようにする
    • 管理対象外にする
    • あるいはdotenvなどで別ファイルに切り出して、シークレットのみ管理対象外にする

さいごに

本日はWordPressのwp-config.phpモジュールでSecrets Managerを使ったシークレットの取得を行ってみました。
AWS SDKの提供も実装は簡単ですが、利用にあたっては課題が多いですね。シークレットのローテーションが出来るようになるので悩ましいところです。

現状ではまず、wp-config.phpに秘匿情報を含ませないようにすることで運用性を高めるよりも、wp-config.phpには秘匿情報が含まれるものだという前提の上でそれを保護するための対策を手厚く行うのが優先かなという所感です。