불완전한 URL 입력값 검증

Incomplete URL sanitization

설명

리다이렉트 목적지로 사용하는 사용자 입력 URL을 충분히 검증하지 않으면, 공격자가 사용자를 의도하지 않은 사이트로 보낼 수 있습니다. 도메인 접두사 비교나 특수 문자 인코딩만으로 허용된 목적지를 확인할 수는 없습니다.

잠재적 영향

  • 정보 노출: 피싱 사이트로 유도된 사용자가 자격 증명이나 민감한 정보를 제공할 수 있습니다.
  • 추가 공격: URL이 HTML에도 삽입되는 경우에는 별도의 문맥별 인코딩과 스킴 검증이 필요합니다. 리다이렉트 자체가 곧 XSS를 뜻하지는 않습니다.
  • 리다이렉션 공격: 사용자를 악성 웹사이트로 리다이렉트할 수 있습니다.

해결 방법

  • URL을 파싱하거나 프레임워크의 검증 함수를 사용하여 허용된 스킴, 정확한 호스트명과 경로 정책을 확인하세요.
  • 출력 위치에 맞는 인코딩은 별도로 적용하세요. 인코딩만으로 외부 목적지를 허용해도 되는지 결정하지 마세요.
  • 허용 목록에 속한 목적지만 사용하고, 검증에 실패하면 리다이렉트를 거부하세요.

예시

Django

변경 전

python
# Unsafe Django code
from django.shortcuts import redirect

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

변경 후

python
# Safe Django code
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")

설명:

  • 변경 전: 사용자 입력값을 검증하지 않고 그대로 URL 리다이렉트에 사용하여, 공격자가 악의적인 URL을 통해 사용자를 피싱 사이트로 리다이렉트할 수 있습니다.
  • 변경 후: Django의 URL 검증 함수를 사용해 안전한 상대 내부 경로나 HTTPS를 사용하는 허용 목록의 정확한 호스트명만 통과시킵니다. 단순 접두사 비교와 달리 example.com.evil 같은 유사 도메인은 허용되지 않습니다.

Flask

변경 전

python
# Unsafe Flask code
from flask import Flask, request, redirect

app = Flask(__name__)

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

변경 후

python
# Safe Flask code
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 리다이렉트에 사용하여, 공격자가 악의적인 URL을 통해 사용자를 피싱 사이트로 리다이렉트할 수 있습니다.
  • 변경 후: URL을 구조적으로 파싱한 뒤 HTTPS, 정확한 호스트명, 기본 HTTPS 포트, 사용자 정보 부재를 모두 확인합니다. 검증에 실패하면 리다이렉트하지 않습니다.

FastAPI

변경 전

python
# Unsafe FastAPI code
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
# Safe FastAPI code
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 리다이렉트에 사용하여, 공격자가 악의적인 URL을 통해 사용자를 피싱 사이트로 리다이렉트할 수 있습니다.
  • 변경 후: URL 구성 요소를 파싱해 HTTPS와 허용된 정확한 호스트만 인정하고, 자격 증명이나 비표준 포트가 포함된 URL은 거부합니다.

참조