이 글에 등장하는 코드는 금융권 보안 정책상 실제 프로젝트 코드를 그대로 공개할 수 없어, 동일한 취약 패턴과 해결 흐름을 재현한 샘플로 대체했다. 클래스명·테이블명·경로 같은 식별자는 가상이지만, SonarQube가 잡는 데이터 플로우와 통과 조건은 실제 환경과 같다.
📑 목차
SonarQube XXE(S2755)를 정리하고 며칠 뒤, 이번엔 SonarQube Open Redirect 룰 S5146이 12건 터졌다. “HTTP request redirections should not be open to forging attacks”라는 메시지였다. CWE-601에 해당하는 취약점이다.
문제가 터진 건 SSO 로그인 완료 후 원래 접근하려던 페이지로 돌려보내는 코드였다. 금융권 포털에서 자주 쓰는 패턴 — 인증 전 요청 URL을 세션에 저장해뒀다가 로그인 성공 시 sendRedirect로 그리로 보내는 방식이다. 문제는 그 URL을 쿼리 파라미터로도 받을 수 있게 열어뒀다는 것이다. 누군가 ?returnUrl=https://phishing.example.com을 붙이면 로그인 후 외부 피싱 사이트로 리다이렉트된다. SonarQube Open Redirect는 이 데이터 플로우를 그대로 잡고 있었다.
SonarQube XSS(S5131)처럼 출력 인코딩 하나로 끝나는 룰이 아니라, 리다이렉트 목적지 자체를 제한해야 하는 룰이라 시행착오가 더 많았다. S5146을 통과시키기까지 3가지 시도 과정을 기록한다.

