URL 검증과 Open Redirect 방지
in Security / Web on Open redirect, Url 검증, 보안, Oauth2, Sso, 피싱, 개발
Open Redirect 공격 방지와 URL 검증을 위한 전략 및 코드 예시.
개요
Open Redirect는 사용자를 악의적인 사이트로 리다이렉트시키는 공격이다. 특히 OAuth2/SSO 환경에서 redirect_uri 파라미터를 악용할 수 있다.
Open Redirect 공격
공격 시나리오
공격자가 피싱 링크 생성:
https://trusted-sso.com/logout?redirect_uri=https://evil-site.com/phishing사용자가 신뢰하는 SSO 도메인을 보고 클릭
SSO 서버가 검증 없이 리다이렉트 수행
사용자가 피싱 사이트에서 자격 증명 입력
위험도
- OWASP Top 10 보안 취약점
- 피싱 공격의 성공률 증가
- 브랜드 신뢰도 손상
방지 전략
1. 화이트리스트 방식
@Service
@ConfigurationProperties(prefix = "oauth2")
@Setter
public class UrlValidatorService {
private List<String> allowedRedirectDomains;
public boolean isAllowedUrl(String url) {
if (url == null || url.isEmpty()) {
return false;
}
for (String domain : allowedRedirectDomains) {
// 정확한 도메인 매칭
if (url.equals(domain) || url.startsWith(domain + "/")) {
return true;
}
}
return false;
}
}
2. 프로토콜 검증
public boolean isSecureUrl(String url) {
// HTTPS만 허용
if (!url.startsWith("https://")) {
log.warn("Non-HTTPS URL rejected: {}", url);
return false;
}
return true;
}
3. 상대 경로 허용
private boolean isRelativePath(String url) {
// "/"로 시작하지만 "//"로 시작하지 않는 경우
// "//" = protocol-relative URL (//evil.com 우회 공격 방지)
return url.startsWith("/") && !url.startsWith("//");
}
4. URL 디코딩
public String validateUrl(String url) {
// 인코딩된 URL 처리
// https%3A%2F%2Fevil.com -> https://evil.com
try {
String decoded = URLDecoder.decode(url, StandardCharsets.UTF_8);
return validateDecodedUrl(decoded);
} catch (Exception e) {
log.warn("URL decoding failed: {}", url);
return DEFAULT_REDIRECT;
}
}
잘못된 검증 패턴
부분 문자열 매칭
// 취약!
if (url.contains("example.com")) {
return true;
}
// evil-example.com도 통과됨
도메인만 체크
// 취약!
if (url.contains("://example.com")) {
return true;
}
// https://example.com.evil.com도 통과될 수 있음
정규식 오류
// 취약!
Pattern pattern = Pattern.compile("https://.*example.com.*");
// https://evil.com?fake=example.com 통과
올바른 검증 로직
public String validateRedirectUrl(String rawUrl, String defaultUrl) {
if (rawUrl == null || rawUrl.trim().isEmpty()) {
return defaultUrl;
}
String url = rawUrl.trim();
// 1. URL 디코딩
try {
String decoded = URLDecoder.decode(url, StandardCharsets.UTF_8);
if (!decoded.equals(url)) {
log.debug("URL decoded: {} -> {}", url, decoded);
url = decoded;
}
} catch (Exception e) {
log.debug("URL decoding failed, using original");
}
// 2. 상대 경로 허용
if (url.startsWith("/") && !url.startsWith("//")) {
return url;
}
// 3. 프로토콜 검증
if (!url.startsWith("https://")) {
log.warn("Non-HTTPS URL rejected: {}", url);
return defaultUrl;
}
// 4. 화이트리스트 검증
for (String allowed : allowedRedirectDomains) {
if (url.equals(allowed) || url.startsWith(allowed + "/")) {
return url;
}
}
log.warn("URL not in whitelist: {}", url);
return defaultUrl;
}
설정 예시
oauth2:
allowed-redirect-domains:
# 운영 환경
- https://app.example.com
- https://admin.example.com
- https://api.example.com
# 개발 환경
- https://dev.example.com
- https://staging.example.com
# 로컬 (개발용)
- https://localhost:3000
테스트 케이스
@Test
void shouldRejectMaliciousUrls() {
// 프로토콜 우회
assertFalse(validator.isAllowedUrl("http://example.com")); // HTTP
assertFalse(validator.isAllowedUrl("//evil.com")); // Protocol-relative
assertFalse(validator.isAllowedUrl("javascript:alert(1)")); // JavaScript
// 도메인 우회
assertFalse(validator.isAllowedUrl("https://evil-example.com"));
assertFalse(validator.isAllowedUrl("https://example.com.evil.com"));
assertFalse(validator.isAllowedUrl("https://evil.com?fake=example.com"));
// 인코딩 우회
assertFalse(validator.isAllowedUrl("https%3A%2F%2Fevil.com"));
}
@Test
void shouldAllowValidUrls() {
assertTrue(validator.isAllowedUrl("https://example.com"));
assertTrue(validator.isAllowedUrl("https://example.com/path"));
assertTrue(validator.isAllowedUrl("https://example.com/path?query=value"));
assertTrue(validator.isAllowedUrl("/relative/path"));
}
로깅 권장사항
// 거부된 URL은 반드시 로깅 (보안 모니터링용)
log.warn("Rejected redirect URL: {} (not in allowed domains)", url);
// 허용된 URL은 DEBUG 레벨
log.debug("Allowed redirect URL: {}", url);
관련 링크
- [[SSO 로그아웃 플로우]]
- [[OAuth2 보안 모범 사례]]
- [[OWASP Top 10]]
- [[Spring Security CORS 설정]]