로그인하면 원래 보던 페이지로
로그인이 필요한 페이지에 들어가면 로그인 화면으로 튕긴다. 로그인을 마치면 아까 보던 페이지로 돌아간다. 이 흐름을 만들려면 "원래 어디 있었는지"를 어딘가에 적어둬야 하는데, 가장 쉬운 방법은 주소창에 붙이는 것이다.
https://myapp.com/login?next=/settings/billing
에이전트에게 "로그인 끝나면 원래 페이지로 돌려보내줘"라고 하면 이런 코드를 받을 수 있다.
// app/login/actions.ts
const next = formData.get('next') as string ?? '/';
await signInWithPassword(email, password);
redirect(next);
돌려보는 동안에는 아무 문제가 없다. next에는 늘 내 앱의 경로가 들어 있으니까. 문제는 이 값을 누가 정하느냐다. 주소창에 있는 값은 링크를 만든 사람이 정한다. 그 사람이 꼭 나일 필요는 없다.
https://myapp.com/login?next=https://myapp-login.example/
링크는 진짜 myapp.com에서 시작한다. 받은 사람은 도메인을 확인하고, 진짜 로그인 화면을 보고, 진짜 비밀번호를 넣는다. 그리고 로그인이 끝나는 순간 공격자가 만든 사이트로 넘어간다. 거기에 "세션이 만료됐습니다. 다시 로그인해주세요"라는 똑같이 생긴 화면이 떠 있으면 별 의심 없이 한 번 더 입력하기 쉽다.
이게 오픈 리다이렉트(open redirect)다. 내 서버가 남이 정한 아무 주소로 사용자를 보내주는 상태를 말한다. CWE 목록에는 601번, "신뢰할 수 없는 사이트로의 URL 리다이렉션"이라는 이름으로 올라 있다. 내 서버에서 데이터가 새는 건 아니다. 대신 내 도메인이 쌓아둔 신뢰를 남이 빌려 쓴다.
호텔 프런트에 비유하면 이렇다. 손님이 "원래 있던 방으로 데려다 주세요"라며 쪽지를 내민다. 직원이 쪽지에 적힌 곳으로 그대로 안내하는데, 쪽지는 호텔 밖 골목 주소다. 호텔 유니폼을 입은 직원이 안내했으니 손님은 의심하지 않는다.
startsWith('/')로는 부족하다
"외부 주소를 막아줘"라고 하면 가장 먼저 떠오르는 수정은 이런 모양이다.
const safe = next.startsWith('/') ? next : '/';
redirect(safe);
/로 시작하면 내 사이트 안의 경로라는 논리다. 그럴듯하지만 브라우저는 이 문자열을 그렇게 읽지 않는다. 아래 입력은 전부 /로 시작한다. Node에 들어 있는 URL 파서(브라우저와 같은 WHATWG URL 표준을 따른다)로 https://myapp.com을 기준 삼아 해석한 결과를 옮겼다.
next 값 | startsWith('/') | 브라우저가 가는 곳 |
|---|---|---|
/settings | 통과 | https://myapp.com/settings |
//evil.example | 통과 | https://evil.example/ |
/\evil.example | 통과 | https://evil.example/ |
/(탭)/evil.example | 통과 | https://evil.example/ |
////evil.example | 통과 | https://evil.example/ |
하나씩 이유가 있다.
//evil.example은 경로가 아니라 "스킴만 생략한 주소"다. RFC 3986이 정한 상대 참조 중 하나로, 지금 페이지가 https면 https://evil.example로 해석된다. CDN 스크립트를 //cdn.example.com/lib.js처럼 쓰던 시절의 그 문법이다.
/\evil.example은 백슬래시가 문제다. WHATWG URL 표준은 http, https 같은 스킴에서 \를 /와 똑같이 취급한다. 표준 안에 이걸 "invalid-reverse-solidus"라는 검증 오류로 따로 적어두긴 했지만, 오류로 표시만 할 뿐 해석은 계속 진행한다. 결국 //evil.example과 같아진다.
탭이 들어간 경우는 더 황당하다. URL 표준은 해석을 시작하기 전에 문자열 안의 탭과 줄바꿈을 지운다. 내 코드는 / 다음에 탭이 있으니 슬래시 두 개가 아니라고 판단하는데, 브라우저는 탭을 지우고 //를 본다. 링크에는 %09로 적어두면 서버가 쿼리를 풀면서 탭으로 바꿔준다.
////evil.example은 브라우저가 https 같은 스킴 뒤의 슬래시 개수를 따지지 않는다는 걸 보여준다. 두 개든 네 개든 그다음에 오는 걸 호스트로 읽는다. 반면 다른 파서는 같은 문자열을 다르게 읽을 수 있다. 바로 다음에 볼 Redash 사례가 그렇다.
공통점은 하나다. 내 코드가 문자열을 읽는 방식과 브라우저가 URL을 읽는 방식이 다르다. 서버가 Location: /\evil.example을 응답하면 브라우저는 HTTP 표준(RFC 9110)에 따라 이 값을 현재 주소 기준으로 해석하고, 해석은 브라우저의 URL 파서가 한다. 서버 쪽 startsWith는 그 과정에 아무 영향도 주지 못한다.

