URL 검증과 Open Redirect 방지

Open Redirect 공격 방지와 URL 검증을 위한 전략 및 코드 예시.

개요

Open Redirect는 사용자를 악의적인 사이트로 리다이렉트시키는 공격이다. 특히 OAuth2/SSO 환경에서 redirect_uri 파라미터를 악용할 수 있다.

Open Redirect 공격

공격 시나리오

  1. 공격자가 피싱 링크 생성:

     https://trusted-sso.com/logout?redirect_uri=https://evil-site.com/phishing
    
  2. 사용자가 신뢰하는 SSO 도메인을 보고 클릭

  3. SSO 서버가 검증 없이 리다이렉트 수행

  4. 사용자가 피싱 사이트에서 자격 증명 입력

위험도

  • 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 설정]]

© 2024. Chiptune93 All rights reserved.