이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- URI, URL, origin, Host, path, query, fragment를 어떻게 구분하는가?
- URL 인코딩과 정규화 차이가 라우팅, 서명 검증, redirect 장애로 이어지는 이유는 무엇인가?
- 백엔드에서 redirect URL, callback URL, forwarded header를 안전하게 다루려면 무엇을 확인해야 하는가?
개요
URI는 리소스를 식별하는 문법이고, URL은 그 리소스에 접근할 위치와 방법까지 포함하는 식별자다. 실무에서는 이 차이보다 더 자주 중요한 것이 scheme, host, port, path, query, fragment, 그리고 HTTP Host header가 각각 어느 계층에서 쓰이는지다.
- URI 구조를 알아야 라우팅, redirect, canonical URL, callback URL을 안전하게 다룰 수 있다.
- scheme, host, port는 origin과 TLS 인증서, CORS, cookie 범위를 결정한다.
- path와 query는 라우팅, 캐시 키, 서명 검증, 검색/필터 조건을 결정한다.
- fragment는 브라우저 내부에서만 쓰이며 서버로 전송되지 않는다.
왜 백엔드 개발자가 알아야 하는가
백엔드 개발자는 URL을 문자열로만 다루면 안 된다. URL 파싱과 정규화 방식은 보안, 라우팅, 캐시, 외부 연동의 계약이 된다.
- OAuth
redirect_uri검증이 느슨하면 open redirect나 계정 탈취 경로가 생긴다. - CDN이나 reverse proxy가
/%2F,//,.., trailing slash를 앱과 다르게 정규화하면 우회 라우팅이 생길 수 있다. - query string에 token, email, phone number를 넣으면 access log, browser history, referrer로 새어 나간다.
- 외부 API 서명은 path/query의 인코딩, 정렬, 대소문자 차이만으로도 실패한다.
요청 흐름에서의 위치
URL은 브라우저, 모바일 앱, SDK, proxy, application 사이에서 계속 해석된다.
사용자 입력 URL
→ DNS 조회 대상 host
→ TLS SNI / 인증서 검증 host
→ HTTP Host header
→ reverse proxy virtual host
→ application route path/query
→ redirect/callback/canonical URL 생성같은 문자열이라도 계층마다 해석 지점이 다르다. 장애 분석에서는 “브라우저 주소창 URL”, “proxy가 받은 request URI”, “앱 프레임워크가 decode한 path”를 구분해야 한다.
원리
일반적인 URL은 다음 조각으로 나뉜다.
https://api.example.com:443/orders/123?include=items#section
└─scheme └────host─────└port└──path───└──query───└fragmentHTTP 요청에서 서버가 실제로 받는 request target은 보통 path와 query다. fragment는 HTTP 요청에 포함되지 않는다. #access_token=... 같은 값은 서버 access log에는 보통 남지 않지만, 브라우저 내부 script나 history에는 영향을 줄 수 있다.
origin은 scheme + host + port 조합이다. https://example.com과 http://example.com은 같은 host라도 다른 origin이다. CORS, cookie, service worker, mixed content 판단에서 origin 구분이 중요하다.
URL 인코딩은 더 조심해야 한다. %2F는 slash를 percent-encoding한 값이고, +는 URL 전체 문법이 아니라 application/x-www-form-urlencoded 문맥에서 space처럼 해석될 수 있다. “한 번 decode한 값”과 “두 번 decode한 값”이 달라지는 순간 path traversal, auth bypass, 서명 검증 실패가 생긴다.
핵심 판단 기준
- redirect나 callback URL은 문자열 prefix 비교가 아니라 파싱된
scheme,host,port,path기준으로 allowlist 검증한다. - 외부 입력 URL로 서버가 직접 요청을 보내면 SSRF 가능성을 검토한다. DNS rebinding, private IP, link-local IP, redirect 추적을 함께 막아야 한다.
- proxy 뒤의 앱은
X-Forwarded-Proto,X-Forwarded-Host,Forwardedheader를 무조건 신뢰하지 않는다. 신뢰 가능한 proxy에서 온 요청인지 먼저 확인한다. - access log에는 raw request URI와 decoded route parameter 중 무엇이 남는지 알고 있어야 한다.
- canonical URL 정책은 trailing slash, 대소문자, query parameter 순서, http-to-https redirect를 포함해 명시한다.
실무에서 자주 만나는 문제
- redirect URL 검증이 약해 open redirect가 생긴다.
- query string에 민감 정보를 넣어 로그와 referrer에 남긴다.
- URL encoding 차이로 서명 검증이나 라우팅이 깨진다.
- proxy는
/api/%2e%2e/admin을 통과시키고 앱은 decode 후/admin으로 해석한다. - NGINX
proxy_pass에 trailing slash를 잘못 붙여 upstream path가 의도와 다르게 바뀐다. - 앱이
Hostheader로 absolute URL을 생성해 password reset link가 공격자 도메인으로 만들어진다. - CDN cache key가 query parameter를 무시해 사용자별 응답이 섞인다.
이 문제들은 “서버는 200을 줬는데 사용자는 잘못된 곳으로 갔다”, “외부 API 서명만 실패한다”, “특정 path만 404다”처럼 드러난다. status code보다 원본 URL과 각 계층의 변환 결과가 핵심 증거다.
디버깅 방법
curl -v로 request target, Host, redirectLocation을 확인한다.curl --path-as-is로 client가 보낸 path를 curl이 정규화하지 않게 한다.- proxy access log의
$request_uri와 앱 log의 decoded path를 비교한다. - redirect는
curl -I와curl -L -v를 나누어 본다. 바로 따라가면 중간Location을 놓치기 쉽다. - OAuth/callback 문제는 등록된 redirect URI와 실제 요청 URI를 byte 단위로 비교한다.
curl -v --path-as-is 'https://example.com/api/%2e%2e/admin?next=https%3A%2F%2Fevil.example'
curl -I 'https://example.com/login?next=https://evil.example'
curl -L -v 'https://example.com/old-path'관찰할 것은 응답 code 자체가 아니라 Location, Set-Cookie, Cache-Control, Vary, X-Request-ID, proxy log의 raw URI다.
코드로 확인하기
redirect allowlist는 문자열 prefix 비교로 끝내지 않는다.
import java.net.URI;
import java.util.Set;
public final class Redirects {
private static final Set<String> ALLOWED_HOSTS = Set.of("example.com", "app.example.com");
public static URI safeRedirect(String input) {
URI uri = URI.create(input).normalize();
if (!"https".equalsIgnoreCase(uri.getScheme())) {
throw new IllegalArgumentException("only https redirect is allowed");
}
if (!ALLOWED_HOSTS.contains(uri.getHost())) {
throw new IllegalArgumentException("untrusted redirect host");
}
if (uri.getRawUserInfo() != null) {
throw new IllegalArgumentException("userinfo is not allowed");
}
return uri;
}
}운영에서는 아래처럼 실패 사례를 테스트에 넣는다.
https://example.com.evil.test/callback
https://example.com@evil.test/callback
http://example.com/callback
https://example.com/%2e%2e/admin이 코드는 예시일 뿐이다. 실제 서비스에서는 국제화 도메인, default port, path allowlist, redirect follow 금지, private IP 차단까지 요구될 수 있다.
주니어가 자주 하는 오해
- URL을 그냥 문자열이라고 생각한다.
- URL은 계층마다 다르게 파싱되고 정규화된다.
- query string은 body보다 가볍고 안전하다고 생각한다.
- query는 로그, 히스토리, referrer에 남기 쉽다.
startsWith("https://example.com")이면 redirect 검증이 충분하다고 생각한다.https://example.com.evil.test같은 값이 통과할 수 있다.
- fragment를 서버에서 받을 수 있다고 생각한다.
- fragment는 일반 HTTP 요청에 포함되지 않는다.
시니어의 설계 판단 기준
- public URL 생성은 한 곳에서만 담당하게 하고, proxy 뒤 absolute URL 생성 정책을 명시한다.
- path normalization 정책을 proxy, gateway, app framework에서 맞춘다.
- 보안 민감 endpoint는 encoded slash, double decode, dot segment를 테스트에 포함한다.
- 캐시 키와 서명 대상 문자열을 문서화한다.
- URL 입력을 받는 기능은 SSRF, open redirect, credential leak를 threat modeling에 넣는다.
인프라 협업 포인트
- NGINX/Envoy/Gateway가 raw URI를 보존하는지, normalize하는지 확인한다.
Host,X-Forwarded-Host,X-Forwarded-Proto,Forwarded중 앱이 무엇을 신뢰할지 합의한다.- proxy access log에
$request_uri,$uri, upstream status, request id가 남는지 확인한다. - redirect loop가 나면 앱 redirect인지, LB http-to-https redirect인지, CDN rule인지 변경 이력을 함께 본다.
실전 팁
- 운영 로그에는 가능하면 raw request URI, route pattern, request id를 분리해서 남긴다.
- 서명 검증이 있는 API는 “정규화 전 문자열”과 “검증에 사용한 canonical string”을 debug level에서 남길 수 있게 한다.
- URL 길이가 긴 요청은 proxy, WAF, app server의 request line/header size 제한을 함께 확인한다.
- 외부에 공유되는 URL은 한번 발급하면 오래 살아남는다. path 변경에는 redirect, canonical, deprecation 기간이 필요하다.
위험 신호!
- redirect URL을 문자열 prefix로 검증한다.
- query string에 token, 주민번호, 전화번호, 이메일 같은 민감 값이 들어간다.
- proxy와 app의 path normalize 정책을 아무도 설명하지 못한다.
- access log에서 원본 URL과 앱 route parameter를 연결할 수 없다.
- CDN cache key 정책과 앱 query parameter 의미가 따로 논다.
확인 질문
확인 질문
- URL 문제를 디버깅할 때 raw URI와 decoded path를 구분해야 하는 이유는 무엇인가?
- proxy, gateway, app framework가 서로 다른 시점에 decode하거나 normalize하면 실제 라우팅 결과가 달라질 수 있기 때문이다.
- fragment가 서버 access log에 남지 않는 이유는 무엇인가?
- fragment는 브라우저 내부에서 해석되며 일반 HTTP 요청으로 서버에 전송되지 않기 때문이다.
- redirect URL 검증에서
startsWith가 위험한 이유는 무엇인가?
- 공격자가 비슷한 prefix를 가진 다른 host나 userinfo 문법을 이용해 allowlist를 우회할 수 있기 때문이다.