레거시에서 발견된 패턴
12건을 분류하니 세 가지 패턴이었다.
패턴 1. Servlet — response.sendRedirect 직접 사용
// SsoLoginServlet.java
@WebServlet("/sso/login")
public class SsoLoginServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String returnUrl = request.getParameter("returnUrl"); // S5146 여기서 잡힘
// ... 인증 처리 로직 ...
if (returnUrl != null && !returnUrl.isEmpty()) {
response.sendRedirect(returnUrl); // 외부 입력이 그대로 리다이렉트 목적지로
} else {
response.sendRedirect("/portal/main");
}
}
}
제일 직관적인 케이스다. request.getParameter("returnUrl")이 그대로 sendRedirect에 들어가면 S5146이 뜬다. SonarQube는 외부 입력(getParameter, getHeader, getAttribute)이 리다이렉트 대상으로 이어지는 데이터 플로우를 추적한다.
패턴 2. Spring MVC — redirect: 접두사
// PortalLoginController.java
@Controller
public class PortalLoginController {
@PostMapping("/portal/auth")
public String processLogin(
@RequestParam(value = "next", defaultValue = "/portal/main") String next,
HttpSession session) {
// ... 인증 처리 ...
session.invalidate();
return "redirect:" + next; // S5146 여기서 잡힘
}
}
Spring MVC에서 "redirect:" + 외부입력 패턴도 동일하게 잡힌다. 컨트롤러 리턴값이 리다이렉트 URL로 직결되는 흐름이다.
패턴 3. JSP — c:redirect
<%-- redirectAfterAuth.jsp --%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<c:redirect url="${param.next}"/>
JSP에서 JSTL <c:redirect>에 ${param.next}를 그대로 쓰는 경우도 마찬가지다. param은 request.getParameter와 동일하게 외부 입력으로 처리된다.
시도 1. javascript: 차단 — 실패
처음에 가장 먼저 떠오른 건 “피싱 공격에 쓰이는 javascript: 스킴부터 막자”였다. XSS 공격에서 javascript:를 URL로 쓰는 걸 많이 봤으니까.
// SsoLoginServlet.java — 첫 번째 시도
private static final String SAFE_DEFAULT = "/portal/main";
private String sanitizeReturnUrl(String url) {
if (url == null || url.isEmpty()) {
return SAFE_DEFAULT;
}
// javascript: 스킴 차단
if (url.toLowerCase().startsWith("javascript:")) {
return SAFE_DEFAULT;
}
return url;
}
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String returnUrl = sanitizeReturnUrl(request.getParameter("returnUrl"));
response.sendRedirect(returnUrl);
}
결과: S5146은 그대로 떠 있었다. 당연한 얘기다. javascript:만 막아봤자 우회가 너무 쉽다.
https://evil.com— 그냥 외부 URL//evil.com/steal— 프로토콜 상대 URL (현재 도메인 프로토콜 상속)data:text/html,<script>...</script>— data URIJAVASCRIPT:— 대소문자 변형 (toLowerCase로 막긴 했지만 나머지는 그대로)
SonarQube는 특정 스킴을 차단하는 방식을 “신뢰할 수 있는 sanitizer”로 인식하지 않는다. 블랙리스트 방식이라 우회 가능성이 있기 때문이다. SQL Injection(S3649)에서 작은따옴표 escape가 통과 안 됐던 것과 같은 맥락이다. SonarQube Open Redirect 룰은 스킴 필터링 한 줄 추가한다고 사라지지 않는다. 30분 날렸다.
시도 2. host 검증 — 부분 통과
다음 시도는 “같은 도메인인지 host를 비교하자”였다. java.net.URI로 파싱해서 host가 우리 도메인이면 허용하는 방식이다.
// SsoLoginServlet.java — 두 번째 시도
private static final String ALLOWED_HOST = "portal.finco.internal";
private static final String SAFE_DEFAULT = "/portal/main";
private String sanitizeReturnUrl(String url, HttpServletRequest request) {
if (url == null || url.isEmpty()) {
return SAFE_DEFAULT;
}
try {
URI uri = new URI(url);
String host = uri.getHost();
// host가 없으면 상대 경로 — 허용
if (host == null) {
return url;
}
// host가 허용된 도메인이면 허용
if (ALLOWED_HOST.equalsIgnoreCase(host)) {
return url;
}
} catch (URISyntaxException e) {
// 파싱 실패 — 기본값으로
}
return SAFE_DEFAULT;
}
일부 케이스는 해소됐다. 그런데 SonarQube S5146은 아직 남아 있었고, 실제로도 우회 가능한 구멍이 있었다.
구멍 1. URI 파싱 실수
java.net.URI는 스펙에 따라 일부 URL을 예상과 다르게 파싱한다. 예를 들어 ///evil.com/path는 URI가 path를 /evil.com/path로 읽어서 host가 null이 되고, 상대 경로로 취급돼 허용된다. 그런데 브라우저는 이걸 evil.com으로 리다이렉트할 수 있다.
구멍 2. @ 우회
// URL: https://[email protected]/
// URI.getHost() 결과: evil.com
// URI.getUserInfo() 결과: portal.finco.internal
@가 들어간 URL에서 URI.getHost()는 @ 뒤를 host로 반환한다. 즉 https://[email protected]/이면 host가 evil.com이 된다. 내 코드에서는 이걸 ALLOWED_HOST와 비교하기 때문에 차단하지만, 반대로 코드가 url.contains(ALLOWED_HOST) 같은 식으로 짜여 있었다면 우회가 됐다. 레거시에서 실제로 이런 패턴이 있었다.
SonarQube Open Redirect가 이 시도를 통과시키지 않은 이유는 uri.getHost() 비교만으로는 데이터 플로우가 완전히 끊기지 않기 때문이다. 외부 입력이 여전히 sendRedirect에 닿을 수 있다는 걸 SonarQube가 인식한다. Path Traversal(S2083)에서 정규식 검증만으로는 부족했던 것과 비슷한 상황이다.
시도 3. 화이트리스트 + 상대 경로 검증 — 거의 통과
방향을 바꿨다. “외부 입력 URL 자체를 신뢰하지 말고, 허용 경로 목록을 직접 관리하자”는 접근이다.
// SsoLoginServlet.java — 세 번째 시도
private static final Set<String> ALLOWED_PATHS = Set.of(
"/portal/main",
"/portal/dashboard",
"/portal/account/list",
"/portal/report/monthly",
"/portal/settings"
);
private String sanitizeReturnUrl(String url) {
if (url == null || url.isEmpty()) {
return "/portal/main";
}
// 절대 URL 전부 거부 — http/https/프로토콜 상대 URL
if (url.startsWith("http://") || url.startsWith("https://")
|| url.startsWith("//")) {
return "/portal/main";
}
// 상대 경로인지 확인 — /로 시작해야 함
if (!url.startsWith("/")) {
return "/portal/main";
}
// 화이트리스트 매칭
if (ALLOWED_PATHS.contains(url)) {
return url;
}
return "/portal/main";
}
이 접근으로 12건 중 8건이 통과됐다. 그런데 두 가지 문제가 남았다.
문제 1. 쿼리 파라미터 있는 URL
/portal/account/list?page=2&sort=date처럼 쿼리 스트링이 붙은 URL은 Set 매칭에서 실패한다. 경로 부분만 추출해서 비교해야 한다.
문제 2. Spring MVC 컨트롤러에서 남은 건
"redirect:" + next 패턴은 String을 직접 조합해서 SonarQube가 데이터 플로우를 여전히 추적한다. sanitizeReturnUrl을 거쳤더라도 그 반환값이 redirect:에 합쳐지면 S5146이 남는다.
최종 해결 — 화이트리스트 매핑 + URI 파싱
두 가지를 추가했다. 경로만 추출해서 비교하고, Spring 컨트롤러에서는 외부 입력을 key로만 쓰고 실제 URL은 Map에서 꺼내는 방식이다.
1) 공통 유틸 — URI 파싱 + 경로 화이트리스트
// RedirectSafetyUtil.java
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Set;
public final class RedirectSafetyUtil {
private static final String SAFE_DEFAULT = "/portal/main";
// 허용 경로 목록 (쿼리 파라미터 제외한 경로만)
private static final Set<String> ALLOWED_PATHS = Set.of(
"/portal/main",
"/portal/dashboard",
"/portal/account/list",
"/portal/account/detail",
"/portal/report/monthly",
"/portal/report/daily",
"/portal/settings",
"/portal/notice/list"
);
private RedirectSafetyUtil() {}
/**
* 외부 입력 URL을 검증하고 안전한 리다이렉트 대상을 반환한다.
* 절대 URL, 프로토콜 상대 URL은 모두 SAFE_DEFAULT로 대체.
* 상대 경로 중 ALLOWED_PATHS에 없는 경로도 SAFE_DEFAULT.
*/
public static String sanitize(String url) {
if (url == null || url.trim().isEmpty()) {
return SAFE_DEFAULT;
}
// 절대 URL 및 프로토콜 상대 URL 차단
// URI.isAbsolute()는 스킴이 있으면 true
try {
URI uri = URI.create(url.trim());
if (uri.isAbsolute()) {
// 스킴이 있는 URL — 외부 이동 가능성 있으므로 모두 거부
return SAFE_DEFAULT;
}
} catch (IllegalArgumentException e) {
return SAFE_DEFAULT;
}
// // 로 시작하는 프로토콜 상대 URL 차단 (스킴 없어도 외부 이동)
if (url.startsWith("//")) {
return SAFE_DEFAULT;
}
// 상대 경로만 허용 — 반드시 /로 시작
if (!url.startsWith("/")) {
return SAFE_DEFAULT;
}
// 쿼리 파라미터와 프래그먼트 제거 후 경로만 비교
String path = url;
int queryIdx = url.indexOf('?');
if (queryIdx != -1) {
path = url.substring(0, queryIdx);
}
int fragmentIdx = path.indexOf('#');
if (fragmentIdx != -1) {
path = path.substring(0, fragmentIdx);
}
// path traversal 방지 — ../ 패턴 차단
if (path.contains("..")) {
return SAFE_DEFAULT;
}
// 화이트리스트 매칭
if (ALLOWED_PATHS.contains(path)) {
return url; // 원본 URL(쿼리 포함) 반환
}
return SAFE_DEFAULT;
}
}
2) Servlet — sanitize 후 sendRedirect
// SsoLoginServlet.java — 최종
@WebServlet("/sso/login")
public class SsoLoginServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String rawReturnUrl = request.getParameter("returnUrl");
String safeUrl = RedirectSafetyUtil.sanitize(rawReturnUrl);
// ... 인증 처리 ...
response.sendRedirect(safeUrl);
}
}
3) Spring MVC — Map 매핑으로 외부 입력 차단
Spring 컨트롤러에서 "redirect:" + 외부입력 패턴은 sanitize를 거쳐도 SonarQube가 데이터 플로우를 추적하는 경우가 있다. 가장 확실한 방법은 외부 입력을 키로만 쓰고 실제 URL은 서버 측 Map에서 꺼내는 것이다.
// PortalLoginController.java — 최종
@Controller
public class PortalLoginController {
// 허용 리다이렉트 매핑 — 외부 입력은 키만 받고 URL은 여기서 꺼냄
private static final Map<String, String> REDIRECT_MAP = Map.of(
"main", "/portal/main",
"dashboard", "/portal/dashboard",
"account-list", "/portal/account/list",
"report-monthly", "/portal/report/monthly",
"settings", "/portal/settings"
);
private static final String DEFAULT_REDIRECT = "redirect:/portal/main";
@PostMapping("/portal/auth")
public String processLogin(
@RequestParam(value = "next", required = false) String next,
HttpSession session) {
// ... 인증 처리 ...
session.invalidate();
// 외부 입력(next)은 Map 키로만 사용 — URL이 직접 리다이렉트로 가지 않음
if (next != null && REDIRECT_MAP.containsKey(next)) {
return "redirect:" + REDIRECT_MAP.get(next);
}
return DEFAULT_REDIRECT;
}
}
이 패턴의 핵심은 REDIRECT_MAP.get(next)가 Map에서 꺼낸 값이지 외부 입력 자체가 아니라는 점이다. SonarQube는 Map 조회 결과를 외부 입력으로 추적하지 않기 때문에 데이터 플로우가 끊긴다.
4) JSP — c:redirect 완전 제거
<%-- redirectAfterAuth.jsp — 최종 --%>
<%@ page contentType="text/html;charset=UTF-8" %>
<%@ page import="com.finco.portal.util.RedirectSafetyUtil" %>
<%
String rawNext = request.getParameter("next");
String safeNext = RedirectSafetyUtil.sanitize(rawNext);
response.sendRedirect(safeNext);
return;
%>
JSP에서 JSTL <c:redirect>를 쓰는 경우, 태그 속성에 EL 표현식이 들어가면 SonarQube가 여전히 잡는다. Java scriptlet으로 바꿔서 RedirectSafetyUtil.sanitize를 거치면 통과된다. 레거시 JSP에서 scriptlet을 쓰는 게 좋은 코드는 아니지만, 이 케이스에서는 SonarQube 통과 조건상 불가피했다.
핵심 정리 3가지
- 블랙리스트는 안 된다 —
javascript:,data:를 막아도//evil.com,@우회 등 변형이 많다. SonarQube도 통과시키지 않는다. - URI 파싱만으로는 부족하다 —
uri.getHost()비교는@우회나 이상한 URI 포맷에 취약하다.uri.isAbsolute()+ 상대경로 화이트리스트 조합이 더 확실하다. - Spring MVC에서는 Map 매핑이 제일 깔끔하다 — 외부 입력이 키가 되고 리다이렉트 URL은 서버 측 상수에서 꺼내면 데이터 플로우가 끊긴다. SQL Injection에서 enum 화이트리스트로 동적 컬럼을 처리한 것과 같은 패턴이다.
정리
SonarQube Open Redirect(S5146)의 본질은 외부 입력이 리다이렉트 목적지가 되는 데이터 플로우를 끊는 것이다. 스킴 차단이나 host 검증 같은 부분적 방어는 우회 가능성이 있고 SonarQube도 통과시키지 않는다. URI.isAbsolute()로 절대 URL을 전부 거부하고, 상대 경로는 경로만 추출해 화이트리스트와 비교하거나, Spring 컨트롤러에서는 Map 매핑으로 데이터 플로우 자체를 끊는 게 결론이었다.
이 시리즈에서 앞서 다룬 Command Injection(S2076), XXE(S2755), Path Traversal(S2083)은 각각 “외부 입력이 시스템 명령/XML 파서/파일 경로에 닿지 않게 하는 것”이 핵심이었다. SonarQube Open Redirect도 같은 원리 — 외부 입력이 신뢰할 수 없는 형태로 민감한 API(sendRedirect)에 닿지 않게 하면 된다.
다음 편은 SonarQube Insecure Cookie(S2092/S3330) — 세션 쿠키에서 Secure, HttpOnly 플래그 미설정으로 Security Hotspot이 터진 케이스다.
룰 본문은 SonarSource 공식 룰 페이지(S5146)에서 확인할 수 있다.