검사가 있었는데도 뚫린 두 사례
이게 초보자만의 실수였다면 다룰 이유가 적다. 그런데 오래 운영된 오픈소스에서도 올해 같은 구멍이 나왔다.
데이터 시각화 도구 Redash는 올해 공개된 CVE-2026-33213에서 이 문제를 고쳤다. 로그인 후 이동 경로를 정리하는 함수가 next 값에서 스킴과 호스트를 떼어내긴 했는데, 앞쪽에 겹친 슬래시는 정리하지 않았다. 그래서 /login?next=////evil.com 하나로 로그인한 사용자를 외부 사이트로 보낼 수 있었다. 영향 범위는 5.0.2부터 26.3.0까지다.
Redash는 Python으로 만든 도구다. Python 표준 라이브러리의 urlparse에 ////evil.com을 넣어보면 호스트는 빈 값, 경로는 //evil.com으로 나온다. 서버 입장에선 "호스트가 없으니 내부 경로"다. 그 경로를 받은 브라우저는 evil.com으로 간다. 같은 문자열을 두 파서가 다르게 읽은 것이다.
Git 호스팅 도구 Gitea의 CVE-2026-25779(GHSA-j5r2-4c8j-xc3m)는 더 교묘하다. Gitea에는 redirect_to가 상대 경로인지 검사하는 함수가 이미 있었다. 공개된 재현 주소는 이렇다.
https://gitea.com/user/login?redirect_to=/a/../\example.com
/a/../는 "a 폴더로 갔다가 다시 나온다"는 뜻이라 결과적으로 아무것도 안 한 것과 같다. 검사 함수가 보기엔 평범한 상대 경로다. 그런데 ..를 정리하고 백슬래시를 슬래시로 바꾸고 나면 경로가 //example.com이 된다. advisory에는 어느 단계에서 이렇게 바뀌었는지까지는 나오지 않는다. 다만 이 값을 손대지 않고 Location에 넣었다면 브라우저는 https://gitea.com//example.com, 즉 같은 사이트 안의 이상한 경로로 갔을 것이다(직접 해석해보면 그렇게 나온다). 실제로 외부로 나갔다는 건, 검사를 통과한 뒤 어딘가에서 경로가 한 번 더 정리돼 //example.com 꼴로 나갔다고 보는 게 자연스럽다. 1.25.4 이하 버전이 영향을 받았다.
두 사례 모두 검사는 있었다. 문제는 검사한 문자열과 실제로 내보낸 문자열이 달랐다는 점이다. 검사 뒤에 정규화가 한 번 더 일어나거나, 검사하는 쪽과 해석하는 쪽의 규칙이 어긋나면 이렇게 된다.
브라우저와 같은 규칙으로 읽고, 읽은 그대로 내보낸다
해결책은 문자열 검사를 더 촘촘하게 만드는 게 아니다. 브라우저와 같은 규칙으로 먼저 해석하고, 해석된 결과의 출처(origin)를 비교하고, 비교에 쓴 바로 그 결과를 내보내면 된다.
// lib/safe-next.ts
const BASE = new URL(process.env.PUBLIC_BASE_URL!); // 예: https://myapp.com
export function safeNext(raw: string | null): string {
const fallback = new URL('/', BASE).href;
if (!raw) return fallback;
try {
const url = new URL(raw, BASE); // 브라우저와 같은 WHATWG 규칙으로 해석
if (url.origin !== BASE.origin) return fallback; // 스킴+호스트+포트가 내 것인지
return url.href; // 검사한 그 절대 주소를 그대로 반환
} catch {
return fallback;
}
}
핵심은 세 줄이다.
new URL(raw, BASE)는 앞의 표를 만든 바로 그 파서다. //evil.example이든 /\evil.example이든 탭이 섞였든 브라우저와 같은 결론을 낸다.
url.origin !== BASE.origin은 스킴, 호스트, 포트를 한꺼번에 비교한다. https://myapp.com.evil.example은 호스트가 다르니 떨어진다. https://myapp.com@evil.example은 앞부분이 사용자 이름으로 해석되고 실제 호스트는 evil.example이라 역시 떨어진다. javascript:alert(1)은 origin이 "null"이라서 떨어진다.
마지막 줄이 가장 중요하다. url.pathname만 꺼내서 돌려주면 안 된다. Gitea 사례의 입력을 넣어보면 해석된 URL의 origin은 https://myapp.com이라 검사를 통과하지만 pathname은 //example.com이다. 이걸 Location에 넣는 순간 다시 외부 주소가 된다. 내 origin이 앞에 붙은 절대 주소 https://myapp.com//example.com을 내보내면 브라우저도 그냥 내 사이트 안의 이상한 경로로 받아들인다. 그 페이지는 404가 나겠지만 밖으로 나가지는 않는다. 이런 주소까지 깔끔하게 정리하고 싶다면, 검사를 통과한 뒤 url.pathname이 //로 시작할 때 홈으로 보내는 줄을 하나 더 넣으면 된다.
기준 주소 BASE를 요청의 Host 헤더에서 만들면 안 된다는 것도 덧붙여 둔다. Host 헤더는 요청을 보내는 쪽이 정하는 값이라서, 이걸 기준으로 삼으면 비교 대상 자체를 공격자가 바꿀 수 있다. 환경변수에 고정해두는 게 맞다.
참고로 Auth.js(NextAuth)의 기본 redirect 콜백도 같은 생각으로 짜여 있다. 소스를 보면 /로 시작하는 값에는 baseUrl을 앞에 붙여 절대 주소로 만들고, 아니면 new URL(url).origin === baseUrl일 때만 통과시키고, 그 외에는 baseUrl로 보낸다. 앞에 내 주소를 붙이는 순간 //evil.example은 https://myapp.com//evil.example이 되니 밖으로 나갈 수 없다. Auth.js를 쓰면서 이 콜백을 직접 고쳐 쓸 일이 생기면, 이 구조를 깨지 않는지부터 확인하자.

