이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • 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───└fragment

HTTP 요청에서 서버가 실제로 받는 request target은 보통 path와 query다. fragment는 HTTP 요청에 포함되지 않는다. #access_token=... 같은 값은 서버 access log에는 보통 남지 않지만, 브라우저 내부 script나 history에는 영향을 줄 수 있다.

origin은 scheme + host + port 조합이다. https://example.comhttp://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, Forwarded header를 무조건 신뢰하지 않는다. 신뢰 가능한 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가 의도와 다르게 바뀐다.
  • 앱이 Host header로 absolute URL을 생성해 password reset link가 공격자 도메인으로 만들어진다.
  • CDN cache key가 query parameter를 무시해 사용자별 응답이 섞인다.

이 문제들은 “서버는 200을 줬는데 사용자는 잘못된 곳으로 갔다”, “외부 API 서명만 실패한다”, “특정 path만 404다”처럼 드러난다. status code보다 원본 URL과 각 계층의 변환 결과가 핵심 증거다.

디버깅 방법

  • curl -v로 request target, Host, redirect Location을 확인한다.
  • curl --path-as-is로 client가 보낸 path를 curl이 정규화하지 않게 한다.
  • proxy access log의 $request_uri와 앱 log의 decoded path를 비교한다.
  • redirect는 curl -Icurl -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를 우회할 수 있기 때문이다.

참고 문서