2026년 9월 15일

1편에서 Open redirect에 대해서 자세히 기록하고 알아보는 과정을 가졌다. 하지만 백엔드의 필터링 과정을 검증해봤을 뿐이지, 이제부터 Front-end에서 어떻게 검증하는지 알아보고자 한다. 만약 글을 못봤다면 여기 를 참고하도록 하자. 사실 대부분 Back-end에서 Open redirect를 제한하는 것만으로도 충분하다. 하지만 Front-end에서도 방지해야 하는 사례를 발견했는데, 대표적으로 OAuth를 적용했을 때 이다.
OAuth를 적용 했을 때 인증 과정은 아래와 같다.
여기서 우리는 이미 Front-end에서 로그인 호출 과정에서 로그인 과정 완료 후 Back-end에서 최종 응답을 기반으로 인증정보 생성 으로 인증 절차를 거쳤을 때 악성URL에 대한 redirect를 이미 방지를 했다.
그래서 마지막 Front-end에서 callback에 따른 redirect 처리 과정에서 redirect callback URL를 받아올 때 루트 페이지로 강제로 반환하는 과정을 거쳤다.
하지만 악성 사용자가 외부 요인을 통해 마지막 callback을 통해 redirect 하는 과정에서 추가적으로 악성 URL을 적용했을 가능성은 배제할 수 없어서 오늘은 이것을 방지하고자 한다.
이 글에서 설명하는 프로젝트는 Next.js + Tanstack Query를 기준으로 적용하고 있습니다. 만약 일반 CSR 기반 환경의 경우, 해당 글의 가이드라인을 적용할 수 없으니 참고 부탁드립니다.
대부분 Front-end에서도 제한 한다고 하면 말도 안된다고 생각할 것이다. 그도 그럴게 Front-end 클라이언트 환경이라면 위/변조가 쉽게 이루어지는 환경이다보니 Back-end에서 주로 처리하는 로직(결제, 인증 처리 전 예외처리)는 따로 하지 않는 것이 대부분이었다. 하지만 이번에 우리가 다뤄볼 내용은 Front-end의 클라이언트 사이드가 아닌 Next.js나 react-route(前 Remix) 등에서 주로 사용하는 서버사이드에서 제어하는 방법을 알아볼 것이다.
상단에 요약 했던 다이어그램의 흐름 중 첫번째 단계인 Front-end에서 로그인 호출 단계와 마지막 단계인 Front-end에서 callback에 따른 Redirect처리 에서 Open redirect를 방지할 것이다.
우선, 프로젝트의 구조에 대해 간략하게 설명하자면 필자의 경우 Next.js를 구성할 때 app 폴더에서는 서버사이드 관련 순수 로직만 남기고, UI, 레이아웃 관련 내용은 모두 components 폴더에 components/ui components/screens components/common 으로 구분하는 형식으로 지정했다.
하지만 우리가 볼 위치는 'use client'가 적용된 클라이언트 사이드의 컴포넌트 폴더가 아닌, 서버 사이드로 동작하는 app 폴더의 내용을 볼 것이다.
그 중 우선 로그인 페이지부터 확인해보자면 대부분 흐름은 아래와 같이 진행될 것이다.
// /app/(auth)/login/page.tsx
import { Login } from "@/components/screens/Login";
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{
error?: string | string[];
redirect?: string | string[];
}>;
}) {
// OAuth 실패 시 백엔드가 ?error=<한글 메시지>를 붙여 에러 메시지를 출력한다.
// 그리고 redirect 파라미터를 기준으로 어느 페이지로 이동할지 대응한다.
const { error, redirect } = await searchParams;
return <Login error={error} redirect={redirect} />;
}
대충 간단하게 확인하자면 서버 사이드에서 searchParams를 기반으로 응답으로 전달 받은 error내용과 redirect 대상을 그대로 클라이언트로 던져주는 역할을 하는 것이 전부다.
솔직히 말하자면 로그인 화면 과정 자체에서 막을 필요는 없긴하다. 이미 Back-end에서 1차적으로 Open redirect에 대해서 대응을 이미 하고 있어 로그인 과정 자체에서는 방지가 되어 있기 때문에 추가적으로 막을 필요가 없다.
하지만 로그인 인증 절차를 다 마치고 돌아오는 과정인 /callback에서는 어떨까?
Back-end에서는 이미 가공을 해서 프론트엔드에 경로를 새로 지정을 해주는 형식으로 대응이 되어 있으나, Front-end에서는 그대로 받아오기만 하고 추가적으로 검증하지 않는다.
그래서 Back-end에서 이미 한번 막는데 Front-end에서 한번 더 막는건 과해보일진 모르나, 외부 요인을 통해 /callback 의 파라미터가 변조될 가능성은 배제할 수 없어 막으려고 했다.
프로젝트 구성은 이미 위에서 설명했으니 생략하며, 이번엔 /app/auth/callback/page.tsx 파일을 한번 살펴보자.
아마 위에 예시로 작성했던 로그인 페이지와 동일하게 바로 파라미터를 넘기면 아래와 같은 형식이 될 것이다.
// /app/auth/callback/page.tsx
import { AuthCallback } from "@/components/screens/AuthCallback";
import { sanitizeReturnUrl } from "@/lib/auth-redirect";
export default async function AuthCallbackPage({
searchParams,
}: {
searchParams: Promise<{ error?: string; redirect?: string | string[] }>;
}) {
const { error, redirect } = await searchParams;
return <AuthCallback error={error} redirect={redirect} />;
}
실제 페이지 랜더링은 /components/screens/AuthCallback 에서 담당하고 있으며, app 폴더에 있는 callback 관련 파일은 서버사이드에서 미리 파라미터를 전달 해주는 역할만 해주고 있다.
그래서 이제 redirect를자세히 보면 아무런 처리를 하지 않아 모든 경로,URL을 그대로 받아올 수 있는 형식으로 되어있는데 이걸 방지하고자 한다.
우선 lib 폴더에 유틸성으로 auth-redirect.ts 파일을 만들어 그 안에 sanitizeReturnUrl 를 만들어 내부 경로만 필터링 하는 함수를 만들고 처리할 것이다.
그래서 실제 예제는 아래와 같이 작성하면 된다.
/*
외부 URL로 튕겨 보내는 오픈 리다이렉트를 막는다.
- "/..." 형태의 같은 출처 경로만 통과
- "//evil.com"(프로토콜 상대 URL)과 "/\evil.com"은 차단
문자열만 놓고 보면 안전해 보여도 브라우저(WHATWG URL 표준)가 실제로는
다르게 파싱해 "//"로 둔갑하는 값들이 있어, 검사 전에 표준이 규정한 전처리를
그대로 재현한다:
- 개행/탭(U+0009, U+000A, U+000D)은 위치와 무관하게 파싱 전에 전부 제거된다.
→ "/\t/evil.com" 같은 값은 문자열 검사만 보면 "//"로 시작하지 않지만,
브라우저는 탭을 지운 뒤 "//evil.com"(프로토콜 상대 URL)으로 해석한다.
- 선행/후행 C0 제어문자·공백은 트리밍된 뒤 파싱된다.
- http/https 등 특수 스킴은 RFC3986과 달리 "\"도 "/"와 동일하게 취급하므로,
둘을 섞어 "//"를 흉내내는 조합(예: "/\/evil.com")도 함께 막아야 한다.
*/
export function sanitizeReturnUrl(
value: string | string[] | undefined | null,
): string | undefined {
const raw = Array.isArray(value) ? value[0] : value;
if (!raw) return undefined;
const decoded = (() => {
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
})();
const normalized = decoded
.replace(/[\t\n\r]/g, "")
.replace(/^[- ]+|[- ]+$/g, "");
if (!normalized.startsWith("/")) return undefined;
const collapsed = normalized.replace(/\\/g, "/");
if (collapsed.startsWith("//")) return undefined;
normalized;
}
해당 함수에서 문자열 URL을 대조해서 안전한 값인지 검토하게 되는데 1편과 동일하게 WHATWG URL, RFC3986표준을 기준으로 검토해서 더욱 안전하게 처리할수 있게 반영했다.
그래서 해당 함수를 기준으로 다시 /callback과 /login 페이지에 반영한다면 아래와 같이 된다.
import { AuthCallback } from "@/components/screens/AuthCallback";
import { sanitizeReturnUrl } from "@/lib/auth-redirect";
export default async function AuthCallbackPage({
searchParams,
}: {
searchParams: Promise<{ error?: string; redirect?: string | string[] }>;
}) {
const { error, redirect } = await searchParams;
// redirect 쿼리는 브라우저(클라이언트 컴포넌트)로 넘어가기 전에 서버에서 먼저
// 검증한다 — 화이트리스트(같은 출처 상대경로)를 통과하지 못하면 여기서 걸러지고,
// 원본 값은 클라이언트 코드에 전달되지 않는다.
const safeRedirect = sanitizeReturnUrl(redirect);
return <AuthCallback error={error} redirect={safeRedirect} />;
}
그리고 해당 처리는 모두 서버사이드에서 담당하게 되므로, 클라이언트가 해당 내용을 볼 일 없이 페이지를 조회할 수 있게 된다. 또한 추가적으로 CORS와 유사하게 안전 URL을 추가해서 예외적으로 cross-origin이어도 redirect를 허용할 수 있게 처리할 수 있으나, 이번 예제에서는 해당 기능은 구현하지 않고 넘어갈 것이다.
그래서 최종적으로 실제 redirect 경로만 보면 undefined로 돼서 홈으로 이동하거나 프로필 이동 등 추가적으로 적용된 이동 트리거가 있다면 해당 페이지로 이동할 것이다.
솔직히 Back-end에서 막는 것만으로도 충분해 보였다고 생각했으나, Front-end에서도 임의로 변조할 가능성이 있다고 생각해서 이번 기회에 Next.js의 서버 사이드에 대해서 조금 더 탐구할 수 있는 기회가 생겨서 좋았던 것 같다. 물론 이 이론자체는 Next.js가 아닌 다른 라이브러리/프레임워크에서도 사용 가능하긴 하다. 하지만 이번에는 Next.js를 기준으로 우선 적용 해봤는데 가능하면 기회가 생길 때 다른 라이브러리/프레임워크에서도 해당 기능을 구현해보고 싶다.
다만 아쉬운점이 있다면 원래 middleware로 적용해보고 싶었는데 OAuth 이동 과정에서 redirect 경로 유실 가능성을 방지하기 위한 fallback으로 인해 적용해보진 못했다. 아마 문제가 없다면 fallback대응을 위한 sessionStorage 저장을 완전 제외하거나 cookie로 바꿔서 미들웨어로 바꿔볼 계획이다. 그래서 아마 바꾼다면 3편으로 또 작성하지 않을까 싶다.