自分のブログに、自分で書いた記事が投稿できなくなりました。
原因はサーバーのWAF(Webアプリケーションファイアウォール)でした。記事の本文に含まれるコードが、攻撃と誤検知されて遮断されていたのです。回避を4種類試して全部失敗し、最後に「内容を変えずに、渡し方を変える」ことで解決しました。
WAFによる誤検知、二分探索を使った原因の特定、実体参照によるすり抜けがなぜ効かないのか、そしてWordPressのインポート機能を使った解決。技術記事を書く人ほど踏みやすい問題です。
403だけが返ってくる
記事をWordPressのREST APIに送ると、403が返りました。
status: 403
body: (空。WordPressのエラー形式ですらない)
ここに最初の手がかりがあります。WordPressが権限で拒否する場合、rest_cannot_create のようなコードを含むJSONを返します。中身が空の403は、WordPressに届く前に止められていることを意味します。
直前まで同じ手順で5本の記事を投稿できていました。コードも認証も変えていません。変えたのは記事の中身だけです。
どこが引っかかっているのかを探す
本文は約13,000文字あります。どこが原因かを目で探すのは現実的ではありません。
そこで二分探索を使いました。本文の前半だけを送ってみて、通れば原因は後半にあり、弾かれれば前半にある。これを繰り返して範囲を半分ずつ狭めていきます。13,000文字なら、14回ほどで場所が特定できます。
const ok = async (text) => {
const res = await fetch('/wp-json/wp/v2/posts', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce },
body: JSON.stringify({ title: 'probe', content: text, status: 'draft' })
});
return res.status === 201;
};
let lo = 0, hi = content.length;
while (hi - lo > 16) {
const mid = Math.floor((lo + hi) / 2);
if (await ok(content.slice(0, mid))) lo = mid; else hi = mid;
}
console.log(content.slice(lo - 50, hi + 16)); // 境界の前後を表示
結果はこうでした。
const HEADING_PATTERN = 正規表現でHTMLの見出しタグを拾う行
記事の中で紹介していた、見出しを抽出する正規表現です。WAFから見ると、HTMLタグを組み立てようとしている文字列=コード注入の試みに見えます。
上の関数は、投稿に成功した場合その投稿をすぐゴミ箱に入れています。二分探索は十数回の投稿を行うので、これをやらないと「probe」という記事が大量に残ります。調査のために本番環境を汚さないのは、調べる前に決めておくべきことです。
すり抜けようとして、4回失敗した
原因が分かったので、表示を変えずに検知だけ避ける方法を順に試しました。
| 試したこと | ねらい | 結果 |
|---|---|---|
| バックスラッシュを数値文字参照に | エスケープ記号を消す | 失敗 |
| バッククォートも数値文字参照に | コマンド置換に見える形を消す | 失敗 |
| コード部分の記号をすべて数値文字参照に | 記号の並びを根本から消す | 失敗 |
| JSONではなくフォーム形式で送る | 検査対象から外れることを期待 | 失敗 |
3番目で分かったことが重要でした。WAFは実体参照を元に戻してから判定しています。
これは考えてみれば当然です。攻撃者も同じ手を使うからです。<script> と書けば通ってしまうWAFには、防御の意味がありません。ブラウザが解釈する前の姿ではなく、解釈した後の姿で判定するのが正しい実装です。
つまり、表示が同じである限り、エンコードを変えても結果は変わりません。目的が「表示は同じまま通す」ことである以上、この方向に出口はないと分かりました。
ひとつだけ、通っていた経路があった
行き詰まったところで、それまでの作業を振り返りました。
記事の本文をブラウザに渡すために、13,000文字のJSONファイルをWordPressのメディアにアップロードしていました。そのアップロードは、一度も失敗していません。
中身は同じです。同じ正規表現も、同じコードも入っています。それでも通っていました。
| 送り方 | 中身 | 結果 |
|---|---|---|
| JSONの本文として送る | 同じ | 403 |
| フォームの項目として送る | 同じ | 403 |
| ファイルとして送る | 同じ | 成功 |
このWAFは、フォームの入力値は検査するが、ファイルの中身は検査していないという動きをしていました。
これは手抜きではなく、設計上の判断だと思われます。アップロードされるファイルは画像や動画やアーカイブで、中身は任意のバイト列です。すべてを攻撃パターンで検査すれば、正常なファイルが大量に誤検知されます。
正規の道具が、すでに用意されていた
ファイルなら通る。ではファイルとして記事を投入する方法はあるか。
あります。WordPressのインポート機能です。サイト間で記事を引っ越すための仕組みで、記事をWXR(WordPress eXtended RSS)という形式のXMLファイルとして読み込みます。
<item>
<title>読む体験をつくる</title>
<dc:creator><![CDATA[sakurai_dev]]></dc:creator>
<content:encoded><![CDATA[ ここに記事本文のHTML ]]></content:encoded>
<wp:post_name><![CDATA[reading-experience]]></wp:post_name>
<wp:status><![CDATA[publish]]></wp:status>
<wp:post_type><![CDATA[post]]></wp:post_type>
<category domain="category" nicename="tech"><![CDATA[技術系]]></category>
<category domain="post_tag" nicename="react"><![CDATA[React]]></category>
</item>
本文は CDATA という区画に入れます。この中身はXMLとして解釈されないので、HTMLタグをそのまま書けます。
「ツール → インポート → WordPress」からこのファイルをアップロードすると、記事・カテゴリ・タグがまとめて作られます。一度で通りました。
この方法は、WAFをだましているわけではありません。WordPressが元から用意している正規の移行手段を、そのまま使っただけです。防御は一切弱めていません。<script>alert(1)</script> は今も遮断されたままです。
WAFを無効にしなかった理由
途中、WAFを一時的に無効にして試したことがあります。実際、正規表現の行は通るようになりました。しかし記事全体はまだ弾かれ、<script> も遮断されたままでした。防御が複数の層で構成されていたためです。
ここで「全部無効にする」という選択もありました。しかし、それはやめました。
理由は単純です。この問題は今後も繰り返し起きます。技術記事を書く以上、本文にコードが入るのは避けられません。次に何が引っかかるかは予測できません。そのたびにWAFを無効にしていたら、いずれ「戻し忘れ」が起きます。
作業のたびに防御を外す運用は、いつか必ず外したままになります。人間の注意力に頼る仕組みは、長く続けると必ず破れます。防御を触らずに済む経路が見つかったなら、そちらを選ぶべきです。
今回の学び
まず、空の403は「届いていない」という意味だということ。アプリケーションが返すエラーには、たいてい理由が書いてあります。理由の無いエラーは、アプリケーションより手前で止められています。どこを調べるべきかが、これで決まりました。
次に、手作業で探さないこと。13,000文字から原因の1行を見つけるのに、二分探索なら14回で済みます。目で追えば見落としますし、時間もかかります。範囲を半分ずつ狭める方法は、原因が1か所に絞れる問題ならほぼ常に使えます。
そして、同じ内容でも、渡し方が変われば結果が変わるということ。今回いちばんの発見はこれでした。中身をどう変形しても通らなかったものが、経路を変えた瞬間に通りました。行き詰まったら、変える対象を「中身」から「経路」に移す。この視点は他の場面でも使えます。
最後に、防御を弱める解決を最後の手段にすること。無効にすれば即座に解決しますが、それは問題を先送りしているだけです。しかも「一時的に」で済んだためしがありません。遠回りに見えても、防御を維持したまま通る道を探す価値はありました。


