0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

オープンリダイレクト対策──?url=の外部誘導を防ぐ検証(PHP/nginx/WordPress対応)

0
Posted at

ログイン後の「元のページに戻す」処理や、外部サービス連携のコールバックで、?url= や ?redirect_to= のようなパラメータを受け取ってそのまま転送していませんか。この遷移先を検証していないと、オープンリダイレクトという穴になります。

この記事は Web 担当者・サイト運営者・制作会社 向けに、オープンリダイレクトが何を招くのかと、遷移先 URL を安全に検証する設定・コードを PHP / WordPress / nginx / Apache のコピペ例でまとめたものです。攻撃手順そのものは扱わず、「同じ被害を防ぐには」の観点で書きます。

オープンリダイレクトとは何が問題か

オープンリダイレクトは、利用者から受け取った値をそのままリダイレクト先(HTTP の Location ヘッダや <meta refresh>)に使ってしまう状態を指します。

https://example.com/login?redirect_to=https://evil.example/login-look-alike
                          ~~~~~~~~~~~~ この値を検証せず転送すると…

利用者から見える URL のドメインは信頼している example.com なので、リンクを踏むこと自体に警戒しません。ところが最終的な着地点は攻撃者のサイトです。これが以下のような被害の土台になります(いずれも概念のみ・手順は書きません)。

  • フィッシング: 信頼済みドメインのリンクから偽ログイン画面へ着地させ、認証情報を入力させる。
  • OAuth / SSO のトークン漏えい: 認可コードやトークンを含んだコールバックが、検証されていない redirect_uri 経由で外部へ渡る。
  • WAF・フィルタの回避: 「自社ドメインだから安全」という前提のメールフィルタや WAF を、自社ドメイン発のリンクとしてすり抜ける踏み台にされる。

CVSS 上は「中」程度に評価されがちですが、フィッシングと組み合わさると実被害が大きく、外部の脆弱性診断でもよく指摘される項目です。

対策の原則:外部由来の URL へは飛ばさない

最も安全なのは、利用者から受け取った文字列を遷移先 URL として一切使わないことです。遷移先が有限個なら、パラメータは「キー」に限定し、実際の URL はサーバ側の固定テーブルで解決します。

<?php
// 遷移先キー => 実URL の固定マッピング。利用者はキーしか渡せない
$ROUTES = [
    'dashboard' => '/mypage',
    'settings'  => '/mypage/settings',
    'top'       => '/',
];

$key = $_GET['to'] ?? 'top';
$path = $ROUTES[$key] ?? '/';   // 未知のキーは安全な既定値へ

header('Location: ' . $path, true, 302);
exit;

この形なら、利用者が何を渡してもマッピング外は既定値に落ちるため、外部サイトへ飛ぶ経路が原理的に存在しません。まずこの方式で組めないかを検討してください。

どうしても動的な遷移先が必要なとき

