2026년 9월 14일

해당 글은 Claude를 기반으로 작성된 내용이 있습니다. 실제로 구조에 대한 검증은 마쳤으나 틀린 부분이 있다면 [email protected]으로 제보 부탁 드립니다.
이 글에서는 Open redirect에 대해서 간략하게 설명하고, 실제 코드 적용 사례와 함께 알아보는 과정을 가질 것이다.
Open Redirect는 요약하자면 URL에서 redirect 처리하는 쿼리 스트링을 제어할 때 서비스에서 공격자가 임의로 URL을 바꿔 사용자의 redirect 경로를 임의로 바꾸는 것을 뜻한다.
redirect를 바꾸는 것이 뭐가 문제냐 할 수 있겠지만, redirect URL을 지정하는 것으로 통해 JavaScript도 실행할 수 있어 계정의 인증 정보나 민감 정보가 유출될 경로를 만드는 경로가 되기도 한다. 그 예시로 Grafana의 CVE-2025-4123이 있으며, 이번 글에서 다루는 Open Redirect에서 파생된 취약점 임과 동시에 JavaScript를 임의로 실행해서 계정 탈취, SSRF(Server-Side Request Forgery, 서버 측 요청 위조)가 이루어졌던 대표적인 사례다. 만약 궁금하면 이 글을 보도록 하자.
이러한 유사 사례를 새로 만들지 않기 위해 개인 프로젝트를 진행할 때 Open redirect를 방지했으며, 이제부터 그 구체적인 사례를 서술하고자 한다.
우선, 악성 사용자가 URL을 변조하는 패턴에 대해서 한번 파악해보자면 아래와 같다.
여기서 사용자의 서비스는 example.com 그리고 악성 서비스의 URL을 iana.org로 가정한다.
https://example.com?redirect=http://www.iana.org
https://example.com?redirect=https://www.iana.org
https://example.com?redirect=https:\\www.iana.org
https://example.com?redirect=https:/\www.iana.org
https://example.com?redirect=https:\/www.iana.org
https://example.com?redirect=https:\@www.iana.org
아마 위 두가지 형식의 URL만 익숙하고 나머지는 뭔 형식인지, 이동되는게 맞는지 확신이 생기지 않을 것이다. 브라우저에서는 WHATWG URL의 표준을 따르는데, 여기서 http / https가 특수 scheme으로 간주돼서 정규화를 거치기에 여러가지 URL 형식이 나오기도 한다. 이제부터 우리는 이 URL을 백엔드에서 한번, 마지막으로 프론트엔드 SSR 환경에서 한번 더 막아서 Open Redirect를 원천 봉쇄하는 작업을 거칠 예정이다.
먼저 백엔드의 내용부터 먼저 확인해보자.
백엔드 환경의 경우, FastAPI를 기반으로 작업한 기준으로 서술될 것이며, 다른 백엔드 프레임워크를 사용하더라도 흐름 자체는 동일하다.
참고로 Python의 경우, URL(URI) 형식이 RFC 3986 형식을 따르므로 브라우저에서 사용하는 WHATWG URL와 달리 https:\/ https:/@ 등 특이한 scheme가 대응이 되지 않아 어느정도 방지를 해주는 느낌이 있지만, 그럼에도 일반 scheme에 대해선 유효한 요청으로 바라보기에 처리 후 redirect 응답 전송 전 URL검증 및 악성 URL 이동을 막아주는 작업을 추가해줘야 한다.
필자의 경우 util성 함수인 sanitize_redirect_url와 urlsplit 함수를 임의로 만들어 scheme, netloc, path, query 등을 분리하는 과정을 거친 뒤 URL을 검증하는 과정을 거쳤으며 규칙은 아래와 같이 지정했다.
도메인 정의는 이번에도 동일하게 사용자의 서비스는 example.com 그리고 악성 서비스의 URL을 iana.org로 가정한다.
startswith()나 정규식 기반의 접두사 매칭은 example.com.iana.org, [email protected]과 같은 변형에 우회될 수 있다. 따라서 scheme://netloc 전체를 추출한 뒤, 사전에 정의된 허용 목록과 문자열 완전일치(exact match) 로 비교한다.http와 https만 허용하고, javascript:, data:와 같은 scheme은 애초에 리다이렉트 대상에서 제외한다.http/https와 같은 special scheme에서의 백슬래시(\) 처리다. 브라우저는 백슬래시를 슬래시(/)와 동일하게 정규화해 https:\iana.org을 https://iana.org으로 해석할 수 있지만, 많은 서버 측 URL 파서(RFC 3986 기반)는 \를 일반 문자로 취급한다. 즉, 서버에서는 안전하다고 판단한 문자열을 브라우저가 다른 URL로 해석할 가능성이 있다. 이런 파서 구현 차이에 의존하기보다는, \처럼 해석 차이를 유발할 수 있는 문자를 검증 단계에서 아예 거부하는 편이 안전하다.이제 위 규칙을 기반으로 코드로 설명을 하겠다. 우선, OAuth를 기준으로 설명할거라 OAuth의 진입점부터 확인하겠다.
# auth.py
async def oauth_login(
provider: str,
request: Request,
redirect_url: str | None = Query(
None,
alias="redirect",
description="로그인 완료 후 돌려보낼 FE URL (허용 오리진만, 미지정·불일치 시 홈으로 폴백)",
),
prompt: str = "",
) -> RedirectResponse:
"""provider(google 등)의 OAuth 인증 페이지로 리다이렉트한다.
PKCE verifier(+ redirect URL)를 Redis에 저장하고 조회 키(state)를
httpOnly 쿠키로 내려준다.
"""
redirect_url = ac.sanitize_redirect_url(redirect_url, _CTX)
if provider not in settings.OAUTH_PROVIDERS:
return ac.login_start_failure(
f"지원하지 않는 프로바이더: {provider}", redirect_url, _CTX
)
verifier, challenge = auth_service.generate_pkce()
state = secrets.token_urlsafe(32)
try:
await get_redis().set(
_CTX.pkce_key.format(state=state),
json.dumps({"verifier": verifier, "redirect": redirect_url}),
ex=ac.PKCE_TTL_SECONDS,
)
except RedisError:
return ac.login_start_failure(
"Redis를 사용할 수 없어 OAuth 로그인을 시작할 수 없습니다", redirect_url, _CTX
)
authorize_url = auth_service.build_authorize_url(
provider, str(request.url_for("oauth_callback")), challenge, prompt
)
response = RedirectResponse(authorize_url)
ac.set_state_cookie(response, state)
return response
해당 내용이 코드 원문인데, redirect_url 를 위주로 참고하면 된다.
redirect_url을 선언하는 과정에서 sanitize_redirect_url를 호출하게 되는데, 여기서 실제로 안전한 URL인지, 악성 URL인지 판별 하는 과정을 거친 뒤 악성 URL이라면 안전한 URL로 변환하는 과정을 거친다.
배경
로그인 완료 후 사용자를 원래 페이지로 돌려보내기 위해 ?redirect= 파라미터를 받는데, 검증 없이 그대로 Location에 쓰면 피싱에 악용될 수 있다(open redirect). sanitize_redirect_url은 이 값을 허용 오리진 화이트리스트로 검증하고, 실패 시 항상 안전한 홈 URL로 조용히 대체한다.
필터링 5단계
| 단계 | 코드 | 통과 조건 | 실패 시 |
| 1. 존재 여부 | `if redirect_url` | `None`/ 빈 문자열이 아님 | 홈으로 강제이동 |
| 2. 백슬래시 차단 | `"\\" not in redirect_url` | `\` 미포함 | 홈으로 강제이동 |
| 3. 파싱 | `urlsplit(redirect_url)` | — (URL 데이터 형식 분리) | — |
| 4. scheme 화이트리스트 | `parts.scheme in ("http", "https")` | http/https만 | 홈으로 강제이동 |
| 5. 오리진 완전일치 | `origin in _allowed_redirect_origins(ctx)` | 설정된 origin과 문자열 일치 | 홈으로 강제이동 |
호출 그래프
oauth_login()
└─ sanitize_redirect_url(redirect_url, ctx) ← 필터링 실행
├─ _allowed_redirect_origins(ctx) ← 화이트리스트 계산
└─ redirect_home(ctx) ← 실패 시 폴백값 생성
└─ (검증된 값) → Redis 저장
└─ 실패 시 login_start_failure() → _redirect_with_error()
oauth_callback()
└─ Redis에서 검증된 값 조회 (재검증 없음)
└─ 실패 시 callback_failure() → redirect_home() → _redirect_with_error()
콜백 단계는 sanitize_redirect_url을 다시 부르지 않는다 — 검증은 요청 접수 시점(oauth_login) 한 번뿐이고, Redis에 저장된 값은 클라이언트가 재전송한 게 아니라 서버 상태이므로 재검증이 필요 없다. 어드민 플로우도 같은 함수를 재사용하되 AuthContext.redirect_urls만 다른 값(ADMIN_ALLOWED_REDIRECT_URLS)으로 주입해 화이트리스트를 분리한다.
def sanitize_redirect_url(redirect_url: str | None, ctx: AuthContext) -> str:
"""FE가 넘긴 redirect URL을 검증한다 — 허용 오리진의 http(s) URL만 통과시키고,
미지정·불일치·비표준 scheme URL은 에러 대신 홈으로 폴백한다 (open redirect 방지).
브라우저(WHATWG URL 파서)는 http/https 같은 "special scheme"에서 백슬래시(\\)를
슬래시(/)와 동일하게 취급해 "https:\\evil.com", "https:/\\evil.com" 같은 입력도
"<https://evil.com>"으로 정규화해 이동한다. 반면 RFC 3986 기반인 urlsplit은
백슬래시를 일반 문자로 남겨두므로, 서버와 브라우저가 같은 문자열을 서로 다른
호스트로 해석할 여지(parser differential)가 생긴다. 지금은 우연히 이런 값이
urlsplit에서도 netloc이 비어 화이트리스트에 걸리지 않지만, 파서 구현에 기대지
않고 백슬래시가 섞인 값은 애초에 통째로 거부한다.
"""
if redirect_url and "\\" not in redirect_url:
parts = urlsplit(redirect_url)
origin = f"{parts.scheme}://{parts.netloc}"
if parts.scheme in ("http", "https") and origin in _allowed_redirect_origins(
ctx
):
return redirect_url
return redirect_home(ctx)
def redirect_home(ctx: AuthContext) -> str:
"""로그인 후 돌아갈 기본 FE 홈 — 허용 목록의 첫 URL."""
urls = ctx.redirect_urls
return urls[0] if urls else "<http://localhost:3000>"
def _allowed_redirect_origins(ctx: AuthContext) -> set[str]:
"""콜백 후 리다이렉트를 허용할 오리진 — ctx.redirect_urls 기준."""
origins = set()
for url in ctx.redirect_urls:
parts = urlsplit(url)
origins.add(f"{parts.scheme}://{parts.netloc}")
return origins
참조 흐름 요약
sanitize_redirect_url()
└─ urlsplit(redirect_url)
├─ scheme_chars ← scheme 파싱 시 문자 검증
├─ _UNSAFE_URL_BYTES_TO_REMOVE / _WHATWG_C0_CONTROL_OR_SPACE ← 전처리
├─ _splitnetloc() ← netloc 분리 (구분자: / ? #)
└─ _checknetloc() ← NFKC 동형이의 문자 검사 (ValueError 가능)
urllib/parse.py)scheme_chars = ('abcdefghijklmnopqrstuvwxyz'
'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
'0123456789'
'+-.')
@functools.lru_cache(typed=True)
def urlsplit(url, scheme='', allow_fragments=True):
url, scheme, _coerce_result = _coerce_args(url, scheme)
# Only lstrip url as some applications rely on preserving trailing space.
url = url.lstrip(_WHATWG_C0_CONTROL_OR_SPACE)
scheme = scheme.strip(_WHATWG_C0_CONTROL_OR_SPACE)
for b in _UNSAFE_URL_BYTES_TO_REMOVE:
url = url.replace(b, "")
scheme = scheme.replace(b, "")
allow_fragments = bool(allow_fragments)
netloc = query = fragment = ''
i = url.find(':')
if i > 0 and url[0].isascii() and url[0].isalpha():
for c in url[:i]:
if c not in scheme_chars:
break
else:
scheme, url = url[:i].lower(), url[i+1:]
if url[:2] == '//':
netloc, url = _splitnetloc(url, 2)
if (('[' in netloc and ']' not in netloc)
( netloc netloc)):
ValueError()
netloc netloc:
_check_bracketed_netloc(netloc)
allow_fragments url:
url, fragment = url.split(, )
url:
url, query = url.split(, )
_checknetloc(netloc)
v = SplitResult(scheme, netloc, url, query, fragment)
_coerce_result(v)
():
delim = (url)
c :
wdelim = url.find(c, start)
wdelim >= :
delim = (delim, wdelim)
url[start:delim], url[delim:]
():
netloc netloc.isascii():
unicodedata
n = netloc.replace(, ).replace(, ).replace(, ).replace(, )
netloc2 = unicodedata.normalize(, n)
n == netloc2:
c :
c netloc2:
ValueError( + netloc + +
)
버전 참고: _WHATWG_C0_CONTROL_OR_SPACE로 앞쪽 공백/제어문자를 lstrip하는 동작은 Python 3.12부터다(WHATWG 스펙 정렬 + 과거 "선행 공백으로 blocklist 우회" 부류 이슈에 대한 보안 패치).
그래서 전체적으로 요약하자면, 아래와 같은 형식이 된다.

이제 Open redirect를 막는 첫번째 단계인 백엔드에서 처리하는 방법에 대해 정리를 마무리 하겠다. 원래는 Front-end(Next.js/SSR)에서 2차로 Open redirect를 방지하는 내용도 기록하고 싶었으나, 분량 조절 실패로 인해 부득이하게 다음편에 기록하려고 한다.
참고로 이 글은 기존에 OAuth를 제공하는 서비스 중 redirect URL을 쿼리스트링에 적용하는데 악성사용자가 이를 악용하지 않을지에 걱정이 있었고, 이 궁금증을 해소하고자 이 글을 작성하게 되었다. 해당 내용을 찾으면서 여러 기업의 Open redirect의 대응 방법에 대해 알게 되었고 특히 Google의 경우 Open redirect에 대해 관대한 인식을 가진것이 인상적이라고 생각하고 있다. 대표적인 사례로 Google의 경우, Open redirect에 대한 버그 바운티를 받지 않는다는 점에서 제일 인상 깊었다고 생각한다.
다만, 개인적으로는 조심해서는 나쁠게 없다고 판단하여 다음 서비스를 개발할 때 redirect를 처리한다면 Open redirect는 막을 생각이다.