管理しているサイトで、テーマのコードを読んで見直していたときのことです。テーマの functions.php(または読み込んでいるファイル)の中に、前は Code Snippets のプラグインに入れていた「404 のときの転送」の処理がありました。この処理は転送の行き先を確かめていなかったので、外のサイトへ飛ばせる形になっていました。
このような弱さを、オープンリダイレクトといいます。この記事では、どんな処理だったか、なぜ外へ飛べるのか、どう直したかと、作業環境で確かめた結果をまとめます。
どんな処理だったか
AMP のページの URL は、記事の URL の末尾に /amp が付いた形です。AMP をやめたあとも、検索結果や外のサイトのリンクには AMP の URL が残ることがあるので、その URL を 404 にせず、元の記事へ転送するための処理でした。形は次のとおりです(関数の名前は例です)。
// 404 のとき、AMP のページの URL(末尾が /amp)を、元のページへ転送する
function my_redirect_amp_404() {
if ( ! is_404() ) {
return;
}
$url = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : '';
// URL に amp を含むとき、amp を取り除いた URL へ 301 で転送する
if ( strstr( $url, 'amp' ) ) {
wp_redirect( str_replace( 'amp', '', $url ), 301 );
exit;
}
}
add_action( 'template_redirect', 'my_redirect_amp_404' );
| 書き方 | 役目 |
|---|---|
add_action( 'template_redirect', … ) |
ページを出す前に動かす |
is_404() |
ページが見つからないときだけ進む |
$_SERVER['REQUEST_URI'] |
開かれた URL のパスとクエリ。ブラウザーから来た値そのもの |
str_replace( 'amp', '', $url ) |
URL の中の amp を取り除く |
wp_redirect( …, 301 ) |
取り除いた URL へ 301 で転送する。行き先のホストは確かめない |
問題は、ブラウザーから来た値を、確かめないまま転送の行き先にしていたことです。この値は、URL を開く人が自由に書けるので、そのまま使うと、行き先を外から決められてしまいます。
なぜ外へ飛べるのか
// で始まる URL は、別のホストとして読まれる
//evil.test/x のように // で始まる URL は、「プロトコル相対 URL」と呼ばれる形です。ブラウザーは、今のページと同じ http か https で、evil.test というホストの /x を開きます。そのため、Location: //evil.test/x が返ると、ブラウザーは外のサイトへ移ってしまいます。この処理では、次のような URL で、その形が作れてしまいます。
| 開いた URL | amp を取り除いたあと(転送の行き先) |
|---|---|
//ampevil.test/x |
//evil.test/x |
/amp/evil.test/x |
//evil.test/x |
あいだの amp が消えると、/ が 2 つ続く形になります。どちらもそのサイトでは 404 になる URL なので、この処理が動きます。リンクはサイトの URL から始まっているので、外のサイトへ移ることに気づきにくいのが困るところです。
/\ で始まる URL も、外のサイトとして読まれることがある
/\evil.test/x のように、スラッシュのあとに逆スラッシュが続く形もあります。ブラウザーによっては逆スラッシュをスラッシュと同じに扱うので、//evil.test/x と同じく、外のサイトとして読まれることがあります。
WordPress の本体が先に直すものもある
WordPress の本体の redirect_canonical() には、URL の中で続いたスラッシュを 1 つにまとめて転送する処理があります。作業環境では、//ampevil.example/x はこの処理が先に動いて、同じサイトの /ampevil.example/x へ転送されました(下の「作業環境で確かめた結果」)。
とはいえ、/amp/evil.test/x は、開いた URL の中に // がないので、本体の処理では止まりません。直す前のコードのままの作業環境では、Location: //evil.test/x が返りました。本体が止めてくれる形があっても、テーマの側で行き先を確かめておく必要があります。
直し方
直したのは次の 2 つです。どちらも、転送の行き先が自分のサイトの中かを確かめるためのものです。
wp_redirectをwp_safe_redirectにする- 転送してよいのは「
/が 1 つで始まるパス」だけにする。//と/\で始まるもの、逆スラッシュ・制御文字を含むものは転送せず、ふつうの 404 のままにする
判定の部分は、試しやすいように関数に分けました。下の「検証用スクリプトで確かめた結果」でも、この関数をそのまま使っています。
// 転送先を決める。転送しないときは null
function my_amp_redirect_target( $url ) {
if ( ! strstr( $url, 'amp' ) ) {
return null;
}
$to = str_replace( 'amp', '', $url );
// / で始まらないものは転送しない
if ( '/' !== substr( $to, 0, 1 ) ) {
return null;
}
// // や /\ で始まるもの(ほかのホストとして読まれる形)は転送しない
if ( preg_match( '#^/[/\\\\]#', $to ) ) {
return null;
}
// 逆スラッシュ・制御文字を含むものは転送しない
if ( preg_match( '/[\\\\\x00-\x1f\x7f]/', $to ) ) {
return null;
}
return $to;
}
// 404 のとき、AMP のページの URL を、同じサイトの元のページへ転送する
function my_redirect_amp_404() {
if ( ! is_404() ) {
return;
}
$url = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : '';
$to = my_amp_redirect_target( $url );
if ( null === $to ) {
return; // 転送しない(WordPress がそのまま 404 を返す)
}
wp_safe_redirect( $to, 301 );
exit;
}
add_action( 'template_redirect', 'my_redirect_amp_404' );
| 書き方 | 役目 |
|---|---|
'/' !== substr( $to, 0, 1 ) |
/ で始まらないもの(https://… など)は転送しない |
preg_match( '#^/[/\\\\]#', $to ) |
// または /\ で始まるものは転送しない |
preg_match( '/[\\\\\x00-\x1f\x7f]/', $to ) |
逆スラッシュと、改行・タブなどの制御文字を含むものは転送しない |
return null; |
転送しないときは null を返し、WordPress のふつうの 404 にまかせる |
wp_safe_redirect( $to, 301 ) |
行き先のホストを確かめてから転送する |
wp_safe_redirect がすること
wp_safe_redirect() は、wp_validate_redirect() で行き先を確かめてから転送する関数です。行き先のホストが自分のサイトのホストでなければ、既定では管理画面(admin_url())へ転送します。ほかのホストへも転送したいときは allowed_redirect_hosts のフィルターで許すホストを足せますが、今回は自分のサイトの中だけに転送できればよいので、フィルターは使っていません。
wp_safe_redirect だけでなく、パスも確かめた理由
wp_safe_redirect() だけでも、外のホストへの転送は止まります。ただ、止めたときの行き先は管理画面になるので、ないページを開いた人や検索エンジンのクローラーを、管理画面のログインへ送ってしまいます。
そこで、おかしな形の URL は、そもそも転送せずに 404 を返すようにしました。wp_safe_redirect() は、パスの確かめを書き間違えたときの、2 つ目の確かめとして残しています。
検証用スクリプトで確かめた結果
判定の関数(上の my_amp_redirect_target() と、直す前の同じ部分)を、WordPress を読み込まない検証用スクリプトに入れて、PHP 8.3.32 で動かしました。表の null は、転送しないという意味です。
| 受け取ったパス | 直す前の行き先 | 直したあとの行き先 |
|---|---|---|
/ |
null(amp を含まない) | null |
/no-such-page |
null | null |
/no-such-page/amp |
/no-such-page/ |
/no-such-page/ |
//ampevil.test/x |
//evil.test/x |
null |
/\ampevil.test/x |
/\evil.test/x |
null |
/%5Campevil.test/x |
/%5Cevil.test/x |
/%5Cevil.test/x |
/amp/evil.test/x |
//evil.test/x |
null |
/amp\evil.test/x |
/\evil.test/x |
null |
/%2F%2Fevil.test |
null(amp を含まない) | null |
/%2F%2Fevil.test/amp |
/%2F%2Fevil.test/ |
/%2F%2Fevil.test/ |
/path?x=1/amp |
/path?x=1/ |
/path?x=1/ |
/a(タブ)b/amp |
/a(タブ)b/ |
null |
直す前に外のサイトへ向いていた 4 つ(太字)は、直したあとはどれも転送しなくなりました。%5C や %2F のようにエンコードされたままのものは、/ 1 つで始まるパスとして通ります。ブラウザーは Location の中の %2F をホストの区切りとしては読まないので、同じサイトの中のパスになります。
WordPress の関数の返す値
作業環境の WordPress(7.1.3)を読み込む検証用スクリプトで、関数の返す値だけを見ました(DB には書いていません)。wp_safe_redirect() が中で使っている wp_validate_redirect() に、いくつかの値を渡しています。
| 渡した値 | wp_validate_redirect( 値, 'FALLBACK' ) |
|---|---|
/no-such-page/ |
/no-such-page/ |
//evil.test/x |
FALLBACK(外のホストなので通さない) |
https://evil.test/x |
FALLBACK |
/\evil.test/x |
/evil.test/x(逆スラッシュが取り除かれ、同じサイトのパスになる) |
/%2F%2Fevil.test/ |
/%2F%2Fevil.test/ |
wp_safe_redirect() はこの関数で確かめ、FALLBACK のところを管理画面の URL にして転送します。wp_safe_redirect() そのものを呼んで管理画面へ転送されるところは、試していません。
作業環境で確かめた結果
直したテーマの作業環境で、curl -sI で応答を見ました。同じテーマを使っている別のサイトの作業環境はまだ直す前のコードだったので、同じ URL で比べています。
| 開いた URL | 直す前 | 直したあと |
|---|---|---|
/ |
200 | 200 |
/no-such-page |
404 | 404(ふつうの 404) |
/no-such-page/amp |
301 → 同じサイトの /no-such-page/ |
301 → 同じサイトの /no-such-page/ |
//ampevil.example/x |
301 → 同じサイトの /ampevil.example/x |
301 → 同じサイトの /ampevil.example/x(外へは飛ばない) |
/%5Campevil.example/x(/\ をエンコードしたもの) |
404 | 404 |
/amp/evil.test/x |
301 → //evil.test/x(外のサイト) |
404 |
//ampevil.example/x の 301 は、上で書いた WordPress の本体の処理によるもので、テーマの処理によるものではありません。また、%5C の 404 は、WordPress の 404 の画面ではなく、Web サーバー(Apache)の 404 の画面でした。作業環境の Apache の設定で、%5C を含む URL が WordPress まで届いていないためで、本番のサーバーで同じになるかは試していません。そのため、/\ の形は、上の検証用スクリプトのほうで確かめています。
本番で確かめたこと
本番では、ふつうのページの開き方で、前と同じに動くかを確かめました。結果は次のとおりです。
| 開いた URL | 結果 |
|---|---|
| トップ | 200 |
| 記事 | 200 |
| ないページ | 404 |
記事の URL の末尾に /amp |
301 で同じサイトの記事へ(前と同じ) |
外のサイトへの転送になる形(// など)は、本番では試していません。なお、同じテーマを使っている別のサイトにも同じ処理があったので、そちらにも同じ直しを入れました。
同じファイルのエスケープ抜けも直した
同じファイルの中に、本文を色の枠で囲むショートコードがありました。このショートコードは、属性の title・color・msg を、そのまま <style> と <p> に出していました。
// [my_point title="ポイント" color="#85db8f" msg="本文"] のように使うショートコード
function my_point_shortcode( $atts ) {
$atts = shortcode_atts( array( 'title' => '', 'msg' => '', 'color' => '#85db8f' ), $atts );
$title = $atts['title'];
$msg = $atts['msg'];
$color = $atts['color'];
return '<style>.my-point { border-color: ' . $color . '; }'
. '.my-point::before { content: "' . $title . '"; color: ' . $color . '; }</style>'
. '<p class="my-point">' . $msg . '</p>';
}
add_shortcode( 'my_point', 'my_point_shortcode' );
たとえば title に "}</style><script>… のような値を入れると、<style> を閉じて、スクリプトを書き込めてしまいます。ただ、ショートコードの属性を入れられるのは、記事を書ける人(投稿者や編集者)だけです。外から誰でも使える 404 の転送に比べると、重さは低いと考えています。とはいえ、記事を開いた人のブラウザーで、書き込んだスクリプトが動く形はよくないので、あわせて直しました。
function my_point_shortcode( $atts ) {
$atts = shortcode_atts( array( 'title' => '', 'msg' => '', 'color' => '#85db8f' ), $atts );
$title = $atts['title'];
$msg = $atts['msg'];
$color = $atts['color'];
// 出す前に整える
$msg = wp_kses_post( $msg ); // 投稿に使える HTML だけを残す
$title = esc_attr( $title ); // " や < > をそのまま出さない
$color = sanitize_hex_color( $color ); // #fff や #85db8f の形だけを通す
if ( ! $color ) {
$color = '#85db8f'; // 色のコードでなければ、既定の色
}
$color = esc_attr( $color );
return '<style>.my-point { border-color: ' . $color . '; }'
. '.my-point::before { content: "' . $title . '"; color: ' . $color . '; }</style>'
. '<p class="my-point">' . $msg . '</p>';
}
add_shortcode( 'my_point', 'my_point_shortcode' );
| 書き方 | 役目 |
|---|---|
wp_kses_post( $msg ) |
投稿の本文に使える HTML だけを残す。<strong> などはそのまま、<script> や onclick は取り除く |
esc_attr( $title ) |
" や < > を、文字のまま出す形(" など)に変える |
sanitize_hex_color( $color ) |
#fff や #85db8f の形だけを通す。それ以外は null |
if ( ! $color ) { $color = '#85db8f'; } |
色のコードでなければ、既定の色にする |
作業環境の WordPress で、それぞれの関数に値を渡して、返る値を見ました。色の名前や、タグを含む値がどうなるかを中心に試しています。
| 関数 | 渡した値 | 返った値 |
|---|---|---|
sanitize_hex_color |
#85db8f |
#85db8f |
sanitize_hex_color |
#fff |
#fff |
sanitize_hex_color |
red |
null |
sanitize_hex_color |
rgb(255,0,0) |
null |
sanitize_hex_color |
red;}</style><script> |
null |
esc_attr |
"}</style><script>x</script> |
"}</style><script>x</script> |
wp_kses_post |
<strong>太字</strong> |
<strong>太字</strong> |
wp_kses_post |
<script>x</script> |
x(タグだけが消え、中の文字が残る) |
wp_kses_post |
<span onclick="x()">a</span> |
<span>a</span>(onclick が消える) |
1 つ注意があります。sanitize_hex_color() は、red のような色の名前や rgb() の書き方を通さないので、これまで color="red" のように書いていた記事は、既定の色で表示されるようになります。直す前に、記事の中で色の名前を使っているものがないかを探しておくと安心です。
テーマの設定の値を出していた alt
テーマの設定で入れた値を、画像の alt にそのまま出していた所もありました。こちらも、ショートコードと同じく、出す前にエスケープしていませんでした。
<!-- 前:テーマの設定の値を、そのまま alt に出していた --> <img src="<?php echo esc_url( $image_url ); ?>" alt="<?php echo $my_banner_title; ?>"> <!-- 後 --> <img src="<?php echo esc_url( $image_url ); ?>" alt="<?php echo esc_attr( $my_banner_title ); ?>">
ここに値を入れられるのは管理者だけなので、重さは低いのですが、ほかの所とそろえて esc_attr() を通すようにしました。こうしておくと、値に " が入っても、HTML が崩れません。
自分のテーマを見直すときの探し方
同じ形がないかは、テーマのフォルダの中を、次の言葉で検索すると見つけやすいです。grep コマンドの -r でフォルダの中をすべて見て、-n で行の番号もいっしょに出します。
# テーマのフォルダの中で、転送している所と、URL をそのまま使っている所を探す cd wp-content/themes/(テーマのフォルダ) grep -rnE "wp_redirect|header\( *['\"]Location|REQUEST_URI" --include=*.php .
| 探す言葉 | 見ること |
|---|---|
wp_redirect |
行き先が、ブラウザーから来た値でできていないか。できているなら wp_safe_redirect にして、行き先の形も確かめる |
header('Location |
WordPress の関数を通さずに転送していないか |
$_SERVER['REQUEST_URI'] |
開かれた URL を、確かめずに転送や画面の出力に使っていないか |
見つかった所は、行き先や出力する値がどこから来ているかをたどって確かめます。あわせて、add_shortcode で作ったショートコードが、属性をエスケープせずに出していないかも見ておくとよいです。
気をつけること
str_replace( 'amp', '', … )は、URL の中のすべてのampを取り除きます。検証用スクリプトでは、/example-page/ampが/exle-page/になりました。今回は、ふつうの転送の動きを前と同じにするため、取り除き方は変えていませんwp_safe_redirect()で止めたときの行き先は、既定では管理画面です。ないページを開いた人を管理画面へ送らないように、おかしな形の URL は転送しないで 404 にしました- ほかのホストへ転送したいときは、行き先を自分で組み立てずに、
allowed_redirect_hostsで許すホストを足します - Code Snippets などから中身を変えずに移した処理は、移したあとに一度読み直しておくと安心です
まとめ
- 404 のときの転送で、
$_SERVER['REQUEST_URI']から作った行き先を、確かめずにwp_redirectしていた //で始まる行き先はプロトコル相対 URL で、外のサイトへ移る。/\で始まるものも、ブラウザーによっては同じwp_safe_redirectにし、転送するのは「/が 1 つで始まるパス」だけにした。おかしな形はふつうの 404 のまま- 作業環境では、直す前に外のサイトへ向いていた
/amp/evil.test/xが、直したあとは 404 になった - 同じファイルのショートコードの属性は、
esc_attr・sanitize_hex_color・wp_kses_postで整えてから出すようにした
参考
- wp_safe_redirect()(developer.wordpress.org)
- wp_redirect()(developer.wordpress.org)
- wp_validate_redirect()(developer.wordpress.org)
- allowed_redirect_hosts(developer.wordpress.org)
- redirect_canonical()(developer.wordpress.org)
- esc_attr()(developer.wordpress.org)
- sanitize_hex_color()(developer.wordpress.org)
- wp_kses_post()(developer.wordpress.org)
- Unvalidated Redirects and Forwards Cheat Sheet(OWASP)


