본문 바로가기
빌드노트 검색

빌드노트

curl에 넣은 /a/../b가 서버에는 /b로 도착한다

curl은 기본적으로 URL의 점 경로를 전송 전에 정리한다. 세 경로를 기본 조건과 --path-as-is로 보내 실제 수신 요청 줄을 비교하고, curl의 처리와 서버의 후속 해석을 구분한다.

curl로 http://127.0.0.1:포트/a/../b를 요청했는데 서버에는 /b가 기록된다면, curl이 전송하기 전에 경로를 정리했을 수 있다. curl은 기본적으로 URL 경로의 점 구간을 정리한다. 점 구간을 포함한 입력으로 서버의 처리를 비교하려면 그 요청에 --path-as-is를 추가한다.

curl 공식 매뉴얼의 --path-as-is (새 탭에서 열림)는 URL 경로에 있는 /../와 /./를 curl이 정리하지 않도록 하는 옵션이다. RFC 3986의 점 구간 제거 절차 (새 탭에서 열림)에서는 경로 구간 전체가 .이면 그 구간을 없애고, ..이면 앞 구간도 함께 제거한다. 따라서 /a/./b는 /a/b로, /a/../b는 /b로 정리된다. 이것은 URI 경로 처리 규칙이므로 디스크에 a라는 폴더가 실제로 있어야 하는 동작은 아니다.

같은 URL에서 옵션 하나만 바꿔 본다

Windows PowerShell과 실제 curl.exe를 사용한다. 준비할 대상은 자신이 실행 중인 loopback HTTP 테스트 서버다. 파일을 제공하지 않는 테스트 핸들러에 합성 경로만 보내고, 가능하면 프레임워크가 해석하기 전 요청 줄도 기록한다. 아래 $base는 그 서버의 실제 주소로 바꾼다. 25061은 이번 실험에서 쓴 임시 포트이며 수신기는 이미 종료했다.

다음 두 명령으로 확인할 것은 응답 내용보다 상세 로그의 > GET ... HTTP/1.1 줄이다.

$base = 'http://127.0.0.1:25061'
curl.exe -q --noproxy '*' --http1.1 --connect-timeout 2 --max-time 3 -sS -v "$base/a/../b"
curl.exe -q --noproxy '*' --http1.1 --connect-timeout 2 --max-time 3 -sS -v --path-as-is "$base/a/../b"

-q를 첫 옵션으로 둬 기본 curl 설정 파일의 영향을 제외하고, --noproxy '*'로 프록시를 거치지 않게 했다. --http1.1은 비교할 요청 형식을 고정한다. 연결에는 2초, 전송 전체에는 3초 제한을 뒀다. 이 조건은 로컬 경로 비교용이다. 기본 설정 파일 제외 (새 탭에서 열림), 프록시 제외 (새 탭에서 열림)

실제 실행에서 첫 명령의 요청 줄은 > GET /b HTTP/1.1, 두 번째는 > GET /a/../b HTTP/1.1이었다. -v의 >는 curl이 보내는 쪽의 정보를 표시한다. URL을 적은 명령행과 curl이 만든 요청 줄을 따로 읽어야 어느 단계에서 경로가 달라졌는지 좁힐 수 있다. 상세 로그 표기 (새 탭에서 열림)

옵션을 알 수 없다는 오류가 나오면 curl.exe -q --version으로 실제 실행 파일의 버전을 확인한다. 공식 옵션 추가 이력 (새 탭에서 열림)에 따르면 --path-as-is는 7.42.0부터 제공된다. 연결 거부나 시간 초과로 요청 줄을 확인하지 못했다면, 먼저 수신기 주소와 실행 상태를 확인한다. 그것만으로 경로 보존 여부를 판정할 수는 없다.

수신 바이트에도 같은 차이가 있었다

