오픈 리다이렉트

리다이렉트 대상의 호스트와 스킴을 검증해 의도하지 않은 외부 이동을 막으세요.

설명

오픈 리다이렉트(Open Redirect)는 애플리케이션이 외부 URL로의 리다이렉션에 검증되지 않은 사용자 입력을 사용할 때 발생하는 취약성입니다. 이는 공격자가 피싱 공격을 수행하거나 악성 사이트로 사용자를 유도하는 데 악용될 수 있습니다.

잠재적 영향

  • 피싱 공격: 공격자가 사용자를 속여 악성 사이트로 유도하여 민감한 정보를 탈취할 수 있습니다.
  • 신뢰도 손상: 사용자가 악성 사이트로 리다이렉트되어 애플리케이션에 대한 신뢰도가 떨어질 수 있습니다.
  • 보안 정책 우회: 공격자가 리다이렉트를 통해 보안 정책을 우회할 수 있습니다.

해결 방법

  • 허용 목록 사용: 리다이렉트할 URL을 허용 목록으로 제한합니다.
  • 사용자 입력 검증: 사용자 입력값을 철저히 검증하고, 신뢰할 수 있는 값만 리다이렉트에 사용합니다.
  • 내부 이동만 필요하면 서버가 정한 경로나 검증된 내부 경로를 사용하세요. //로 시작하는 외부 호스트 경로를 내부 경로로 취급하지 마세요.

예시

허용 호스트는 실제 서비스에 맞게 정하세요. Django의 home과 Flask의 index는 애플리케이션에 정의된 내부 경로를 가정합니다.

Django

변경 전

python
# Unsafe Django code
from django.http import HttpResponseRedirect

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

변경 후

python
# Safe Django code
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
# Unsafe Flask code
from flask import Flask, request, redirect

app = Flask(__name__)

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

변경 후

python
# Safe Flask code
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
# Unsafe FastAPI code
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
# 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("/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은 거부합니다.

참조