SonarQube SSRF 빠르게 잡기 — URL 입력 검증 3가지

이 글에 등장하는 코드는 금융권 보안 정책상 실제 프로젝트 코드를 그대로 공개할 수 없어, 동일한 취약 패턴과 해결 흐름을 재현한 샘플로 대체했다. 클래스명·테이블명·경로 같은 식별자는 가상이지만, SonarQube가 잡는 데이터 플로우와 통과 조건은 실제 환경과 같다.

SonarQube ReDoS(S5852) 작업이 끝나자마자 이번엔 SonarQube SSRF 룰 S5144가 7건 터졌다. “Server-side requests should not be vulnerable to forging attacks”라는 메시지였다. CWE-918에 해당하는 취약점이다.

문제가 생긴 건 외부 결제 게이트웨이의 webhook 콜백 처리 모듈이었다. 결제가 완료되면 외부 시스템이 우리 서버에 결과를 전송하고, 우리 서버는 그 결과 안에 들어 있는 callbackUrl로 다시 호출해서 최종 확인하는 구조였다. 당시 개발자가 그 callbackUrl을 요청 본문에서 꺼내 그대로 URL.openConnection()에 넘겼다. SonarQube SSRF는 이 데이터 플로우를 Vulnerability로 분류해 잡고 있었다.

SSRF가 왜 위험한지는 알고 있었다. 공격자가 callbackUrlhttp://10.0.0.1/admin 같은 내부망 주소를 넣으면 서버가 직접 내부 서비스를 호출하게 된다. Open Redirect(S5146)가 브라우저를 외부로 보내는 거라면, SonarQube SSRF가 잡는 패턴은 서버 자체가 내부망을 탐색하는 발판이 된다는 점이다. XXE(S2755)처럼 단순한 파서 설정으로 막히는 게 아니라, SonarQube SSRF를 통과시키려면 URL 자체를 검증하는 로직을 따로 짜야 했다.

SonarQube SSRF — S5144 룰 경고 화면

레거시에서 발견된 패턴

7건을 분류하니 세 가지 호출 방식으로 나뉘었다.

패턴 1. URLConnection 직접 호출

// WebhookCallbackHandler.java
@Service
public class WebhookCallbackHandler {

    public String fetchCallbackResult(String callbackUrl) throws IOException {
        // 요청 본문에서 꺼낸 URL을 그대로 사용 — S5144 여기서 잡힘
        URL url = new URL(callbackUrl);
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        conn.setRequestMethod("GET");
        conn.setConnectTimeout(5000);

        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(conn.getInputStream()))) {
            StringBuilder sb = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                sb.append(line);
            }
            return sb.toString();
        }
    }
}

7건 중 4건이 이 패턴이었다. new URL(callbackUrl).openConnection() 형태로 외부 입력을 직접 연결에 쓰는 경우다. SonarQube는 callbackUrl의 출처가 요청 파라미터나 본문이면 taint source로 표시하고, 그게 URL 생성자나 openConnection까지 흘러들어오면 S5144를 잡는다.

패턴 2. RestTemplate getForObject

// PaymentVerificationService.java
@Service
public class PaymentVerificationService {

    @Autowired
    private RestTemplate restTemplate;

    public PaymentResult verify(WebhookPayload payload) {
        // payload.getVerifyUrl()이 외부 입력 — S5144 여기서 잡힘
        String userUrl = payload.getVerifyUrl();
        return restTemplate.getForObject(userUrl, PaymentResult.class);
    }
}

Spring RestTemplate를 쓰는 2건이었다. getForObject(userUrl, ...)에서 userUrl이 외부 입력인 경우 동일하게 S5144가 뜬다.

패턴 3. Apache HttpClient execute

// ExternalNotificationClient.java
@Component
public class ExternalNotificationClient {

    private final CloseableHttpClient httpClient = HttpClients.createDefault();

    public String call(String url) throws IOException {
        // 외부 입력 url을 그대로 HttpGet에 넘김 — S5144 여기서 잡힘
        HttpGet request = new HttpGet(url);
        try (CloseableHttpResponse response = httpClient.execute(request)) {
            return EntityUtils.toString(response.getEntity());
        }
    }
}

나머지 1건은 Apache HttpClient를 직접 쓰는 구형 모듈이었다. new HttpGet(url)에 외부 입력이 들어가는 구조다. 패턴은 다르지만 SonarQube가 잡는 데이터 플로우는 똑같다.