curl 쪽 로그만으로 끝내지 않고 새 TCP 수신기를 127.0.0.1에만 열었다. 수신기는 받은 HTTP 요청의 첫 줄을 바이트 그대로 기록하고, 그 줄을 응답 본문으로 돌려줬다. URL을 파싱하거나 경로를 파일로 연결하는 코드는 넣지 않았다. Windows 11, PowerShell 7.6.6, Python 3.12.10, curl 8.21.0에서 실행한 별도 실험이다.

두 명령의 경로를 /a/./b, 일반 경로 /a/b로도 바꿔 총 여섯 요청을 보냈다. 다음은 조건별로 묶은 실제 수신 요청 줄이다. GET과 HTTP/1.1 사이가 이번에 비교한 요청 대상(request target)이다.

입력 /a/../b
기본:         GET /b HTTP/1.1
--path-as-is: GET /a/../b HTTP/1.1

입력 /a/./b
기본:         GET /a/b HTTP/1.1
--path-as-is: GET /a/./b HTTP/1.1

입력 /a/b
기본:         GET /a/b HTTP/1.1
--path-as-is: GET /a/b HTTP/1.1

기본 조건에서는 수신기에 도착한 바이트부터 이미 점 구간이 없었다. 수신기가 경로를 정리해서 만든 기록이 아니다. 옵션을 추가하면 두 점 경로가 그대로 도착했고, 점 구간이 없는 /a/b는 두 조건에서 같았다. 클라이언트의 상세 로그와 수신한 첫 줄도 모두 일치했다.

여섯 요청 모두 HTTP 200과 curl 종료 코드 0으로 끝났다. 수신기가 받은 줄을 돌려주도록 만든 결과이며, 실제 애플리케이션의 라우팅 성공을 확인한 것은 아니다. 이 실험의 판단 근거는 상태 코드가 아니라 요청 대상의 차이다.

서버가 그 뒤에 해석하는 경로까지 고정하지는 않는다

--path-as-is는 curl이 수행하는 점 구간 정리를 멈춘다. 프록시나 HTTP 서버, 애플리케이션의 경로 해석 설정을 바꾸는 옵션은 아니다. 자기 로컬 서비스에서 비교할 때는 다음처럼 관찰 지점을 나눈다.

  • 기본 명령의 전송 로그에 이미 /b가 보이면, 그 요청은 점 구간이 남은 경로를 서버에 시험한 것이 아니다. 같은 입력에 옵션을 추가해 다시 비교한다.
  • 옵션을 넣은 전송 로그에는 /a/../b가 있는데 앱의 경로 값에는 /b가 보이면, 수신 원문과 앱의 경로 값 사이에서 어떤 처리가 있었는지 조사한다. 앱 로그가 파싱 후 값을 출력하는지도 확인한다. 이 차이만으로 특정 프레임워크나 프록시를 원인으로 확정할 수는 없다.
  • 점 경로가 도착한 뒤 서버가 요청을 거절한다면, 옵션으로 강제 수락시키는 것이 아니라 서버의 처리 결과를 관찰한 것이다. 정리된 경로와 같은 응답이 나온다고 해서 수신 원문까지 같았다고 판단하지 않는다.

이번 실험은 리다이렉트 없는 HTTP/1.1의 첫 GET 요청만 확인했다. libcurl의 PATH_AS_IS 문서 (새 탭에서 열림)도 최초 요청에 대한 설정이며 리다이렉트를 따라갈 때는 계속 적용되지 않을 수 있다고 설명한다. 이동 뒤 경로는 각 후속 요청에서 다시 확인해야 한다.

정리된 URI로 통신하는 일반 동작을 확인할 때는 기본 조건을 사용하고, 점 구간이 남은 경로를 서버가 어떻게 받는지 확인할 때는 해당 재현 명령에 --path-as-is를 추가한다. 이 관찰로 확인한 범위는 위 세 ASCII 경로의 전송 차이다. 퍼센트 인코딩된 점, HTTP/2·3, 실제 프록시나 프레임워크의 후속 정규화는 실행하지 않았다.

← 전체 글 보기