PHP로 웹을 만들다 보면 이런 함수를 한 번쯤 짜게 된다.
function sameOrigin(string $a, string $b): bool {
$pa = parse_url($a);
$pb = parse_url($b);
return strtolower($pa['scheme'] ?? '') === strtolower($pb['scheme'] ?? '')
&& strtolower($pa['host'] ?? '') === strtolower($pb['host'] ?? '')
&& ($pa['port'] ?? null) === ($pb['port'] ?? null);
}
리다이렉트 대상이 우리 사이트인지, 웹훅 URL이 허용된 호스트인지, 에디터 본문의 링크가 외부인지 — 전부 이 함수 위에 서 있다. 그리고 이 함수는 틀렸다. 기본 포트를 모르고(:443이 붙은 쪽과 안 붙은 쪽이 다르다고 답한다), 한글 도메인을 못 읽고, 무엇보다 parse_url()이 브라우저와 다르게 URL을 자르는 입력이 있다.
상대경로를 절대경로로 바꾸는 쪽은 더하다. dirname()과 explode('/')로 ..을 지워 나가는 코드는 대개 어딘가에서 한 번 더 /를 붙이거나 뺀다.
PHP 8.5에 URI 확장이 들어왔다. 항상 켜져 있고, 설치할 것이 없다. 이 글은 그 확장이 위의 두 코드를 어떻게 대체하는지, 그리고 클래스가 왜 둘인지에 대한 것이다.
parse_url()이 부족한 이유 — 표준이 없다
parse_url()의 문제는 버그가 많아서가 아니다. 어떤 표준도 따르지 않는다는 것이다. 매뉴얼 스스로 "상대 URL이나 잘못된 URL에서는 올바른 결과를 주지 않을 수 있고, 일반적인 HTTP 클라이언트의 동작과도 다를 수 있다"고 적어뒀다.
같은 문자열을 PHP 8.5.4에서 넣어본 결과다.
| 입력 | parse_url() |
문제 |
|---|---|---|
http://a.example\@b.example/ | host = b.example | 브라우저는 a.example로 간다 |
HTTP://EXAMPLE.COM:80/a/../b/ | 그대로 조각냄 | 정규화 없음. 대소문자·기본 포트·.. 전부 내 몫 |
http://한글.example/ | host = 깨진 바이트 | IDN 미지원 |
http://example.com:99999/ | false | 실패 시 배열 대신 false — 매번 분기 |
../images/a.png | path만 | 기준 URL에 대해 해석하는 기능이 없다 |
첫 줄이 제일 무섭다. 백슬래시를 어디까지 사용자 정보로 볼지가 파서마다 다른데, 내 코드는 b.example을 허용 목록과 대조하고 브라우저는 a.example로 간다. 이 불일치를 "파싱 혼동"이라 부르고, 오픈 리다이렉트와 SSRF의 고전적인 입구다. parse_url()이 틀린 게 아니라, 두 파서가 같은 표준을 안 보고 있는 것이 문제다.
클래스가 둘인 이유 — 표준이 둘이다
새 확장에는 클래스가 둘 있다. 같은 URL을 다르게 읽는 표준이 둘이기 때문이다.
Uri\Rfc3986\Uri— RFC 3986. 2005년 IETF 표준. cURL, 대부분의 서버 라이브러리, HTTP 명세가 이걸 따른다. 엄격하다. 문법에 안 맞으면 거부한다.Uri\WhatWg\Url— WHATWG URL Standard. 브라우저가 따르는 것. 관대하다. 사람이 주소창에 치는 온갖 것을 브라우저가 고쳐 읽듯이 고쳐 읽는다. 자바스크립트의new URL()과 같은 결과를 낸다.
아까 그 백슬래시 입력을 셋에 넣으면 이렇게 갈린다.
어느 쪽을 고를지는 그 URL을 최종적으로 누가 해석하느냐로 정한다.
| 이 URL을 읽는 것은… | 클래스 | 예 |
|---|---|---|
| 브라우저 | WhatWg\Url | 리다이렉트 대상 검증, 사용자가 입력한 링크, 에디터 본문의 href, Origin/Referer 헤더 대조, 한글 도메인 |
| 서버·프로토콜 | Rfc3986\Uri | API 엔드포인트 조립, cURL로 보낼 요청, 설정 파일의 URL 검증, urn:·mailto: 같은 비-웹 식별자, 기준 없는 상대 URI |
대부분의 웹 애플리케이션 코드는 위쪽 줄이다. 사용자에게서 온 URL은 WHATWG라고 기억해도 크게 틀리지 않는다.
origin 비교
맨 위의 함수를 다시 쓰면 이렇다.
use Uri\WhatWg\Url;
function origin(Url $u): string {
return $u->getScheme() . '://' . $u->getAsciiHost()
. ($u->getPort() !== null ? ':' . $u->getPort() : '');
}
function sameOrigin(string $a, string $b): bool {
$ua = Url::parse($a);
$ub = Url::parse($b);
return $ua && $ub && origin($ua) === origin($ub);
}
실측이다.
origin('https://example.com:443/a') → https://example.com
origin('HTTPS://Example.COM/b?x') → https://example.com
origin('https://example.com:8443/') → https://example.com:8443
origin('https://한글.example/') → https://xn--bj0bj06e.example
손으로 하던 세 가지가 사라졌다.
- 대소문자 — 스킴과 호스트가 소문자로 정규화되어 나온다.
- 기본 포트 —
https의:443,http의:80은getPort()가null을 준다. 스킴별 기본 포트 표를 내가 들고 있을 필요가 없다. - 한글 도메인 —
getAsciiHost()가 퓨니코드로 준다. 화면에 보여줄 때는getUnicodeHost().
여기서 Rfc3986\Uri를 쓰면 안 되는 이유가 하나 드러난다. RFC 3986은 스킴별 기본 포트라는 개념이 없다. 실측으로 https://example.com/p와 https://example.com:443/p를 equals()로 비교하면 WHATWG는 true, RFC 3986은 false다. 브라우저에서는 같은 origin인데 RFC 3986은 다르다고 한다. origin은 웹 개념이고, 웹 개념은 WHATWG 쪽에 있다.
오픈 리다이렉트 막기
이 함수의 가장 흔한 쓰임새다. 로그인 후 ?next=로 돌아갈 곳을 받는 코드.
$site = new Url('https://example.com/');
$next = Url::parse($_GET['next'] ?? '', $site); // 상대경로면 우리 사이트 기준으로
if ($next === null || origin($next) !== origin($site)) {
$next = $site; // 밖으로 나가는 건 전부 홈으로
}
header('Location: ' . $next->toAsciiString());
두 번째 인자 $site가 기준 URL이다. next=/mypage처럼 상대경로가 오면 우리 사이트에 붙여서 해석하고, next=//evil.example/처럼 프로토콜 상대 경로가 오면 스킴만 물려받아 https://evil.example/이 되어 origin 비교에서 걸린다. next=http://a.example\@b.example/는 브라우저와 같은 방식으로 a.example로 읽히므로, 브라우저가 갈 곳을 기준으로 판정한다.
상대경로 해석
크롤러, 사이트맵 생성기, 에디터 본문의 이미지 경로 보정 — 어디든 "이 페이지 기준으로 이 링크는 어디인가"를 계산하는 코드가 있다. resolve()가 그 계산이다.
$page = new Url('https://example.com/docs/guide/intro.html?x=1');
$page->resolve('../images/a.png')->toAsciiString();
// https://example.com/docs/images/a.png
$page->resolve('/api/v1'); // https://example.com/api/v1
$page->resolve('?page=2'); // https://example.com/docs/guide/intro.html?page=2
$page->resolve('#top'); // https://example.com/docs/guide/intro.html?x=1#top
$page->resolve('//cdn.example/lib.js'); // https://cdn.example/lib.js
$page->resolve('sub/page.html'); // https://example.com/docs/guide/sub/page.html
$page->resolve('.'); // https://example.com/docs/guide/
전부 실측값이다. 손으로 짜면 틀리기 쉬운 곳이 몇 군데 보인다. ?page=2는 경로를 유지하되 기존 쿼리를 버리고, #top은 기존 쿼리를 유지한다. //cdn.example은 스킴만 물려받는다. .은 디렉터리다. 이 규칙들은 RFC 3986 5.2절에 있는데, 직접 구현하면 대개 하나둘 빠진다.
기준을 생성자에 넘겨도 된다. new Url('../a.png', $page)는 $page->resolve('../a.png')와 같다. 파싱과 해석을 한 번에 한다.
정규화 — raw 와 normalized
Rfc3986\Uri에는 게터가 두 벌이다. getPath()와 getRawPath(), toString()과 toRawString().
$u = new Uri\Rfc3986\Uri('HTTPS://EXAMPLE.COM:443/a/./b/../c/%7Euser?q=1#frag');
$u->toRawString(); // HTTPS://EXAMPLE.COM:443/a/./b/../c/%7Euser?q=1#frag (넣은 그대로)
$u->toString(); // https://example.com:443/a/c/~user?q=1#frag (정규화)
$u->getRawPath(); // /a/./b/../c/%7Euser
$u->getPath(); // /a/c/~user
정규화는 RFC 3986 6장이 정한 것만 한다. 스킴·호스트 소문자, .·.. 제거, 비예약 문자의 퍼센트 인코딩 해제(%7E → ~). 반대로 %2F처럼 예약 문자를 인코딩한 것은 그대로 둔다 — 풀면 경로 구분자가 되어 의미가 바뀌기 때문이다. 이 구분을 손으로 하려면 예약 문자 목록을 외워야 한다.
쓸모는 같은 자원을 가리키는 URL을 하나로 모으는 것이다. 캐시 키, 사이트맵의 중복 제거, 방문 기록 집계. toString()을 키로 쓰면 대소문자·..·인코딩 차이가 사라진다.
equals()가 이 정규화 위에서 동작한다. 기본값은 fragment를 무시하고 비교하고, Uri\UriComparisonMode::IncludeFragment를 주면 포함한다. 한 가지 차이 — %65와 e를 RFC 3986은 같다고 하고(비예약 문자니까), WHATWG는 다르다고 한다(브라우저는 풀지 않으니까). 어느 쪽이 맞느냐가 아니라, 두 표준의 동등성 정의가 다른 것이다.
실패는 어떻게 오나
parse_url()은 실패하면 false를 주고, 그걸 배열처럼 쓰면 경고가 난다. 새 클래스는 두 길을 준다.
- 생성자 — 실패하면
Uri\InvalidUriException(WHATWG는 그 하위인Uri\WhatWg\InvalidUrlException)을 던진다. 내가 만든 URL, 즉 틀리면 버그인 곳에 쓴다. parse()— 실패하면null. 사용자에게서 온 URL, 즉 틀린 것이 정상인 곳에 쓴다.
WHATWG 쪽은 하나 더 있다. 브라우저는 URL을 고쳐 읽으면서도 "이건 원래 잘못된 것"을 기록하는데, 그 목록을 세 번째 인자로 받을 수 있다.
$u = Url::parse('http://example.com:99999/', null, $errors);
// $u === null
// $errors[0]->type === Uri\WhatWg\UrlValidationErrorType::PortOutOfRange
// $errors[0]->failure === true (고칠 수 없는 오류)
failure가 false인 항목은 "고쳐서 읽었다"는 뜻이다. 입력 검증 화면에서 "이렇게 고쳐서 저장합니다"를 보여주거나, 로그에 남기는 데 쓴다.
불변 객체와 with*()
두 클래스 모두 final readonly다. 바꾸려면 with*()로 새 객체를 받는다.
$u = new Url('https://example.com/a?x=1');
$v = $u->withHost('cdn.example')->withQuery(null);
$u->toAsciiString(); // https://example.com/a?x=1 (그대로)
$v->toAsciiString(); // https://cdn.example/a
정적 자원 URL을 CDN 호스트로 바꾸거나, 추적용 쿼리를 떼어내거나, 페이지 번호만 바꾼 링크를 만들 때 parse_url → 배열 수정 → http_build_url(PECL에만 있던 그 함수)로 이어지던 것이 메서드 체인 하나가 된다. Rfc3986\Uri는 withScheme(null)도 받아서 //example.com/a 같은 프로토콜 상대 URL을 만들 수 있다.
걸리는 곳
RFC 3986은 유니코드 호스트를 거부한다. new Uri('http://한글.example/')은 예외다. RFC 3986은 ASCII 문법이고 IDN은 별도 표준(RFC 3987 IRI)이기 때문이다. 한글 도메인이 올 수 있으면 WHATWG다.
WHATWG는 기준 없는 상대 URL을 거부한다. new Url('path/only')은 예외, Url::parse('path/only')은 null이다. 브라우저에서 상대 URL은 항상 어떤 문서 기준으로 존재하기 때문이다. 기준 URL을 넘기면 된다. 반대로 Rfc3986\Uri는 path/only를 상대 URI 객체로 그냥 받는다.
RFC 3986은 포트 범위를 검사하지 않는다. :99999가 통과하고 getPort()가 99999를 준다. 문법상 포트는 "숫자의 나열"이라서다. WHATWG는 PortOutOfRange로 거부한다.
공백과 특수문자. http://example.com/a b를 RFC 3986은 거부하고, WHATWG는 /a%20b로 고쳐 읽는다. 관대함은 양날이다. 사용자 입력에는 편하고, 설정값 검증에는 너무 너그럽다.
이 네 가지는 전부 같은 이유에서 온다. RFC 3986은 "올바른 URI가 무엇인가"를 정의한 문법이고, WHATWG는 "브라우저가 이 문자열을 어디로 보내는가"를 정의한 알고리즘이다. 어느 쪽이 나은 게 아니라 질문이 다르다.
어디에 쓰나
- 오픈 리다이렉트 방어 —
?next=,?return_url=. WHATWG + origin 비교. - SSRF 방어의 첫 단계 — 사용자가 준 URL에서 호스트를 꺼내 내부망·메타데이터 주소를 거른다. 브라우저와 같은 방식으로 꺼내야 우회가 어렵다. (DNS 리바인딩 등은 별도 문제다.)
- CORS·CSRF의
Origin대조 — 요청 헤더의 origin과 허용 목록을 정규화해서 비교. - 웹훅·콜백 URL 등록 검증 — 스킴이
https인지, 호스트가 허용 범위인지,parse()로 형식 검사. - 크롤러·링크 추출 — 본문의
href를 페이지 기준으로resolve()해서 절대 URL로. - 사이트맵·캐시 키 —
Rfc3986\Uri::toString()으로 정규화해 중복 제거. - 에디터 본문 후처리 — 외부 링크에
rel="noopener"를 붙일지, 이미지 경로를 CDN으로 바꿀지 판정.
정리
parse_url()의 문제는 표준이 없다는 것이다. 검증하는 쪽(PHP)과 이동하는 쪽(브라우저)이 다르게 읽으면 그 틈이 취약점이 된다.- PHP 8.5의
Uri\WhatWg\Url은 브라우저와 같은 표준,Uri\Rfc3986\Uri는 cURL·서버와 같은 표준. 사용자에게서 온 URL은 WHATWG. - origin 비교는
getScheme()+getAsciiHost()+getPort()세 줄이면 된다. 기본 포트·대소문자·IDN이 처리되어 나온다. - 상대경로 해석은
resolve()또는 생성자의 기준 URL 인자. 쿼리·프래그먼트·프로토콜 상대 경로의 규칙이 다 들어 있다. - 내가 만든 URL은 생성자(예외), 사용자가 준 URL은
parse()(null). - 둘 다
final readonly.with*()로 새 객체.
PHP 8.5로 올릴 이유를 하나만 대라면 이 확장을 대겠다. 새 기능이라기보다, 20년 동안 각자 조금씩 틀리게 짜던 코드를 표준 하나로 걷어내는 것이다.
참고: PHP RFC: Add RFC 3986 and WHATWG compliant URI parsing support · PHP 매뉴얼 — URI. 실측은 PHP 8.5.4 CLI.