PHPのparse_urlはスキームなしのURLをhostにせずpathに入れる。リダイレクト先の検証で見落とす落とし穴

parse_url()は、URL文字列をscheme・host・port・path・queryなどの要素に分解して連想配列で返す関数です。ただしURLの妥当性を検証する関数ではなく、スキームなしの文字列は「hostではなくpath」として分解されるため、そのまま信用すると判定が抜けます。

ログイン後の戻り先URLを受け取って、自サイトのホストかどうかだけ確認する。よくある処理ですし、parse_url($url, PHP_URL_HOST)を一行書けば済むように見えます。私もそう書いて、スキームなしの文字列が来たときに何が返るのかを手元で確かめたことがありました。PHP 8.3で動かした結果をまとめます。

スキームがないURLはhostではなくpathに入る

「example.com/path?x=1」のようにスキームを省いた文字列を渡すと、example.com/pathの全体がpathとして返ります。hostは存在しません。人間の目にはドメインに見えても、パーサーはスキームか先頭のスラッシュ2本がない限り「これはパスだ」と解釈する、ということですね。

<?php
var_dump(parse_url('example.com/path?x=1'));
// array(2) { ["path"]=> "example.com/path", ["query"]=> "x=1" }

var_dump(parse_url('//example.com/path'));
// array(2) { ["host"]=> "example.com", ["path"]=> "/path" }

二つ目の「//」始まりはスキーム相対URLと呼ばれる形で、こちらはhostが取れます。ブラウザは現在のページのスキームを補ってそのまま外部サイトへ遷移するので、リダイレクト先の検証ではこの形を見逃せません。

要素がないときはnull、壊れているときはfalseになる?

戻り値の約束を整理しておきます。第2引数なしで呼ぶと、分解できれば配列、URLが「ひどく壊れている」と判断されたときはfalseが返ります。配列の場合、存在しない要素はキー自体が入りません。第2引数にPHP_URL_HOSTなどを渡すと、その要素の文字列か整数(portのみ)が返り、要素がなければnullです。

つまり、第2引数つきの呼び出しでは「要素がない」と「URLが壊れている」がnullとfalseで分かれます。ここを==やif (!$host)でまとめてしまうと区別が消えるので、判定は厳密比較で書くのが安全です。

<?php
var_dump(parse_url('https://example.com', PHP_URL_PORT));  // NULL(portなし)
var_dump(parse_url('https:///example.com'));                // bool(false)(スラッシュが3本)
var_dump(parse_url('http://example.com:99999/'));           // bool(false)(ポート番号が範囲外)

ポート番号が範囲外のときもfalseになる点は、意外と知られていない気がします。

「ホスト名:ポート」だけの文字列はなぜhostとして取れるのか

スキームなしでも、「example.com:8080/path」や「localhost:8080」は、hostとportが取れます。「:」のあとが数字だけなら、スキームではなくポート番号と見なされるためです。さきほどの「example.com/path」はpathになるのに、ポートを付けたらhostになる。この不揃いが、検証コードを書くときにいちばん混乱するところだと思います。

<?php
var_dump(parse_url('example.com:8080/path'));
// host => "example.com", port => 8080, path => "/path"

var_dump(parse_url('mailto:foo@example.com'));
// scheme => "mailto", path => "foo@example.com"

入力の形によって返り方が変わるので、「hostが取れたからドメイン文字列だ」と決めつけず、schemeも必ず見ます。

リダイレクト先の検証はどう書くのか

手順は単純で、falseを弾き、schemeとhostの両方が揃っていることを確認し、schemeをhttp/httpsに限定してから、hostを許可リストと完全一致で照合します。前方一致や部分一致にすると「www.buglabo.com.evil.test」のようなドメインが通ってしまうので、in_arrayの厳密比較で十分です。

<?php
declare(strict_types=1);

function safeRedirectHost(string $url, array $allowedHosts): ?string
{
    $parts = parse_url($url);
    if ($parts === false) {
        return null;
    }
    if (!isset($parts['scheme'], $parts['host'])) {
        return null;
    }
    if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
        return null;
    }
    $host = strtolower($parts['host']);
    return in_array($host, $allowedHosts, true) ? $host : null;
}

$allowed = ['www.buglabo.com'];
var_dump(safeRedirectHost('https://www.buglabo.com/a', $allowed));            // string(15) "www.buglabo.com"
var_dump(safeRedirectHost('//evil.test/a', $allowed));                        // NULL
var_dump(safeRedirectHost('www.buglabo.com/a', $allowed));                    // NULL(スキームなし)
var_dump(safeRedirectHost('https://www.buglabo.com.evil.test/a', $allowed));  // NULL
var_dump(safeRedirectHost('https://WWW.BUGLABO.COM/a', $allowed));            // string(15) "www.buglabo.com"
var_dump(safeRedirectHost('javascript://www.buglabo.com/%0Aalert(1)', $allowed)); // NULL(スキームがjavascript)
var_dump(safeRedirectHost('https://www.buglabo.com@evil.test/', $allowed));   // NULL(実際のhostはevil.test)

最後の「@」を使った例は、見た目だけ自サイトで、実際のhostは@の右側になるパターンです。parse_urlは@より前をuser情報として切り出すので、hostがevil.testになって弾かれます。許可リストに載せる値をスキームとhostの組で見ているぶん、見た目に引っ張られにくくなるのが嬉しいところです。

スキームなしの入力を救いたいときはどうするか

ユーザーが「example.com/path」と入力するフォームのように、スキームなしを許したい場面もあります。その場合は、parse_urlに渡す前に自分でスキームを補います。正規表現で「英字から始まるスキーム+://」があるかを見て、なければhttpsを前置する程度で足ります。

<?php
function withScheme(string $url): string
{
    return preg_match('#^[a-z][a-z0-9+.-]*://#i', $url)
        ? $url
        : 'https://' . ltrim($url, '/');
}

echo withScheme('example.com/path?x=1');                        // https://example.com/path?x=1
var_dump(parse_url(withScheme('example.com/path?x=1'), PHP_URL_HOST)); // string(11) "example.com"

ただしこれは「入力を整える」処理であって、安全性の検証ではありません。リダイレクト先のように外部入力を信用できない場面では、先ほどの許可リスト方式と組み合わせるのが筋だと思います。

まとめ

parse_url()は便利ですが、URLの妥当性を保証してくれる関数ではありません。スキームなしはpathに入り、「//」始まりはhostが取れ、壊れた入力はfalse、要素がなければnull、と返り方に段差があります。判定は厳密比較で書いて、schemeとhostの両方を見て、許可リストと完全一致で照合する。ここまで書いておけば、だいぶ安心して使えるようになる気がしています。

よくある質問

Q. parse_urlでURLが正しいかどうか検証できますか?
A. できません。PHPの公式ドキュメントでも、URLの妥当性を検証する用途には向かないとされています。形式の検証が必要ならfilter_varのFILTER_VALIDATE_URLなどを併用し、リダイレクト先のような用途では許可リストで照合してください。

Q. parse_urlの第2引数を指定したとき、nullとfalseはどう違いますか?
A. nullは「その要素がURLに含まれていない」、falseは「URL自体が壊れていて分解できなかった」を表します。判定には===を使うと区別がつきます。

Q. 「//example.com/path」のようなスキーム相対URLは、どう扱えばよいですか?
A. parse_urlはhostを返しますがschemeは返しません。リダイレクト先の検証では、schemeがないものを不許可にすれば、外部サイトへの意図しない遷移を防げます。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です