先日、技術ブログを投稿しようとした際に、こんなエラー画面が発生しました。
弊ブログでは、基本的にHTTPステータスが400以降のエラーが発生した場合、サーバに用意されているエラー画面にリダイレクトされる仕様になってます。
しかし、403エラーが発生しているにもかかわらず、ページ遷移が行われないため、アプリ(PHP)ではない部分でエラーが発生しているのでないかと思って調べてみました。
そこでわかったのですが、技術ブログを投稿する際に送信したリクエスト(通信)が悪意のあるものだと誤検知され、WAFに引っかかっていました。
(下記画像の赤枠部分)
弊ブログはさくらのレンタルサーバを使用しており、サーバの機能で「SiteGuard」というWAFが設定されています。
このWAFは仕様上、特定ページでON/OFFを切り替えることができません。
そのため、技術ブログのようにプログラムコードを含めたリクエストを送信するには、サイト全体でWAFを無効化しなければなりません。
しかし、安直にWAFをオフにしてしまうとXSSやSQLインジェクションなど、重大な脆弱性を発生させる危険性があります。
そのため、今回の目的は特定ページでのみWAFを回避するための方法になります。
WAFの仕組み
今回通信を遮断してしまっているWAFについての話です。
WAFとは、Web Application Firewallの略で、文字通りwebアプリのファイアウォールになります。
簡単な仕組みの図として、下記の画像を用意しました。
WAFはクライアント(利用者)とサーバの通信内容の検査を行います。
サーバに対して安全なリクエストは、通信を許可してサーバへ到達させます。
サーバに対して害を及ぼす危険性のあるリクエストは、WAFが通信の遮断を行います。
今回、記事冒頭のエラーが発生した原因は、自分がブログに投稿した内容が「サーバに対する攻撃である」とWAFに判断されたため起こったことになります。
WAFで通信が遮断された場合、リクエストがサーバまで到達することができません。
そのため、サーバで用意したエラー画面を取得できず、冒頭の「403エラーが発生した際、サーバに用意されているエラー画面に遷移しない」という事象が起こったんですね。
特定のページでWAFによる遮断を回避する
本題になります。
WAFによる通信の遮断を防ぐには、下記のような方法があります。
- WAFを無効化したドメインを用意し、ブログの管理はそのドメインで行う
- WAFに検知されない文字列に暗号化して送信し、サーバで復号する
- WAFを無効化する
「3」の方法はブログ全体で攻撃的なリクエストを防ぐことができなくなってしまうため、選択肢からは真っ先に外れます。
「1」の方法をとる場合は、ブログの表示用ドメインと管理用ドメインを使用することになります。
その場合は管理画面でWAFが使用できないため、安全性確保のため管理用ドメインにはbase64認証を追加するなど、代わりの対策が必要になります。
しかし、ブログの投稿ごとにユーザー認証と合わせてbase64認証を行うというのも、非常に手間がかかってしまうため、今回は「2」の方法を使用しました。
実装
実装は下記のようになります。
データの入力と送信を行うアプリを「send.html」としました。
send.htmlからデータを受信して、サーバサイドで実行されるアプリを「recv.php」とします。
まずは「send.html」の説明になります。
下記htmlのフォームには、ブログ内容を入力するテキストエリアがあり、formタグで囲われています。
通常であれば、送信ボタンのtypeはsubmitとなるのですが、そのまま送信してしまうと、WAFによりリクエストが遮断されてしまう可能性があります。
そのため、送信ボタンを押した際はsubmitを実行させず、javascriptで"post_blog()"関数を実行して、内容をbase64変換したのちにformのsubmitを発火します。
jsでのbase64変換はjs-base64を使用しています。
send.html
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/js-base64@3.7.2/base64.min.js"></script>
<script>
const post_blog = () => {
// submit前に投稿内容をbase64変換する
const textarea = document.getElementById('textarea');
textarea.value = Base64.encode(textarea.value);
// formのsubmitを呼び出し
document.getElementById('form').submit();
};
</script>
</head>
<body>
<form id="form" action="recv.php" method="post" enctype="multipart/form-data" autocomplete="off">
<div>
<textarea id="textarea" name="blog" rows="20" cols="40"></textarea>
</div>
<!-- クリック時にjsの"post_blog()"を実行する -->
<input type="button" onclick="post_blog()" value="送信" />
</form>
</body>
</html>
次に「recv.php」の説明になります。
こちらの実装は単純で、htmlから受け取ったbase64文字列を、PHPの関数である"base64_decode()"を使用して復号します。
recv.php
<?php
if($_SERVER["REQUEST_METHOD"] == "POST"){
$blog = base64_decode($_POST['blog']);
// 受け取った$blogに対する処理を行う
// ...
}
?>
さくらレンタルサーバのWAFはシグネチャのマッチングを行うものになります。
そのため、遮断されてしまうようなリクエストでも、WAFのシグネチャと一致しないよう、別の文字列に変換することで、遮断を回避できます。
しかし、このやり方にはいくつか問題があります。
-
対象のページだけ、ほかのページと比べてセキュリティリスクが上がること
(部外者にbase64変換された悪意のあるリクエストを送られた場合、サーバで実行される可能性がある) - レンタルサーバがbase64変換された文字列のシグネチャを使用し始めた場合、この方法が使えなくなってしまうこと
- base64変換の仕様上、送信する文字列のデータ量が、1.3倍ほどになってしまうこと
上記のようなリスクはありますが、セキュリティのリスクに関しては「そもそも限られたユーザーしかブログを変更できないようにする」
といった、基本的な実装になっていれば問題はないかと思います。
なので、この方法でWAFを回避する際には、上記のようなデメリットを受け入れられるか、ということを考えて採用してください。