오픈 리다이렉트 취약점: OAuth 토큰 탈취 경로와 검증법

오픈 리다이렉트 취약점이 피싱을 넘어 OAuth 인가 코드·토큰 탈취로 이어지는 원리, startsWith·정규식 검증이 우회되는 패턴, returnUrl을 허용 목록으로 검증하는 코드와 점검 체크리스트를 정리했습니다.

보안 점검 의뢰 팀 15분 분량

오픈 리다이렉트 취약점은 returnUrl, redirect, next처럼 이동할 주소를 받는 파라미터를 서버가 검증 없이 Location 헤더에 넣을 때 생깁니다. 공격자는 https://app.example.com/login?returnUrl=https://evil.example.net 같은 링크를 만들 수 있습니다. 링크는 우리 도메인으로 시작하니 사용자는 믿고 누르지만, 로그인을 마치면 공격자 사이트에 도착합니다. MITRE는 이 약점을 CWE-601(URL Redirection to Untrusted Site)로 분류합니다.

점검 리포트에서 이 항목이 "중" 또는 "낮음"으로 나오면 피싱 경유지 정도로 보고 넘기기 쉽습니다. 그런데 이 리다이렉트가 OAuth 콜백 도메인 안에 있고, 인가 서버의 redirect_uri 검증까지 느슨하다면 이야기가 달라집니다. 인가 코드나 액세스 토큰이 공격자에게 넘어가는 경로가 생기기 때문입니다. 고치는 방법은 간단합니다. 문자열 비교 대신 URL을 파싱한 뒤 허용 목록과 비교하고, 가능하면 외부 URL은 아예 받지 않으면 됩니다.

이 글에서는 위험이 커지는 조건, 흔히 쓰는 검증 코드가 우회되는 패턴, 바로 적용할 수 있는 검증 코드를 차례로 다룹니다.

오픈 리다이렉트는 어디서 생기나

실무에서 가장 자주 보이는 위치는 다음 네 곳입니다.

  • 로그인 후 원래 페이지로 돌려보내기: /login?returnUrl=/orders/123
  • 로그아웃 후 이동: /logout?next=...
  • 외부 링크 추적·광고 클릭 집계: /go?url=..., /r?to=...
  • OAuth·SSO 콜백 이후 최종 이동: /auth/callback?state=... 처리 뒤 세션에 저장해 둔 returnTo로 이동

취약한 코드는 대체로 이렇게 생겼습니다.

// Express - 취약한 예
app.post('/login', async (req, res) => {
  const user = await authenticate(req.body);
  req.session.userId = user.id;
  res.redirect(req.query.returnUrl || '/'); // 어떤 주소든 그대로 이동
});

res.redirect, Spring의 "redirect:" + url, Django의 HttpResponseRedirect(url)은 모두 받은 값을 그대로 Location 헤더에 씁니다. 프레임워크가 막아 주리라 기대하면 안 됩니다. 프런트엔드에서 window.location.href = params.get('next')로 이동하는 코드도 같은 문제를 안고 있습니다. 여기에 javascript: 스킴까지 통과시키면 XSS로 번질 수 있습니다.

피싱 경유지를 넘어서: OAuth와 결합될 때

OWASP의 Unvalidated Redirects and Forwards Cheat Sheet는 오픈 리다이렉트의 기본 위험을 이렇게 설명합니다. 신뢰받는 도메인을 거치기 때문에 피싱 링크의 신뢰도가 높아진다는 것입니다. 실무에서 더 신경 써야 할 부분은 OAuth 흐름과 엮일 때입니다.

