URL 생성 규칙과 인코딩
URL에 파이프(|) 문자가 섞여 들어가며 겪은 에러를 계기로, RFC 3986 문법과 encodeURI/encodeURIComponent의 차이, new URL()을 활용한 검증까지 정리한 글
서비스 중인 Form 화면에 URL을 입력하는 Input이 있다. 기본적으로 String 타입 입력 영역이고, 사용자는 origin이 포함된 Full URL과 origin을 제외한 상대 경로를 입력할 수 있다.(통일 요건..) 상대 경로는 서비스 내 특정 화면으로 이동하는 경로이고, 절대 경로는 origin이 포함된 URL이다.
Input 하나에 절대 경로/상대 경로 모두 입력 가능하다보니, 현재는 입력값 제약에 대한 로직이 없는 상태다. 상대 경로의 경우 사용자가 입력할 때 오타의 가능성이 있지만, 프론트 입장에서 현재 서비스되고 있는 모든 화면 경로를 알고 있지 않는 이상 이에 대한 유효성 검증을 진행하는 것은 어렵다. 절대 경로는 유효한 URL인지 여부를 확인할 수는 있지만, 현재 프론트 로직상 적용된 검증 로직은 없는 상태다.
그렇게 사용자의 판단에 의해서 운영되고 있었는데, ’|’ 파이프가 포함된 URL이 입력되면서 에러가 발생했다.
URL을 복사/붙여넣기 하면서 URL에 대한 사용자의 직접 검증이 없었고, 동작하지 않는 URL이 입력된 상황이다. 원인과 해결책이 명확한 상황이지만, 이 참에 URL 생성 규칙과 기본적인 내용을 정리하고 싶어 URL에 관한 기술적인 관점으로 중요한 Point들 위주로 정리해 보았다.
URL은 RFC 39861 문법을 따르는데, 여기서 사용 가능한 문자를 “unreserved”와 “reserved” 두 그룹으로 나눈다. A-Z a-z 0-9 - . _ ~ 는 unreserved로, 그 자체로 아무 의미가 없어서 인코딩 없이 그대로 써도 되는 문자들이다. : / ? # [ ] @ ! $ & ' ( ) * + , ; = 는 reserved인데, URL 구조를 나누는 구분자 역할을 하기 때문에 위치에 따라 “구분자”로 해석될지 “그냥 데이터”로 해석될지가 갈린다. 그래서 데이터로 쓰고 싶다면 퍼센트 인코딩(%XX)을 거쳐야 한다.
문제는 파이프(|)가 이 두 그룹 어디에도 속하지 않는다는 점이다. RFC 3986 문법상 애초에 URL에 그대로 등장할 수 없는 문자라는 뜻이다. 옛날 RFC 1738에서는 이런 문자들을 아예 “unsafe characters”라고 따로 불렀는데, 공백이나 < > " { } | \ ^ 백틱 같은 것들이 여기 해당한다. 브라우저 주소창은 이런 문자를 입력해도 알아서 퍼센트 인코딩을 해주기 때문에 평소에는 체감을 못 하는데, 코드에서 문자열을 그대로 조합해 fetch나 location.href, <a href> 같은 곳에 흘려보내면 인코딩 없이 그대로 전달되고, 이걸 받는 쪽에서 유효하지 않은 URL로 판단해버리는 것이다.
encodeURI와 encodeURIComponent, 그리고 검증의 한계
그럼 인코딩을 어디서 걸어줬어야 했는지 생각해보면, JS에는 encodeURI와 encodeURIComponent 두 함수가 있는데 둘의 차이를 알아야 한다. encodeURI는 URL 전체를 인코딩하는 함수라서 : / ? # [ ] @ ! $ & ' ( ) * + , ; = 같은 구조 문자는 건드리지 않고 남겨둔다. 반면 encodeURIComponent는 값 하나를 인코딩하는 함수라서 reserved character까지 전부 인코딩 대상에 포함시킨다. 이 둘을 바꿔 쓰면 바로 버그로 이어진다.
const query = "a&b=c";
// encodeURI - 값 하나를 인코딩하기엔 부적합
`/search?q=${encodeURI(query)}`;
// '/search?q=a&b=c' ← &, = 가 그대로 살아있어서 쿼리 구조가 의도치 않게 깨짐
// encodeURIComponent - 값 인코딩엔 이걸 써야 함
`/search?q=${encodeURIComponent(query)}`;
// '/search?q=a%26b%3Dc'
쿼리 파라미터 값 하나를 인코딩할 때 encodeURI를 쓰면 값 안에 있는 &나 = 가 그대로 살아남아서 쿼리 구조 자체가 깨지고, 반대로 완성된 URL 전체를 encodeURIComponent로 인코딩하면 ://나 ? 같은 구조 문자까지 인코딩돼서 URL이 아예 망가진다. 이번 케이스는 사용자가 붙여넣은 URL 문자열 전체를 다루는 상황이었으니, 저장하거나 전달하기 전에 encodeURI 한 번을 태워줬다면 파이프가 %7C로 정규화되어 넘어갔을 것이다.
다만 이건 인코딩 함수 하나 끼워 넣는 방법이고, 근본적으로는 검증 로직을 어떻게 둘 것인가의 문제로 이어진다. 절대 경로 쪽은 다행히 브라우저에 내장된 URL 생성자를 활용할 수 있다. RFC 3986을 완전히 커버하는 정규식을 직접 짜서 유지보수하는 것보다, new URL(value)를 시도해보고 예외가 나면 유효하지 않은 것으로 판단하는 편이 정확하고 간단하다. 게다가 new URL()은 파싱 과정에서 path 안에 있는 unsafe character를 알아서 퍼센트 인코딩까지 해주기 때문에, 검증 도구인 동시에 정규화 도구로도 쓸 수 있다. 다만 여기서 한 가지는 별도로 챙겨야 하는데, new URL()은 javascript:나 data: 같은 scheme도 문법적으로는 유효한 URL로 통과시켜버린다. 사용자가 입력한 URL을 그대로 <a href>나 리다이렉트에 쓰는 화면이라면, 형식 검증과는 별개로 protocol을 http/https로 제한하는 화이트리스트 체크가 필요하다.
상대 경로는?
상대 경로는 조금 다른 이야기다. new URL()은 base 없이는 파싱이 안 되고, 더미 base를 붙여서 파싱한다 해도 그건 어디까지나 “URL로서 형식이 맞는가”를 확인해줄 뿐, 그 경로가 실제 서비스에 존재하는 라우트인지는 확인해주지 못한다. 이건 애초에 프론트가 런타임에 알 수 있는 정보가 아니라 라우터 설정이나 서버 라우팅 테이블에 있는 정보라서, 지금 이 Form에서 프론트가 할 수 있는 검증의 한계는 명확하다.
참고로 //로 시작하는 입력은 얼핏 상대 경로처럼 보이지만 실제로는 protocol-relative URL이라, 브라우저가 현재 scheme을 그대로 붙여 완전히 다른 외부 origin으로 해석해버린다. 상대 경로라고 믿고 그대로 통과시켰다가 실제로는 절대 URL이었던 셈이 될 수 있어서, 이 부분도 별도로 걸러야 한다.
브라우저 주소창은 사용자가 무엇을 입력하든 알아서 보정해주지만, 서비스 내부 로직은 그런 보정을 대신 해주지 않는다. 붙여넣기로 들어오는 값은 입력받는 시점에 한 번은 정규화를 거치게 만들어야 한다.