개발 이야기3분 읽기

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은 정리됩니다 — 008080입니다.

스킴과 호스트는 소문자로 내려갑니다. 경로는 그대로입니다 — /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와 빈 값인 yget으로 구분되지 않습니다 — 구분하려면 has와 함께 봐야 합니다.

상대 주소는 기준이 필요하다

new URL('//example.com/p', 'https://base.example/dir/page')
// https://example.com/p

//로 시작하면 스킴만 물려받는 프로토콜 상대 주소입니다. 호스트가 base.example이 아니라 example.com이 됩니다. 사용자가 넣은 문자열을 기준 주소와 합칠 때, //evil.com은 다른 사이트를 가리킵니다. 리다이렉트 대상을 검사하면서 http://https://로 시작하는지만 보면 이 형태를 놓칩니다.

확인 순서

  1. 호스트를 꺼낼 때 split('/') 대신 파서를 씁니다. 사용자 정보(user:pw@)가 끼면 위치가 밀립니다
  2. 리다이렉트 대상을 검사한다면 //로 시작하는 값과 \\를 함께 막습니다. 스킴 검사만으로는 부족합니다
  3. 경로 검사는 파서를 통과시킨 뒤의 pathname으로 합니다. %2e%2e는 검사 전에 ..가 됩니다
  4. 포트를 비교한다면 빈 문자열을 기본 포트로 채워 넣고 비교합니다
  5. 질의 값은 searchParams로 읽습니다. 직접 디코딩하면 +가 어긋납니다
  6. 같은 키가 여러 번 올 수 있는 파라미터는 getAll로 읽고, 서버가 몇 번째를 쓰는지 맞춥니다

tools.onuel.devURL 파서에 주소를 넣으면 스킴·호스트·포트·경로 조각과 디코딩된 질의 파라미터로 나눠 보여 줍니다. 위에서 본 정규화가 실제로 어떻게 적용되는지 확인할 수 있습니다. 값 하나를 따로 다룰 때는 URL 인코딩URL 디코딩을 쓰고, 한글 도메인이 어떤 ASCII 이름이 되는지는 Punycode 변환에서 봅니다.

  • #URL
  • #파싱
  • #