不十分なURL入力の検証

不十分なURLの検証

説明

ユーザーが指定したリダイレクト先のURLを十分に検証しないと、攻撃者がユーザーを意図しないサイトへ誘導できます。ドメインの前方一致や特殊文字のエンコードだけでは、許可された宛先かどうかを判断できません。

想定される影響

  • 情報漏えい: フィッシングサイトに誘導されたユーザーが、認証情報や機密情報を渡すおそれがあります。
  • 追加の攻撃: URLをHTMLにも挿入する場合は、文脈に応じたエンコードとスキーム検証が別途必要です。リダイレクト自体がXSSを意味するわけではありません。
  • 不正なリダイレクト: 攻撃者が管理するサイトへユーザーを誘導する可能性があります。

対処方法

  • URLを解析するか、フレームワークの検証関数を使い、許可するスキーム、正確なホスト名、パスの方針を確認してください。
  • 出力先の文脈に応じたエンコードは別途適用してください。エンコードだけで外部の宛先を許可しないでください。
  • 許可リストに含まれる宛先だけを使い、検証に失敗した場合はリダイレクトを拒否してください。

例

Django

変更前

python
# URL検証が不適切なDjangoコード
from django.shortcuts import redirect

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

変更後

python
# URL検証を改善したDjangoコード
from django.http import HttpResponseBadRequest
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 redirect(url)
    return HttpResponseBadRequest("Invalid URL")

説明:

  • 変更前: 入力を検証せずリダイレクト先に使うため、攻撃者がフィッシングサイトへ誘導できます。
  • 変更後: Djangoの検証関数によって、適切な相対内部パス、またはHTTPSを使う許可済みの正確なホストだけを許可します。前方一致とは異なり、example.com.evil のような類似ドメインは拒否されます。

Flask

変更前

python
# 検証が不適切なFlaskコード
from flask import Flask, request, redirect

app = Flask(__name__)

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

変更後

python
# 検証を改善したFlaskコード
from flask import Flask, request, redirect, abort
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('/safe')
def safe():
    url = request.args.get('next')
    if is_allowed_redirect(url):
        return redirect(url)
    return abort(400, description="Invalid URL")

説明:

  • 変更前: ユーザー入力をそのままリダイレクト先に使っています。
  • 変更後: URLを解析し、HTTPS、正確なホスト名、既定のHTTPSポート、ユーザー情報がないことを確認します。検証に失敗した宛先にはリダイレクトしません。

FastAPI

変更前

python
# 検証が不適切なFastAPIコード
from fastapi import FastAPI, Request
from starlette.responses import RedirectResponse

app = FastAPI()

@app.get("/unsafe")
async def unsafe(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("/safe")
async def safe(request: Request):
    url = request.query_params.get('next')
    if is_allowed_redirect(url):
        return RedirectResponse(url)
    raise HTTPException(status_code=400, detail="Invalid URL")

説明:

  • 変更前: 未検証の入力によって、フィッシングサイトへユーザーを誘導できます。
  • 変更後: URLの構成要素を解析してHTTPSと許可済みの正確なホスト名を確認し、認証情報や標準外のポートを含むURLを拒否します。

参考資料