시도 1. http/https 프로토콜 검증 — 실패

제일 먼저 든 생각은 “프로토콜만 걸러내면 되는 거 아닐까”였다. file://, ftp://, dict:// 같은 이상한 스킴을 차단하고 http/https만 통과시키면 외부 URL만 허용하는 거니까 SSRF가 막힐 거라고 봤다.

// 시도 1 — 프로토콜(스킴) 검증
public String fetchCallbackResult(String callbackUrl) throws IOException {
    // http 또는 https만 허용
    if (!callbackUrl.startsWith("http://") && !callbackUrl.startsWith("https://")) {
        throw new IllegalArgumentException("허용되지 않는 프로토콜: " + callbackUrl);
    }

    URL url = new URL(callbackUrl);
    HttpURLConnection conn = (HttpURLConnection) url.openConnection();
    conn.setRequestMethod("GET");
    // ...
}

SonarQube는 여전히 S5144를 잡았다. 그리고 실제로도 이 검증은 의미가 없다. http://10.0.0.1/adminhttp://로 시작하기 때문에 프로토콜 체크를 통과한다. 서버가 내부망 주소를 그대로 호출하는 상황은 전혀 막혀 있지 않다.

공격 시나리오를 실제로 그려보니 더 명확했다. 공격자가 callbackUrlhttp://192.168.1.100:8080/internal-api를 넣으면, 프로토콜은 http로 유효하고, 서버는 내부 서비스에 직접 요청을 날린다. 프로토콜 검증만으로는 SSRF를 전혀 못 막는다는 걸 깨달았다. 40분 날렸다.

시도 2. host 정규식 검증 — 부분 통과

그러면 도메인을 정규식으로 검증하면 어떨까 싶었다. 내부 IP 패턴을 정규식으로 차단하고, 외부 도메인처럼 생긴 것만 통과시키는 방식이었다.

// 시도 2 — host 정규식 검증
private static final Pattern INTERNAL_IP_PATTERN = Pattern.compile(
    "^(10\\.|192\\.168\\.|127\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.).*"
);

public String fetchCallbackResult(String callbackUrl) throws IOException {
    if (!callbackUrl.startsWith("http://") && !callbackUrl.startsWith("https://")) {
        throw new IllegalArgumentException("허용되지 않는 프로토콜");
    }

    // URL에서 host 추출 (정규식)
    String host = extractHost(callbackUrl);
    if (INTERNAL_IP_PATTERN.matcher(host).matches()) {
        throw new SecurityException("내부 IP 차단: " + host);
    }

    URL url = new URL(callbackUrl);
    HttpURLConnection conn = (HttpURLConnection) url.openConnection();
    // ...
}

private String extractHost(String urlStr) {
    // "://" 이후, 첫 "/" 또는 ":" 전까지 추출하는 단순 정규식
    return urlStr.replaceAll("^https?://", "").split("[/:?]")[0];
}

이 방식은 두 가지 이유로 부분 통과에 그쳤다.

첫째, @ 우회가 가능하다. URL에서 @는 userinfo 구분자다. http://[email protected]/path처럼 쓰면 단순 문자열 파싱으로 뽑아낸 host가 trusted.example.com이 되고, 실제 접속은 10.0.0.1로 간다. 내가 짠 extractHost:// 이후 첫 슬래시 이전을 host로 뽑으면 [email protected] 전체가 host로 잡히지만, 또 다른 파싱 방법에 따라 다르게 동작할 수 있다.

둘째, DNS rebinding이 있다. 검증 시점에 외부 IP로 응답하던 도메인이, 실제 연결 시점에는 내부 IP로 응답할 수 있다. 정규식으로 host를 체크해도 실제 연결 목적지가 내부망이 되는 상황을 막지 못한다.

SonarQube도 문자열 기반 파싱으로 host를 뽑아 검증하는 패턴은 신뢰하지 않는다. S5144는 계속 남아 있었다.

시도 3. URI 파싱 + host 화이트리스트 + 내부 IP 차단 — 최종