「戻り先が動的に決まる」ケースでは、次の 3 点をすべて満たすものだけを許可します。安全側に倒し、少しでも怪しければ既定ページへフォールバックします。

  1. 相対パスのみ許可する(先頭が / で始まり、かつ // で始まらない)。//evil.example や https://… を弾く。
  2. バックスラッシュや制御文字を含まない(/\evil.example のようなブラウザ差を突く表記を弾く)。
  3. 絶対 URL を許すのは自ホストの許可リストと完全一致のときだけ。ホスト名の部分一致(example.com.evil.jp のような紛れ)を許さない。

PHP での検証関数の例です。

<?php
/**
 * 遷移先として安全な相対パスだけを返す。危険なら null。
 */
function safe_redirect_path(string $input): ?string
{
    // 制御文字・空白を除去(改行によるヘッダ分割対策も兼ねる)
    $input = preg_replace('/[\x00-\x1F\x7F\s]/', '', $input);

    // スキーム付き(http:, javascript:, data: 等)は一律拒否
    if (preg_match('#^[a-z][a-z0-9+.\-]*:#i', $input)) {
        return null;
    }
    // 相対パスの体裁(先頭 / かつ // でない、\ を含まない)だけ許可
    if (preg_match('#^/(?!/)[^\\\\]*$#', $input)) {
        return $input;
    }
    return null;
}

$dest = safe_redirect_path($_GET['redirect_to'] ?? '') ?? '/mypage';
header('Location: ' . $dest, true, 302);
exit;

絶対 URL(サブドメイン間で戻す等)を許可したい場合は、ホストを完全一致の許可リストで照合します。

<?php
function safe_redirect_url(string $input): ?string
{
    $allow_hosts = ['www.example.com', 'app.example.com'];

    $p = parse_url($input);
    if ($p === false || empty($p['scheme']) || empty($p['host'])) {
        return null;
    }
    if ($p['scheme'] !== 'https') {           // https 以外は拒否
        return null;
    }
    if (!in_array($p['host'], $allow_hosts, true)) {  // 完全一致のみ
        return null;
    }
    return $input;
}

str_starts_with($host, 'example.com') や strpos($input, 'example.com') !== false のような部分一致での判定は使わないでください。example.com.evil.jp や evil.jp/?x=example.com を通してしまいます。判定は必ず「パースしたホスト名の完全一致」で行います。

WordPress は専用関数を使う

WordPress には遷移先を検証する関数が用意されています。wp_redirect() を直接使わず、wp_safe_redirect() を使うのが基本です。これは許可ホスト以外への外部リダイレクトを既定でブロックします。

<?php
// NG: 検証なしで外部へ飛べてしまう
// wp_redirect( $_GET['redirect_to'] );

// OK: 許可ホスト外なら第2引数のフォールバック先へ落ちる
$requested = isset($_GET['redirect_to']) ? wp_unslash($_GET['redirect_to']) : '';
wp_safe_redirect( wp_validate_redirect( $requested, home_url('/mypage') ) );
exit;

自ドメインのサブドメイン等へ正当に飛ばしたい場合だけ、allowed_redirect_hosts フィルタで許可ホストを明示的に足します(既定ではサイト自身のホストのみ許可)。

<?php
add_filter('allowed_redirect_hosts', function (array $hosts): array {
    $hosts[] = 'app.example.com';   // 必要なホストだけを完全一致で追加
    return $hosts;
});

プラグインやテーマのコードレビューでは、wp_redirect( を検索して外部由来の値が渡っていないかを確認し、該当箇所を wp_safe_redirect() + wp_validate_redirect() に置き換えるのが定石です。

Web サーバ層での注意点

nginx や Apache でリダイレクトを書く場合、クエリ文字列やリクエスト由来の変数を遷移先へそのまま埋め込まないことが重要です。

nginx で $arg_url(= ?url= の値)などを return/rewrite の宛先に直接差し込むと、そこがオープンリダイレクトになります。宛先は固定の内部パスにします。

# NG: 外部由来の値を宛先にそのまま使う
# if ($arg_url) { return 302 $arg_url; }

# OK: 宛先は固定パス。動的な戻り先はアプリ側で許可リスト検証する
location = /go {
    return 302 /mypage;
}

Apache の mod_rewrite でも同様に、%{QUERY_STRING} を宛先 URL に直挿ししないでください。動的な戻り先の判断はサーバ設定ではなくアプリケーション層(上記の検証関数)に寄せるのが安全です。

なお、ヘッダに改行が混入するとレスポンス分割につながるため、Location へ渡す前に制御文字を除去しておくこと(上のコードの preg_replace が兼ねています)も合わせて実施します。

直ったかを curl で確認する

修正後は、外部 URL や // 始まりの値を渡して**自サイト内に落ちる(既定ページへ 302 される)**ことを確認します。-s -o /dev/null でボディを捨て、Location だけ見ます。

# 外部URLを渡しても Location が自サイト内(/mypage など)になればOK
curl -s -o /dev/null -D - "https://www.example.com/login?redirect_to=https://evil.example/" | grep -i '^location:'

# スキーム偽装(//始まり)も自サイト内へ落ちることを確認
curl -s -o /dev/null -D - "https://www.example.com/login?redirect_to=//evil.example/" | grep -i '^location:'

location: https://evil.example/ のように外部ドメインがそのまま返っていれば、まだ穴が残っています。許可リストとフォールバックを見直してください。

まとめ

  • オープンリダイレクトは「利用者から受け取った URL を検証せず転送する」状態。信頼済みドメインを踏み台にしたフィッシングの入り口になる。
  • 原則は「外部由来の URL へは飛ばさない」。遷移先はキー→URL の固定マッピングで解決する。
  • 動的にする場合は「相対パスのみ・// や \ を弾く・絶対 URL は自ホスト許可リストと完全一致」を全部満たすものだけ許可。部分一致判定は使わない。
  • WordPress は wp_safe_redirect() + wp_validate_redirect()。外部ホストは allowed_redirect_hosts で明示追加。
  • サーバ設定にリクエスト由来の値を直挿ししない。最後は curl の Location で自サイト内に落ちるか確認する。

関連記事

本記事のような入力値・遷移先の検証漏れは、Web サイトを 9 つの守りでまるごと守るサイトドックでまとめて対策できます → https://sitedock.jp

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?