본문 바로가기
빌드노트 검색

빌드노트

슬래시로 시작하는 returnUrl인데 외부로 이동한다

슬래시로 시작하는 returnUrl도 //host나 역슬래시 조합이면 외부로 해석된다. 실제 URL 파싱과 ASP.NET Core helper 결과를 비교하고, 신뢰하는 origin·허용 경로·디코딩 시점과 접근 권한을 구분한다.

로그인 뒤 돌아갈 returnUrl을 startsWith("/")로 검사하면 //outside.invalid/account도 통과한다. 이 값의 outside.invalid는 경로가 아니라 호스트다. 복귀 목적지를 제한하려면 신뢰하는 base를 기준으로 URL을 파싱한 뒤 origin과 허용 경로를 확인해야 한다. 검사한 결과를 이동 API에 전달하기 전 다시 디코딩하지 않는 것도 같은 판단의 일부다.

슬래시 검사는 같아도 목적지는 다르다

Node.js가 설치돼 있다면 터미널에서 node를 실행하고 다음 코드를 붙여 넣을 수 있다. Node.js 22.23.2에서 직접 실행한 입력이다. URL 문자열만 파싱하므로 서버나 로그인이 필요 없고, 주소로 접속하지도 않는다. .invalid는 RFC 2606에서 예약한 이름 (새 탭에서 열림)이다.

const base = "https://portal.invalid/";
for (const raw of ["/account", "//outside.invalid/account", "/\\outside.invalid/account"]) {
  const u = new URL(raw, base);
  console.log(JSON.stringify(raw), raw.startsWith("/"), u.origin, u.pathname);
}

출력은 입력, 슬래시 검사 결과, origin, 경로 순서다.

"/account" true https://portal.invalid /account
"//outside.invalid/account" true https://outside.invalid /account
"/\\outside.invalid/account" true https://outside.invalid /account

/account는 base의 호스트를 유지한다. 반면 //outside.invalid/account는 scheme을 생략하고 호스트를 지정하는 network-path 주소다. base의 https는 물려받지만 호스트는 바뀐다. 출력의 \\는 실제 역슬래시 한 글자를 JSON으로 표시한 것이다. 세 번째 입력도 외부 호스트로 해석됐으므로 //만 추가로 거르는 수정으로는 부족하다.

WHATWG URL 표준의 HTTPS 파싱 규칙은 이 위치의 역슬래시도 구분자로 처리한다. 표준에서 잘못된 입력으로 표시하더라도 반드시 파싱 실패가 되는 것은 아니다. 따라서 예외가 나지 않았다는 사실도 허용 근거가 될 수 없다. WHATWG URL 파싱 규칙 (새 탭에서 열림)

문자열 안의 도메인 대신 origin을 비교한다

HTTP·HTTPS의 origin은 scheme, 호스트, 포트로 결정된다. Node의 new URL(input, base)에서 base는 상대 주소를 해석하는 기준이며, 모든 입력을 그 호스트 안에 가두는 옵션이 아니다. Node.js 22의 URL 생성자 설명 (새 탭에서 열림)도 파싱 결과의 origin을 확인하도록 안내한다.

같은 실험에서 다음 입력도 비교했다.

  • //portal.invalid.outside.invalid/account의 호스트는 portal.invalid.outside.invalid였다. 원래 도메인 이름이 앞에 있다고 같은 origin이 되지 않는다.
  • //portal.invalid@outside.invalid/account의 호스트는 outside.invalid였다. @ 앞부분은 사용자 정보다.
  • https://portal.invalid:444/account는 다른 origin이었다. 반면 기본 HTTPS 포트인 :443은 정규화되어 https://portal.invalid와 같았다.

그다음은 경로다. /admin은 같은 origin이지만 이 글의 복귀 대상으로 허용하지 않았다. /account/%2e%2e/admin도 파싱 후 경로가 /admin이 됐다. 원문이 /account로 시작하는지만 보면 이 차이를 놓친다.

다음은 서버에서 사용할 작은 정책 예다. 복귀 경로를 /와 /account로 한정하고, 사용자 정보와 값이 있는 query·fragment는 받지 않는다. ASCII 공백·제어 문자·역슬래시도 거부한다. 앞의 진단 코드와 같은 Node 세션에 붙여 넣을 수 있다.

const BASE = new URL('https://portal.invalid/');
const RETURN_PATHS = new Set(['/', '/account']);

function validatedReturnUrl(raw) {
  if (typeof raw !== 'string' || raw === '' ||
      /[\u0000-\u0020\u007f\\]/u.test(raw)) return null;
  try {
    const u = new URL(raw, BASE);
    if (u.protocol !== BASE.protocol || u.origin !== BASE.origin ||
        u.username || u.password || u.search || u.hash ||
        !RETURN_PATHS.has(u.pathname)) return null;
    return u.href;
  } catch {
    return null;
  }
}