SonarQube가 S5144를 통과시키는 조건을 다시 확인했다. 핵심은 두 가지였다. 첫째, 외부 입력 URL을 직접 쓰지 않고 검증된 데이터만 연결에 사용할 것. 둘째, URI.create() + getHost()로 host를 표준 파싱할 것. 문자열 조작이 아니라 JDK의 URI 파서를 써야 @ 같은 우회가 방지된다.

거기에 내부 IP 대역 차단까지 합쳐서 최종 코드를 완성했다.

// WebhookSafeUrlValidator.java — URI 파싱 + 화이트리스트 + 내부 IP 차단
@Component
public class WebhookSafeUrlValidator {

    // 허용할 외부 도메인 화이트리스트
    private static final Set<String> ALLOWED_HOSTS = Set.of(
        "api.payment-gateway.com",
        "webhook.fin-partner.co.kr",
        "notify.external-bank.com"
    );

    // 내부 IP 대역 패턴 (10.x, 192.168.x, 127.x, 169.254.x — link-local)
    private static final List<String> BLOCKED_PREFIXES = List.of(
        "10.", "192.168.", "127.", "169.254.",
        "172.16.", "172.17.", "172.18.", "172.19.",
        "172.20.", "172.21.", "172.22.", "172.23.",
        "172.24.", "172.25.", "172.26.", "172.27.",
        "172.28.", "172.29.", "172.30.", "172.31.",
        "0.", "::1", "fc", "fd"  // IPv6 루프백 및 ULA
    );

    /**
     * 외부 입력 URL을 검증하고, 통과 시 안전한 URI 객체를 반환한다.
     * SonarQube S5144: 이 메서드를 통과한 URI만 연결에 사용할 것.
     */
    public URI validateAndParse(String rawUrl) {
        if (rawUrl == null || rawUrl.isBlank()) {
            throw new IllegalArgumentException("URL이 비어 있다");
        }

        URI uri;
        try {
            // URI.create()는 RFC 3986 규격으로 파싱 — @ 우회 방지
            uri = URI.create(rawUrl);
        } catch (IllegalArgumentException e) {
            throw new SecurityException("URL 파싱 실패: " + rawUrl, e);
        }

        // 1. 스킴 검증
        String scheme = uri.getScheme();
        if (!"https".equalsIgnoreCase(scheme) && !"http".equalsIgnoreCase(scheme)) {
            throw new SecurityException("허용되지 않는 스킴: " + scheme);
        }

        // 2. host 추출 — URI.getHost()는 userinfo(@)를 제거한 순수 host
        String host = uri.getHost();
        if (host == null || host.isBlank()) {
            throw new SecurityException("host 추출 실패");
        }
        host = host.toLowerCase();

        // 3. 내부 IP 대역 차단
        for (String prefix : BLOCKED_PREFIXES) {
            if (host.startsWith(prefix)) {
                throw new SecurityException("내부 IP 차단: " + host);
            }
        }
        // localhost 명시 차단
        if ("localhost".equals(host) || "0.0.0.0".equals(host)) {
            throw new SecurityException("내부 호스트 차단: " + host);
        }

        // 4. 호스트 화이트리스트 검증
        if (!ALLOWED_HOSTS.contains(host)) {
            throw new SecurityException("허용되지 않는 host: " + host);
        }

        return uri;
    }
}
// WebhookCallbackHandler.java — 검증기 적용 후 최종 코드
@Service
public class WebhookCallbackHandler {

    @Autowired
    private WebhookSafeUrlValidator urlValidator;

    public String fetchCallbackResult(String callbackUrl) throws IOException {
        // 외부 입력 URL 검증 — 통과 시 안전한 URI 반환
        URI safeUri = urlValidator.validateAndParse(callbackUrl);

        // safeUri.toURL()을 사용 — 외부 입력 문자열을 직접 넘기지 않음
        HttpURLConnection conn = (HttpURLConnection) safeUri.toURL().openConnection();
        conn.setRequestMethod("GET");
        conn.setConnectTimeout(5_000);
        conn.setReadTimeout(10_000);

        int status = conn.getResponseCode();
        if (status != 200) {
            throw new IOException("응답 코드 오류: " + status);
        }

        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(conn.getInputStream()))) {
            StringBuilder sb = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                sb.append(line);
            }
            return sb.toString();
        } finally {
            conn.disconnect();
        }
    }
}