OAuth 인가 서버는 인증을 마친 사용자를 redirect_uri로 돌려보내면서 인가 코드(또는 implicit 흐름이라면 토큰)를 붙입니다. 이때 인가 서버가 redirect_uri를 등록값과 정확히 비교하지 않고 "https://app.example.com/으로 시작하면 허용"처럼 접두사로만 검사한다고 해 봅시다. 그리고 같은 도메인에 오픈 리다이렉트 엔드포인트가 있다고 합시다.

  1. 공격자는 redirect_uri를 우리 도메인의 리다이렉트 엔드포인트(https://app.example.com/go?url=...)로 지정한 인가 링크를 피해자에게 보냅니다.
  2. 접두사 검사를 통과하므로 인가 서버는 피해자를 그 주소로 돌려보내면서 코드나 토큰을 붙입니다.
  3. 리다이렉트 엔드포인트는 다시 공격자 사이트로 이동시킵니다. 토큰이 URL 프래그먼트(#access_token=...)에 있으면 브라우저는 3xx 리다이렉트를 거쳐도 프래그먼트를 유지합니다. 그래서 공격자 페이지의 스크립트가 토큰을 읽을 수 있습니다. 인가 코드가 쿼리에 있는 경우에도, 리다이렉터가 받은 쿼리를 통째로 넘기는 구현이라면 그대로 유출됩니다.

PortSwigger Web Security Academy는 이 시나리오를 "Stealing OAuth access tokens via an open redirect" 실습으로 다룹니다. 인가 서버가 허용 목록으로 검증하더라도, 허용된 도메인 안에 오픈 리다이렉트가 하나 있으면 우회된다는 점을 보여 줍니다.

이 때문에 OAuth 2.0 Security Best Current Practice(RFC 9700, 2025년 1월)는 두 가지를 명시합니다. 인가 서버는 네이티브 앱의 localhost 포트를 제외하면 redirect_uri를 정확한 문자열 일치로 비교해야 합니다. 그리고 클라이언트와 인가 서버는 오픈 리다이렉터를 두지 않아야 합니다. 정리하면, 오픈 리다이렉트의 위험도는 같은 도메인에 OAuth 콜백이 있는지, 인가 서버 검증이 얼마나 엄격한지에 따라 크게 달라집니다.

// 인가 서버(직접 구현한 경우) - 취약한 예
if (!redirectUri.startsWith(client.redirectUriPrefix)) throw new Error('invalid redirect_uri');

// 수정: 등록된 값과 정확히 일치할 때만 허용
if (!client.registeredRedirectUris.includes(redirectUri)) throw new Error('invalid redirect_uri');

클라이언트 쪽에서는 Authorization Code + PKCE를 쓰고 state를 검증하세요. 그러면 코드가 새어 나가더라도 공격자는 code_verifier가 없어 토큰으로 교환하기 어렵습니다. 다만 이것은 피해를 줄이는 장치이지, 오픈 리다이렉트를 남겨 둬도 된다는 근거는 아닙니다.

startsWith·정규식 검증이 우회되는 패턴

리포트를 받고 급하게 넣는 검증은 대개 문자열 비교입니다. 브라우저의 URL 해석 방식과 개발자의 직관이 다르기 때문에 아래 패턴들이 반복해서 뚫립니다. 예시 도메인은 모두 가상입니다.

검증 방식통과하는 입력 예브라우저가 실제로 가는 곳
startsWith('https://app.example.com')https://app.example.com.evil.net/app.example.com.evil.net
startsWith('https://app.example.com')https://[email protected]/evil.net (앞부분은 사용자 정보로 해석)
startsWith('/') (내부 경로만 허용 의도)//evil.net/evil.net (스킴 상대 URL)
startsWith('/') 그리고 // 차단/\evil.net/많은 브라우저가 \를 /로 취급해 evil.net
endsWith('example.com')https://evilexample.com/evilexample.com
/example\.com/ (앵커 없음)https://evil.net/?example.comevil.net
/^https:\/\/.*example.com/https://evil.net/xexample.comevil.net (.*와 이스케이프 안 된 .)
includes('://') 차단javascript:alert(1)스크립트 실행

여기에 탭·개행 문자 삽입(/\t/evil.net)도 있습니다. WHATWG URL 파서는 이런 문자를 제거한 뒤 해석하므로, 문자열 단계에서는 //가 보이지 않다가 파싱 후에 스킴 상대 URL이 됩니다. 이중 URL 인코딩, 검증 라이브러리와 브라우저의 파서 차이를 노리는 입력도 꾸준히 나옵니다.

결론은 하나입니다. 문자열로 판단하지 말고, 브라우저와 같은 규칙의 파서로 URL을 해석한 다음 그 결과(origin, host, scheme)를 비교하세요. 정규식으로 URL 문법 전체를 정확히 표현하기는 사실상 어렵습니다.

허용 목록 기반 검증 코드

1순위: 외부 URL을 받지 않는다

가장 안전한 방법은 이동할 주소 자체를 파라미터로 받지 않는 것입니다.

  • 로그인 시작 시점에 돌아갈 경로를 서버 세션에 저장해 두고, 로그인 완료 후 세션 값으로 이동합니다.
  • 이동 대상이 몇 개로 정해져 있다면 키 매핑을 씁니다.
const DESTINATIONS = { orders: '/orders', mypage: '/mypage', cart: '/cart' };
const target = DESTINATIONS[req.query.next] ?? '/';
res.redirect(target);

2순위: 내부 경로만 허용한다

로그인 후 이동처럼 같은 서비스 안에서만 움직이는 경우입니다. 파싱한 결과의 origin이 우리 origin과 같은지 확인합니다.

const APP_ORIGIN = 'https://app.example.com';

function safeReturnPath(input) {
  if (typeof input !== 'string' || input.length > 2048) return '/';
  // 1차 필터: 상대 경로 형태만
  if (!input.startsWith('/') || input.startsWith('//') || input.startsWith('/\\')) return '/';

  let url;
  try {
    url = new URL(input, APP_ORIGIN); // 브라우저와 같은 WHATWG 규칙으로 해석
  } catch {
    return '/';
  }
  // 핵심: 파싱 결과의 origin 비교 (탭·백슬래시 우회도 여기서 걸러짐)
  if (url.origin !== APP_ORIGIN) return '/';

  // 정규화된 경로만 돌려준다
  return url.pathname + url.search + url.hash;
}

app.post('/login', async (req, res) => {
  // ...인증 처리
  res.redirect(safeReturnPath(req.query.returnUrl));
});

1차 필터는 빠른 차단용일 뿐이고, 실제로 막아 주는 것은 url.origin 비교입니다. 검증에 실패하면 에러 페이지 대신 기본 경로로 보내야 사용자 경험도 해치지 않습니다.

3순위: 외부 도메인이 꼭 필요하면 호스트 허용 목록

여러 서브서비스로 보내야 하는 경우입니다.

const ALLOWED_HOSTS = new Set(['app.example.com', 'shop.example.com']);

function safeExternalUrl(input) {
  let url;
  try {
    url = new URL(input);
  } catch {
    return null;
  }
  if (url.protocol !== 'https:') return null;          // javascript:, data:, http: 차단
  if (url.username || url.password) return null;       // user@host 형태 차단
  if (url.port !== '') return null;                    // 예상치 못한 포트 차단
  if (!ALLOWED_HOSTS.has(url.hostname)) return null;   // 정확히 일치하는 호스트만
  return url.toString();
}

*.example.com 같은 와일드카드 허용은 신중하게 결정하세요. 사용자 콘텐츠를 올리는 서브도메인이나 방치된 서브도메인이 하나라도 있으면 허용 목록이 그대로 우회됩니다.

Django라면

Django의 LoginView는 내부적으로 url_has_allowed_host_and_scheme로 next를 검증합니다. 직접 만든 뷰에서도 같은 함수를 쓸 수 있습니다.

from django.utils.http import url_has_allowed_host_and_scheme
from django.shortcuts import redirect

def after_login(request):
    next_url = request.GET.get("next", "")
    if not url_has_allowed_host_and_scheme(
        next_url,
        allowed_hosts={request.get_host()},
        require_https=request.is_secure(),
    ):
        next_url = "/"
    return redirect(next_url)

이 함수는 공식 레퍼런스 문서에 정식으로 올라 있는 API가 아니라 내부 헬퍼에 가깝습니다. 버전을 올릴 때 동작이 그대로인지 테스트로 확인해 두세요.

점검 리포트에서 지적받았을 때 판단 기준

리포트의 위험도는 일반론적인 등급입니다. 우리 서비스에서의 실제 우선순위는 아래 질문으로 다시 매기는 것이 좋습니다. 리포트 해석 전반은 취약점 점검 리포트 읽는 법에서 더 자세히 다뤘습니다.

  • 같은 도메인(또는 허용된 서브도메인)에 OAuth·SSO 콜백이 있는가? 있다면 우선순위를 올립니다.
  • 우리가 인가 서버이거나 자체 SSO를 운영하는가? redirect_uri를 접두사·정규식으로 비교하고 있다면 오픈 리다이렉트보다 그쪽을 먼저 고쳐야 합니다.
  • 이동 직전에 토큰이나 세션 식별자를 URL에 붙이는 흐름이 있는가? 예를 들어 앱 딥링크나 SSO 티켓 전달이 그렇습니다.
  • 프런트엔드에서 location.href로 이동하는가? javascript: 스킴이 통과하면 XSS로 다뤄야 합니다.
  • 스캐너가 잡은 파라미터 외에 같은 패턴이 더 있는가? 보통 returnUrl, next, redirect, continue, url, to, dest 등 여러 이름으로 흩어져 있으니 코드 전체를 검색하세요.

실무 점검 체크리스트

  • res.redirect, sendRedirect, "redirect:", HttpResponseRedirect, location.href, location.assign 호출부를 모두 찾아 입력 출처를 확인했다
  • 이동 대상은 서버 세션 저장 또는 키 매핑으로 처리하고, 원시 URL 파라미터는 최소화했다
  • 검증은 문자열 비교가 아니라 URL 파싱 후 origin/host 비교로 한다
  • 스킴은 https:(내부 경로라면 상대 경로)만 허용하고, javascript:·data:는 차단한다
  • 허용 목록은 호스트 단위 정확 일치이며, 와일드카드 서브도메인은 근거가 있을 때만 쓴다
  • OAuth 클라이언트 등록 시 redirect_uri를 경로까지 정확히 등록했고, 와일드카드를 쓰지 않는다
  • 자체 인가 서버는 redirect_uri를 정확한 문자열 일치로 비교한다
  • Authorization Code + PKCE와 state 검증을 쓰고, implicit 흐름은 쓰지 않는다
  • OAuth 콜백 도메인에는 범용 리다이렉트 엔드포인트(/go?url=)를 두지 않는다
  • 위 우회 패턴 표의 입력값으로 단위 테스트를 만들어 회귀를 막는다

로그인 흐름 전체를 함께 점검하고 싶다면 로그인 보안 체크리스트도 참고하세요.

자주 묻는 질문

내부 서비스로만 이동하는데, 그래도 고쳐야 하나요?

코드상으로는 "의도"가 아니라 "검증"이 있는지가 기준입니다. 지금은 내부 경로만 쓰더라도 검증이 없다면 외부 주소가 그대로 통과합니다. 위의 safeReturnPath 정도면 몇 줄로 해결됩니다.

경고 페이지("외부 사이트로 이동합니다")를 띄우면 충분한가요?

OWASP도 사용자 확인을 받는 중간 페이지를 대안으로 제시합니다. 외부 링크가 많은 커뮤니티 서비스에서는 현실적인 선택입니다. 다만 OAuth 콜백 도메인이라면 중간 페이지를 둔다고 프래그먼트 토큰 유출 경로가 완전히 사라지는 것은 아니므로, 범용 리다이렉터 자체를 두지 않는 편이 낫습니다.

구글·카카오 같은 외부 인가 서버를 쓰면 redirect_uri 걱정은 없나요?

인가 서버 쪽 검증은 제공자가 담당합니다. 하지만 어떤 URI를 등록할지는 우리가 정합니다. 경로까지 정확히 등록하고, 등록한 도메인 안에 오픈 리다이렉트가 없도록 관리하는 것은 여전히 우리 책임입니다.

정리

  • 오픈 리다이렉트는 피싱 경유지에 그치지 않습니다. OAuth 콜백 도메인에 있으면 인가 코드·토큰 유출 경로가 될 수 있습니다.
  • startsWith, endsWith, 앵커 없는 정규식은 @, //, \, 유사 도메인 같은 입력으로 쉽게 우회됩니다.
  • 이동 대상은 세션 저장이나 키 매핑으로 처리하는 것이 가장 안전하고, 불가피하면 URL을 파싱한 뒤 origin·호스트 허용 목록과 정확히 비교하세요.
  • OAuth에서는 redirect_uri 정확 일치, PKCE, state 검증을 함께 적용하세요. 직접 점검이 어렵다면 무료 분석 신청으로 현재 구성을 함께 살펴볼 수 있습니다.

참고 자료

함께 읽으면 좋은 글