직접 호출하면 validatedReturnUrl('/account')는 https://portal.invalid/account, validatedReturnUrl('//outside.invalid/account')와 validatedReturnUrl('/admin')은 null을 반환했다. 호출부는 null일 때 고정된 기본 화면으로 보내고, 허용됐을 때는 반환된 URL을 그대로 사용한다. 예를 들어 목적지는 validatedReturnUrl(returnUrl) ?? BASE.href로 고를 수 있다. base는 서비스가 승인한 공개 origin을 설정으로 제공한다. 사용자가 보낸 returnUrl이나 검증하지 않은 호스트 값에서 만들면 비교 기준 자체가 달라진다.

이 정책은 같은 origin의 절대 주소와 account 같은 상대 경로도 받아 정규화한 절대 주소를 반환한다. query가 필요한 서비스라면 필요한 키와 값의 의미를 따로 정해야 한다. 예제의 제한을 없애고 임의의 next 주소까지 그대로 전달하는 식으로 넓히지 않는다. 복귀 화면이 정해져 있다면 목적지 ID를 서버의 고정 URL에 대응시키는 방법도 있다. OWASP의 목적지 검증 지침 (새 탭에서 열림)

허용 목록은 복귀할 화면을 고르는 규칙이다. /account를 허용했다고 그 화면의 모든 사용자 데이터에 접근할 수 있는 것은 아니다. 목적지 핸들러는 현재 사용자와 요청한 자원의 접근 권한을 별도로 확인해야 한다.

인코딩된 입력은 어느 단계의 값인지 확인한다

/%2f%2foutside.invalid/account를 그대로 파싱했을 때는 origin이 https://portal.invalid였다. 경로의 %2f를 이 단계에서 실제 슬래시로 바꾸지는 않았다. 이 입력은 위 코드의 허용 경로와 달라 거부됐다. 여기에 별도로 decodeURIComponent를 적용하면 ///outside.invalid/account가 되고, 다시 파싱한 목적지는 https://outside.invalid/account로 바뀌었다.

쿼리 매개변수의 인코딩은 또 다른 단계다. 같은 Node 세션에서 아래 코드를 실행하면 매개변수를 읽은 뒤 검사할 실제 값을 볼 수 있다.

const value = new URLSearchParams("returnUrl=%2F%2Foutside.invalid%2Faccount").get("returnUrl");
console.log(value);
console.log(new URL(value, "https://portal.invalid/").origin);
//outside.invalid/account
https://outside.invalid

따라서 요청 주소에 %2F가 보인다는 이유만으로 로컬 경로라고 판단하면 안 된다. 프레임워크가 매개변수를 읽은 뒤의 값으로 검사하고, 검증 이후에는 다른 문자열로 디코딩하거나 원래 입력으로 되돌리지 않는다. OWASP의 URL 검증 절 (새 탭에서 열림)도 이동 API·브라우저와 호환되는 파서로 검사하고 검증된 URL을 사용하도록 안내한다.

프레임워크의 local URL helper는 무엇을 보장할까

ASP.NET Core를 쓴다면 직접 슬래시 조건을 늘리기 전에 Url.IsLocalUrl과 LocalRedirect를 확인한다. 공식 문서상 LocalRedirect는 로컬 주소가 아니면 예외를 던진다. ASP.NET Core 10의 open redirect 방지 안내 (새 탭에서 열림)

별도 콘솔 프로그램에서 ASP.NET Core 10.0.11의 실제 UrlHelper.IsLocalUrl을 호출했다. /account는 true, //outside.invalid/account와 슬래시 다음에 역슬래시를 둔 입력은 false였다. 같은 호스트의 절대 주소 https://portal.invalid/account도 false였다. helper의 local 기준은 host·authority가 없는 절대 경로 또는 ~/ 경로이므로, 위 JavaScript 정책의 같은 origin 허용과 범위가 다르다. IsLocalUrl의 공식 정의 (새 탭에서 열림)

하지만 /admin과 /%2f%2foutside.invalid/account는 helper에서 true였다. 이는 모든 경로가 복귀 목적지로 적합하거나 접근이 허가됐다는 판정이 아니다. helper를 써도 서비스의 허용 경로와 목적지의 권한 검사는 남는다. helper 통과 뒤 임의로 디코딩하는 코드가 있다면 그 변환도 없애거나 최종 값을 다시 검증해야 한다.

이번 확인 범위는 Windows에서 실행한 Node.js 22.23.2의 URL 파싱·허용 결정과 ASP.NET Core 10.0.11의 helper 반환값이다. 실제 로그인, 토큰 전달, 브라우저의 외부 이동은 실행하지 않았다. 사용 중인 프레임워크가 매개변수나 응답 주소를 다르게 가공한다면, 격리 환경에서 자동으로 이동을 따라가지 않고 최종 Location까지 대조해야 한다. 파싱 결과가 예상과 다를 때는 슬래시 조건을 더 붙이기 전에 검사에 들어간 값과 이동 API에 전달한 값이 같은지 확인한다.

← 전체 글 보기