서버 말고 브라우저에서 이동할 때
리다이렉트를 서버가 아니라 클라이언트 코드가 할 때도 많다.
const next = new URLSearchParams(location.search).get('next');
window.location.href = next ?? '/';
이 경우엔 위험이 한 단계 더 올라간다. MDN은 javascript: URL이 window.location을 포함해 이동 대상이 되는 모든 곳에서 쓰일 수 있고, 이동을 시도하는 순간 그 안의 코드가 실행된다고 적어두었다. ?next=javascript:alert(document.domain) 같은 값이 들어오면 피싱이 아니라 내 페이지 안에서 남의 스크립트가 도는 XSS가 된다. 콘텐츠 보안 정책(CSP)을 걸어두고 script-src에 'unsafe-inline'을 허용하지 않았다면 이런 이동이 막힐 수는 있다. 그래도 그건 두 번째 안전망이다. 클라이언트에서도 위의 safeNext를 똑같이 거친 값만 써야 한다.
OAuth의 redirect_uri는 더 엄격하다
"로그인 후 돌아갈 주소"와 비슷하지만 무게가 전혀 다른 값이 있다. 구글 로그인이나 깃허브 로그인을 붙일 때 등록하는 redirect_uri, 즉 콜백 주소다.
OAuth에서 로그인을 마친 사용자는 인증 서버에서 내 앱의 콜백 주소로 돌아오는데, 이때 주소에 인가 코드나 토큰이 붙어 온다. 둘 다 잠깐 쓰는 출입증이다. 콜백 주소를 느슨하게 검사하면 이 출입증이 엉뚱한 곳으로 배달된다.
OAuth 보안 모범 사례를 정리한 RFC 9700은 이 대목에서 오픈 리다이렉트를 직접 거론한다. 문서에 실린 공격은 이렇게 흘러간다. 어떤 앱이 콜백 주소를 https://client.example/cb?*처럼 "뒤에 파라미터는 아무거나" 패턴으로 등록해뒀다. 그리고 같은 도메인 어딘가에 redirect_to를 받아 아무 데나 보내주는 엔드포인트가 있다. 공격자는 콜백 주소 뒤에 redirect_to=https://attacker.example을 붙여 로그인 요청을 만든다. 패턴에는 맞으니 인증 서버는 통과시키고, 토큰을 #access_token=... 형태로 주소 끝에 붙여 돌려보낸다.
여기서 HTTP 표준의 한 줄이 작동한다. RFC 9110은 리다이렉트 응답의 Location에 프래그먼트(# 뒤)가 없으면 브라우저가 원래 주소의 프래그먼트를 그대로 이어 붙여야 한다고 정해두었다. 앱의 오픈 리다이렉터가 Location: https://attacker.example/을 내보내는 순간, 브라우저는 https://attacker.example/#access_token=...으로 간다. 토큰이 공격자 페이지 손에 들어간다.
RFC 9700의 처방은 두 갈래다. 인증 서버는 등록된 redirect URI와 요청 값을 정확한 문자열 일치로 비교해야 한다(MUST). 로컬호스트를 쓰는 네이티브 앱의 포트 번호만 예외다. 패턴을 없애면 위 공격은 성립하지 않는다. 그리고 클라이언트, 즉 내 앱도 오픈 리다이렉터를 노출하면 안 된다(MUST NOT)고 따로 적었다. 피싱에 쓰이는 건 물론이고, 인증 서버 쪽 설정이 조금만 느슨해져도 토큰을 빼내는 중간 다리가 되기 때문이다. 애초에 토큰을 프래그먼트로 돌려주는 옛 방식(implicit flow)은 이 RFC가 쓰지 말라고 권하는(SHOULD NOT) 방식이다. 대신 짧게 쓰고 버리는 인가 코드를 받은 뒤 서버에서 토큰으로 바꾸는 code 방식을 쓰라고 한다.
바이브 코더가 이 문제를 마주치는 곳은 대개 두 군데다.
첫째는 Supabase 같은 인증 서비스의 Redirect URLs 허용 목록이다. 앞에서 본 "패턴 등록"이 바로 여기에 해당한다. Supabase는 *(점과 슬래시를 넘지 않음)와 **(아무 문자나) 와일드카드를 지원하고, Vercel 미리보기 배포용으로 https://*-<팀-또는-계정-슬러그>.vercel.app/** 패턴을 예로 든다. 슬러그 부분을 빼먹고 https://*.vercel.app/**처럼 넓게 등록하면, 아무나 만들 수 있는 다른 사람의 Vercel 배포 주소까지 허용 목록에 들어간다. 문서도 운영 환경에서는 정확한 경로를 등록하라고 권한다.
둘째는 MCP 서버다. Claude Code나 Codex에 붙일 원격 MCP 서버를 직접 만들면서 OAuth를 붙이면 내가 인증 서버 역할까지 떠안을 수 있다. MCP 인가 스펙(2025-06-18 판)은 "Open Redirection" 항목에서 인증 서버가 사전 등록된 값과 redirect URI를 정확히 일치시켜 검증해야 한다(MUST)고 적어두었다. 하나 더 조심할 게 있다. 동적 클라이언트 등록(앱이 스스로 클라이언트로 등록하는 방식)을 열어두면 공격자도 자기 사이트를 redirect URI로 둔 클라이언트를 정상적으로 등록할 수 있다. RFC 9700은 이렇게 등록된 클라이언트로 일부러 틀린 요청을 보내 인증 서버가 오류와 함께 공격자 사이트로 사용자를 보내게 만드는 수법을 소개한다. 처방은 인증 서버가 사용자를 먼저 인증하고, 믿을 수 없는 주소로는 자동으로 보내지 않는 것이다. 이런 세부까지 직접 챙기기보다 검증된 인증 서버를 쓰는 편이 낫다.

에이전트에게 맡길 때
이 문제는 "보안 신경 써줘" 한 마디로 잘 잡히지 않는다. 에이전트가 쓰는 startsWith('/')는 틀린 코드가 아니라 덜 된 코드라서, 리뷰에서도 그냥 지나가기 쉽다. 요구사항을 테스트로 박아두는 쪽이 확실하다.
로그인 후 이동 경로(next)는 lib/safe-next.ts의 safeNext()만 거쳐서 써줘.
new URL(next, PUBLIC_BASE_URL)로 해석하고 origin이 같을 때만 그 href를 쓰고,
아니면 홈으로 보내. pathname만 꺼내서 쓰지 마.
아래 입력을 넣었을 때 결과의 origin이 항상 PUBLIC_BASE_URL의 origin인지 테스트해줘:
//evil.example, /\evil.example, /(탭 문자)/evil.example, ////evil.example,
/a/../\evil.example, https://myapp.com.evil.example,
https://myapp.com@evil.example, javascript:alert(1)
/a/../\evil.example처럼 내 사이트 안의 이상한 경로로 남는 입력도 있어서, 기대값을 "홈으로 간다"로 적으면 테스트가 틀린다. 기준은 "밖으로 나가지 않는다"다. /settings?tab=billing 같은 정상 경로가 그대로 통과하는지도 같이 확인하게 하면 된다.
오픈 리다이렉트는 서버가 털리는 취약점이 아니라서 우선순위에서 자주 밀린다. 하지만 이 구멍은 사용자가 "주소창 도메인을 확인하라"는 교육을 충실히 따를수록 더 잘 통한다. 확인한 도메인이 진짜 내 도메인이기 때문이다. 막는 데 드는 건 next를 처리하는 함수 하나와 테스트 몇 줄이다. 나중에 신고를 받고 고치는 것보다 로그인 흐름을 처음 만들 때 넣어두는 쪽이 훨씬 덜 번거롭다.