オープンリダイレクト

リダイレクト先のホストとスキームを検証し、意図しない外部サイトへの移動を防いでください。

説明

外部URLへのリダイレクトに未検証のユーザー入力を使うと、オープンリダイレクトが発生します。攻撃者がフィッシングに利用したり、ユーザーを悪意のあるサイトへ誘導したりするおそれがあります。

想定される影響

  • フィッシング: 攻撃者がユーザーを悪意のあるサイトに誘導し、機密情報を盗む可能性があります。
  • 信頼の低下: 悪意のあるサイトへのリダイレクトによって、アプリケーションへの信頼が損なわれる可能性があります。
  • ポリシーの回避: 攻撃者がリダイレクトを利用してセキュリティポリシーを回避するおそれがあります。

対処方法

  • リダイレクト先を許可リストで制限してください。
  • 入力値を検証し、信頼できる値だけをリダイレクトに使ってください。
  • 内部の移動だけが必要なら、サーバーが定義したパスや検証済みの内部パスを使ってください。//で始まる外部ホストへのパスを内部パスとして扱わないでください。

例

許可するホストは実際のサービスに合わせてください。DjangoのhomeとFlaskのindexは、アプリケーションに定義した内部ルートを前提としています。

Django

変更前

python
# Djangoの変更前の例
from django.http import HttpResponseRedirect

def unsafe_redirect(request):
    url = request.GET.get('next')
    return HttpResponseRedirect(url)

変更後

python
# Djangoの変更後の例
from django.http import HttpResponseRedirect
from django.shortcuts import redirect
from django.utils.http import url_has_allowed_host_and_scheme

def safe_redirect(request):
    url = request.GET.get('next', '')
    allowed_hosts = {'example.com', 'mysite.com'}
    if url_has_allowed_host_and_scheme(
        url,
        allowed_hosts=allowed_hosts,
        require_https=True,
    ):
        return HttpResponseRedirect(url)
    return redirect('home')

説明:

  • 変更前: 未検証の入力値をリダイレクトに使うため、オープンリダイレクトが可能です。
  • 変更後: DjangoのURL検証関数で、安全な相対内部パス、またはHTTPSを使う許可リスト上の正確なホスト名を確認します。https://example.com.evilのように、許可ホスト名で始まるだけのドメインは通過しません。

Flask

変更前

python
# Flaskの変更前の例
from flask import Flask, request, redirect

app = Flask(__name__)

@app.route('/redirect')
def unsafe_redirect():
    url = request.args.get('next')
    return redirect(url)

変更後

python
# Flaskの変更後の例
from flask import Flask, request, redirect, url_for
from urllib.parse import urlsplit

app = Flask(__name__)
ALLOWED_REDIRECT_HOSTS = frozenset({'example.com', 'mysite.com'})

def is_allowed_redirect(url):
    if not url:
        return False
    try:
        parsed = urlsplit(url)
        port = parsed.port
    except ValueError:
        return False

    return (
        parsed.scheme == 'https'
        and parsed.hostname in ALLOWED_REDIRECT_HOSTS
        and parsed.username is None
        and parsed.password is None
        and port in (None, 443)
    )

@app.route('/redirect')
def safe_redirect():
    url = request.args.get('next')
    if is_allowed_redirect(url):
        return redirect(url)
    return redirect(url_for('index'))

説明:

  • 変更前: 未検証の入力値をリダイレクトに使うため、オープンリダイレクトが可能です。
  • 変更後: URLを解析し、HTTPS、正確なホスト名、HTTPSの既定ポート、ユーザー情報がないことをすべて確認します。検証に失敗した場合は、サーバーが生成した内部URLへ移動します。

FastAPI

変更前

python
# FastAPIの変更前の例
from fastapi import FastAPI, Request
from starlette.responses import RedirectResponse

app = FastAPI()

@app.get("/redirect")
async def unsafe_redirect(request: Request):
    url = request.query_params.get('next')
    return RedirectResponse(url)

変更後

python
# FastAPIの変更後の例
from fastapi import FastAPI, Request, HTTPException
from starlette.responses import RedirectResponse
from urllib.parse import urlsplit

app = FastAPI()
ALLOWED_REDIRECT_HOSTS = frozenset({'example.com', 'mysite.com'})

def is_allowed_redirect(url):
    if not url:
        return False
    try:
        parsed = urlsplit(url)
        port = parsed.port
    except ValueError:
        return False

    return (
        parsed.scheme == 'https'
        and parsed.hostname in ALLOWED_REDIRECT_HOSTS
        and parsed.username is None
        and parsed.password is None
        and port in (None, 443)
    )

@app.get("/redirect")
async def safe_redirect(request: Request):
    url = request.query_params.get('next')
    if is_allowed_redirect(url):
        return RedirectResponse(url)
    raise HTTPException(status_code=400, detail="Invalid redirect URL")

説明:

  • 変更前: 未検証の入力値をリダイレクトに使うため、オープンリダイレクトが可能です。
  • 変更後: URLの構成要素を検証して、HTTPSと正確な許可ホストだけを受け付けます。認証情報や標準以外のポートを含むURLは拒否します。

参考資料