문제와 원인
/safe/..//outside.example는 입력 단계의 단일 슬래시 검사를 통과했다. URL 정규화 뒤 pathname이 //outside.example로 바뀌어도 첫 URL 객체의 origin은 https://app.local로 유지됐다. 반환값을 다음 단계에서 다시 상대 URL로 해석하면 outside.example가 호스트가 됐다. %2e%2e로 인코딩한 상위 경로도 같은 우회를 만들었다.
수정 전 코드
safeRelativeReturnPath
좌우로 이동해 전체 내용을 확인하세요. 키보드: ←·→, Home·End
export function safeRelativeReturnPath(value: unknown) {
const candidate = typeof value === "string" ? value : "/";
if (!candidate.startsWith("/") || candidate.startsWith("//")) return "/";
try {
const url = new URL(candidate, "https://app.local");
if (url.origin !== "https://app.local" || url.pathname.startsWith("/api/auth/")) {
return "/";
}
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return "/";
}
}
수정 후 코드
safeRelativeReturnPath
URL 객체를 만든 뒤 반환할 pathname의 // 시작 여부를 추가로 검사하고 해당 경로를 /로 돌린다. 내부 경로에는 정규화된 pathname과 쿼리·해시를 함께 반환한다.
좌우로 이동해 전체 내용을 확인하세요. 키보드: ←·→, Home·End
export function safeRelativeReturnPath(value: unknown) {
const candidate = typeof value === "string" ? value : "/";
if (!candidate.startsWith("/") || candidate.startsWith("//")) return "/";
try {
const url = new URL(candidate, "https://app.local");
if (url.origin !== "https://app.local" || url.pathname.startsWith("//") || url.pathname.startsWith("/api/auth/")) {
return "/";
}
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return "/";
}
}
검증 결과
Wrangler의 로컬 Worker에서 수정 전후 원본 safeRelativeReturnPath에 같은 입력 7개를 넣었다. 반환값을 https://app.local 기준 URL로 다시 해석해 최종 origin을 비교했으며, 외부 출처 반환은 2/7개에서 0/7개로 줄었다.
좌우로 이동해 전체 내용을 확인하세요. 키보드: ←·→, Home·End
| 입력 | 수정 전 반환값 | 수정 후 반환값 |
|---|---|---|
/safe/..//outside.example | //outside.example, 외부 출처 | /, 동일 출처 |
/safe/%2e%2e//outside.example | //outside.example, 외부 출처 | /, 동일 출처 |
/\outside.example | / | / |
//outside.example | / | / |
/api/auth/google/start | / | / |
/learn/sql/sqld?view=theory#unit | 입력 경로 유지 | 입력 경로 유지 |
/admin/../guides | /guides | /guides |