URL을 문자열로 자르면 안 되는 이유
포트가 사라지고 경로가 위로 올라가고 +가 공백이 됩니다. URL 파서가 실제로 무엇을 하는지 실행 결과로 정리했습니다.
주소에서 호스트만 꺼내려고 이런 코드를 씁니다.
const host = url.split('/')[2];
대부분 동작합니다. 그리고 몇 가지 입력에서 조용히 틀립니다.
파서는 주소를 고쳐 쓴다
new URL()에 넣은 값과 나온 값이 같지 않습니다.
입력 https://user:pw@example.com:8443/a/../b/./c?x=1&x=2#frag
출력 https://user:pw@example.com:8443/b/c?x=1&x=2#frag
경로 /a/../b/./c가 /b/c가 됐습니다. ..와 .이 해석됩니다. 문자열로 잘랐다면 /a/../b/./c를 그대로 들고 있었을 것입니다.
이것이 보안 문제가 되는 자리가 있습니다.
입력 http://example.com/%2e%2e/etc
출력 http://example.com/etc
%2e는 .의 퍼센트 인코딩입니다. 파서는 이것을 디코딩한 뒤 ..로 해석해 한 단계 올라갑니다. 인코딩된 형태만 보고 "점이 없으니 안전하다"고 판단하는 검사는 여기서 뚫립니다.
포트는 있다가도 없다
입력 HTTPS://ExAmPle.COM:443/A
출력 https://example.com/A port=""
입력 https://example.com:0080/x
출력 https://example.com:80/x port="80"
스킴의 기본 포트와 같으면 포트가 지워집니다. https의 443, http의 80이 그렇습니다. 그래서 u.port는 빈 문자열이 될 수 있고, 포트를 비교하는 코드는 이 경우를 따로 처리해야 합니다. 앞의 0은 정리됩니다 — 0080은 80입니다.
스킴과 호스트는 소문자로 내려갑니다. 경로는 그대로입니다 — /A는 /A로 남습니다.
도메인은 ASCII로 바뀐다
입력 https://한글.example/길?q=값
출력 https://xn--bj0bj06e.example/%EA%B8%B8?q=%EA%B0%92
호스트는 punycode로, 경로와 질의는 퍼센트 인코딩으로 바뀝니다. 서로 다른 방식입니다. 호스트를 퍼센트 인코딩하거나 경로를 punycode로 바꾸면 둘 다 틀립니다.
+ 는 자리에 따라 다르다
new URL('https://e.com/?q=a+b').searchParams.get('q') // "a b"
decodeURIComponent('a+b') // "a+b"
new URL('https://e.com/a+b').pathname // "/a+b"
질의 문자열에서 +는 공백입니다. 이것은 URL 규격이 아니라 HTML 폼 인코딩(application/x-www-form-urlencoded)의 규칙이고, searchParams가 그 규칙을 따릅니다. decodeURIComponent는 따르지 않습니다. 경로에서도 +는 그냥 +입니다.
질의 값을 직접 디코딩하는 코드가 decodeURIComponent만 쓰면 a+b가 그대로 남습니다.
같은 키가 여러 번 올 수 있다
const u = new URL('https://e.com/?x=1&x=2&y=&z');
u.searchParams.getAll('x') // ["1", "2"]
u.searchParams.get('x') // "1" 첫 번째만
u.searchParams.get('y') // "" 값이 빈 문자열
u.searchParams.get('z') // "" 값이 아예 없어도 빈 문자열
u.searchParams.has('none') // false
get은 첫 값만 돌려줍니다. 서버가 마지막 값을 쓰도록 구현돼 있다면 클라이언트와 서버가 다른 값을 보게 됩니다. 값이 없는 z와 빈 값인 y는 get으로 구분되지 않습니다 — 구분하려면 has와 함께 봐야 합니다.
상대 주소는 기준이 필요하다
new URL('//example.com/p', 'https://base.example/dir/page')
// https://example.com/p
//로 시작하면 스킴만 물려받는 프로토콜 상대 주소입니다. 호스트가 base.example이 아니라 example.com이 됩니다. 사용자가 넣은 문자열을 기준 주소와 합칠 때, //evil.com은 다른 사이트를 가리킵니다. 리다이렉트 대상을 검사하면서 http://나 https://로 시작하는지만 보면 이 형태를 놓칩니다.
확인 순서
- 호스트를 꺼낼 때
split('/')대신 파서를 씁니다. 사용자 정보(user:pw@)가 끼면 위치가 밀립니다 - 리다이렉트 대상을 검사한다면
//로 시작하는 값과\\를 함께 막습니다. 스킴 검사만으로는 부족합니다 - 경로 검사는 파서를 통과시킨 뒤의
pathname으로 합니다.%2e%2e는 검사 전에..가 됩니다 - 포트를 비교한다면 빈 문자열을 기본 포트로 채워 넣고 비교합니다
- 질의 값은
searchParams로 읽습니다. 직접 디코딩하면+가 어긋납니다 - 같은 키가 여러 번 올 수 있는 파라미터는
getAll로 읽고, 서버가 몇 번째를 쓰는지 맞춥니다
tools.onuel.dev의 URL 파서에 주소를 넣으면 스킴·호스트·포트·경로 조각과 디코딩된 질의 파라미터로 나눠 보여 줍니다. 위에서 본 정규화가 실제로 어떻게 적용되는지 확인할 수 있습니다. 값 하나를 따로 다룰 때는 URL 인코딩과 URL 디코딩을 쓰고, 한글 도메인이 어떤 ASCII 이름이 되는지는 Punycode 변환에서 봅니다.
- #URL
- #파싱
- #웹