이 구조로 S5144 7건이 전부 해소됐다. 핵심은 세 가지다.

  1. URI.create() + getHost() — JDK 표준 파서를 써서 @ userinfo 우회를 원천 차단한다. uri.getHost()는 항상 순수한 host 부분만 반환한다.
  2. 내부 IP 대역 전방 차단10., 192.168., 127., 169.254.(link-local), 172.16~31.(RFC 1918), localhost, 0.0.0.0 전부 차단한다. 하나라도 빠지면 우회가 가능하다.
  3. host 화이트리스트 — 차단 목록보다 허용 목록이 더 안전하다. 알고 있는 외부 파트너 도메인만 통과시키고, 나머지는 전부 차단이다.

RestTemplate, Apache HttpClient 적용

같은 검증기를 다른 두 패턴에도 그대로 적용했다.

// PaymentVerificationService.java — RestTemplate 케이스
@Service
public class PaymentVerificationService {

    @Autowired
    private RestTemplate restTemplate;

    @Autowired
    private WebhookSafeUrlValidator urlValidator;

    public PaymentResult verify(WebhookPayload payload) {
        // 검증 통과 후 URI 문자열로 변환해 RestTemplate에 전달
        URI safeUri = urlValidator.validateAndParse(payload.getVerifyUrl());
        return restTemplate.getForObject(safeUri, PaymentResult.class);
    }
}
// ExternalNotificationClient.java — Apache HttpClient 케이스
@Component
public class ExternalNotificationClient {

    private final CloseableHttpClient httpClient = HttpClients.createDefault();

    @Autowired
    private WebhookSafeUrlValidator urlValidator;

    public String call(String rawUrl) throws IOException {
        URI safeUri = urlValidator.validateAndParse(rawUrl);
        HttpGet request = new HttpGet(safeUri);
        try (CloseableHttpResponse response = httpClient.execute(request)) {
            return EntityUtils.toString(response.getEntity());
        }
    }
}

WebhookSafeUrlValidator를 Bean으로 만들어두면 세 곳 모두 같은 검증 로직을 공유한다. 화이트리스트에 파트너 도메인이 추가될 때 한 곳만 수정하면 된다.

화이트리스트가 어렵다면 — endpoint 매핑 방식

외부 파트너가 많아서 화이트리스트 관리가 어렵다면, URL 전체를 받지 말고 key만 받는 방식도 있다.

// endpoint 매핑 방식 — 외부 입력은 key만 받음
@Component
public class WebhookEndpointRegistry {

    private static final Map<String, String> ENDPOINT_MAP = Map.of(
        "PAYMENT_GW_A", "https://api.payment-gateway.com/verify",
        "PAYMENT_GW_B", "https://webhook.fin-partner.co.kr/confirm",
        "BANK_NOTIFY",  "https://notify.external-bank.com/callback"
    );

    public String resolve(String endpointKey) {
        String url = ENDPOINT_MAP.get(endpointKey);
        if (url == null) {
            throw new SecurityException("알 수 없는 endpoint key: " + endpointKey);
        }
        return url;  // 외부 입력이 아닌 서버 내부 상수
    }
}

이 방식은 SonarQube taint 분석 관점에서 가장 깔끔하다. 연결에 사용하는 URL 자체가 외부 입력이 아니라 서버 내부의 상수이기 때문에 데이터 플로우가 끊긴다. 파트너 수가 제한적이라면 이 방식을 우선 고려할 만하다.

정리

SonarQube SSRF(S5144)의 본질은 서버가 신뢰할 수 없는 URL로 직접 네트워크 요청을 보내는 것을 막는 거다. 프로토콜 체크만으로는 내부 IP 우회를 못 막고, 문자열 기반 host 파싱은 @ 우회에 취약하다. URI.create() + getHost()로 표준 파싱하고, 내부 IP 대역(10.x, 192.168.x, 127.x, 169.254.x link-local 포함)을 전방 차단한 뒤, host 화이트리스트로 허용 목록만 통과시켜야 S5144가 잡힌다.

이 시리즈는 Hardcoded Credentials(S2068), Insecure Deserialization(S5135), ReDoS(S5852)에 이어 진행 중이다. 다음 편은 SonarQube LDAP Injection 룰 S2078 — DirContext 필터 문자열을 그대로 합쳤다가 터진 케이스를 정리할 예정이다.

룰 본문은 SonarSource 공식 룰 페이지(S5144)에서 확인할 수 있다.