| 항목 | 내용 |
|---|---|
| CVE ID | CVE-2026-13676 |
| 영향 제품/버전 | npm 패키지 fast-uri >= 2.3.1, < 2.4.2 / >= 3.0.0, < 3.1.3 / >= 4.0.0, < 4.0.1 |
| CVSS v3.1 | 7.5 (High) — AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N (GHSA) |
| CWE | CWE-436 (Interpretation Conflict), CWE-551 (Authorization Before Parsing/Canonicalization) |
| 공개일 | 2026-06-29 (NVD) |
| 수정 버전 | 2.4.2 / 3.1.3 / 4.0.1 |
| 취약 지점 | index.js의 parse() 내부 IDN(Unicode 호스트) → ASCII 변환 블록 |
fast-uri는 RFC 3986/3987에 맞춰 URI를 파싱·정규화·비교하는 Node.js 라이브러리로, JSON Schema 검증기 Ajv의 format: "uri" 처리기를 비롯해 여러 패키지가 URL 파싱 엔진으로 내부에 품고 있다. 이 라이브러리는 http/https/ws/wss 스킴의 호스트에 한글·한자·전각(全角) 문자 같은 비 ASCII 문자가 섞여 있으면 이를 표준 ASCII 형태(퓨니코드)로 변환하는 IDN(국제화 도메인 이름) 정규화 단계를 거친다. 그런데 이 변환을 시도하는 코드가 URL.domainToASCII(...)라는, 실제로는 존재하지 않는 함수를 호출하고 있었다. 이 호출은 항상 TypeError를 던지지만, 코드는 이 예외를 잡아 parsed.error 필드에 조용히 적어 넣을 뿐 변환에는 실패한 채로 계속 진행한다. 결과적으로 parse()·normalize()·equal() 모두 호스트를 원래의 Unicode 형태 그대로 반환한다. 반면 Node의 표준 URL이나 fetch()는 같은 입력을 정상적으로 ASCII로 정규화한다. fast-uri로 호스트 기반 정책(차단목록, 루프백 필터링, 리다이렉트 검증, 아웃바운드 프록시 라우팅)을 판단한 뒤 같은 URL을 Node의 URL이나 fetch()에 넘기는 애플리케이션은, 이 두 파서가 같은 입력을 다르게 해석하는 틈을 타 정책 판단과 실제 요청 대상이 어긋나는 문제를 겪을 수 있다.
도메인 이름 시스템(DNS)은 원래 ASCII 문자만 지원한다. 한글.example.com처럼 사람이 읽기 편한 유니코드 도메인을 쓰려면, 브라우저나 URL 파서가 이를 xn--로 시작하는 퓨니코드(Punycode) ASCII 표현으로 바꿔서 실제 네트워크 요청·DNS 조회에 사용한다. 이 변환을 "IDN → ASCII 정규화"라고 부른다. 반대 방향(사람이 보는 화면 표시용)으로 다시 유니코드로 바꾸는 경우도 있지만, 이번 취약점과 관련된 것은 요청을 보내기 전에 반드시 거쳐야 하는 ASCII 정규화 방향이다.
전각(全角, fullwidth) 문자도 같은 문제의 연장선이다. 유니코드에는 일반 ASCII 마침표 .(U+002E)와 시각적으로 비슷하지만 코드포인트가 다른 "표의문자 마침표(ideographic full stop)" 。(U+3002)가 따로 존재한다. WHATWG URL 표준은 호스트를 파싱할 때 이런 "매핑 대상(mapping)" 문자들을 표준 ASCII 문자로 치환한 뒤 IDNA 처리를 적용하도록 정의돼 있다. 즉 127。0。0。1처럼 전각 마침표로 쓰인 IP 주소 형태의 문자열도, 표준을 따르는 파서라면 127.0.0.1로 정규화해야 한다.
fast-uri는 호스트 문자열에 "단순(simple)" ASCII 문자 집합(영문자·숫자·하이픈·마침표 및 RFC 3986이 허용하는 몇몇 구두점) 밖의 문자가 하나라도 있으면 IDN 변환 경로를 탄다.
// lib/utils.js
const nonSimpleDomain = RegExp.prototype.test.bind(/[^!"$&'()*+,\-.;=_`a-z{}~]/u)그리고 이 경로는 스킴이 "도메인 호스트"를 쓰는 경우에만 활성화된다. http/https/ws/wss는 스킴 정의에서 domainHost: true로 표시돼 있어 이 조건에 해당한다.
// lib/schemes.js — HTTP 계열 스킴은 domainHost 를 갖는다즉 IP 리터럴이 아니면서(isIP === false) "단순하지 않은" 문자를 담은 HTTP/WS 계열 URL의 호스트가, 이 IDN 변환 블록의 대상이 된다.
문제의 블록은 다음과 같다(2.3.1~4.0.0 계열, 패치 전).
// index.js — parse() 내부
if (!options.unicodeSupport && (!schemeHandler || !schemeHandler.unicodeSupport)) {
if (parsed.host && (options.domainHost || (schemeHandler && schemeHandler.domainHost)) && isIP === false && nonSimpleDomain(parsed.host)) {
// convert Unicode IDN -> ASCII IDN
try {
parsed.host = URL.domainToASCII(parsed.host.toLowerCase())
} catch (e) {
parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e
}
}
}여기서 URL은 파일 어디에도 require되지 않은 채로 쓰인다. Node.js(및 브라우저)는 WHATWG URL 클래스를 전역(global)으로 노출하므로 이 코드는 문법적으로는 문제없이 그 전역 URL을 참조한다. 그런데 domainToASCII라는 이름의 함수는 전역 URL 클래스에 정적 메서드로 존재하지 않는다. 이름이 비슷한 함수는 따로 있다 — Node의 레거시 url 모듈을 require('url')로 불러와야 얻을 수 있는 url.domainToASCII(domain)이라는 독립 함수다. 즉 이 코드는 "레거시 url 모듈의 domainToASCII 함수"를 쓰려 했지만, 실제로는 "전역 URL 클래스에 없는 정적 메서드"를 호출한 것이다. 이런 이름 충돌 때문에 URL.domainToASCII(...)는 호출될 때마다 항상 TypeError: URL.domainToASCII is not a function을 던진다.
이 TypeError는 catch 블록에서 잡히지만, 처리 방식이 문제를 키운다.
} catch (e) {
parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e
}parsed.error에 에러 메시지를 기록할 뿐, parsed.host는 변환 시도 이전 값(원래의 Unicode 문자열)에 그대로 머문다. 그리고 parsed.error가 설정돼 있어도 parse()는 정상적으로 파싱 결과 객체를 반환한다 — 예외를 다시 던지거나 파싱 자체를 실패로 처리하지 않는다. 호출자가 parsed.error를 명시적으로 확인하지 않는 한(그리고 대부분의 사용 패턴은 parsed.host만 꺼내 쓴다), 이 실패는 겉으로 드러나지 않는다.
[패치 전 동작 흐름 — 입력: "http://127。0。0。1/"]
parse("http://127。0。0。1/")
│
├─ host = "127。0。0。1" (전각 마침표 포함)
├─ nonSimpleDomain(host) → 참 (전각 마침표는 "단순 문자"가 아님)
▼
IDN 변환 시도
│
├─ URL.domainToASCII(...) ──▶ TypeError 발생 (그런 정적 메서드 없음)
├─ catch: parsed.error = "Host's domain name can not be converted to ASCII: ..."
│ (parsed.host는 변경되지 않음)
▼
반환값: { host: "127。0。0。1", error: "...", ... } ◀── 에러가 있어도 host는 그대로 반환됨
문제의 핵심은 fast-uri가 이 입력을 파싱하는 데 "실패"한다는 사실 자체가 아니라, 같은 입력을 두고 fast-uri와 표준 WHATWG URL/fetch()가 서로 다른 호스트를 돌려준다는 데 있다.
| 파서 | 입력 | 반환된 host |
|---|---|---|
fast-uri (패치 전) |
http://127。0。0。1/ |
127。0。0。1 (원문 그대로, 전각 마침표 유지) |
Node URL / fetch() |
http://127。0。0。1/ |
127.0.0.1 (표준 ASCII, 루프백 IP로 정규화됨) |
애플리케이션이 SSRF 방어나 리다이렉트 검증 같은 목적으로 fast-uri의 parse()/normalize()/equal()로 "이 호스트가 허용 목록에 있는가", "이 호스트가 루프백/사설 IP는 아닌가"를 판단한 뒤, 실제 네트워크 요청은 Node의 fetch()나 http.request + 표준 URL 파싱으로 수행하는 구조라면, 정책 판단에 쓰인 문자열(127。0。0。1, 눈에 보기엔 "정체불명의 문자열"이라 사설/루프백 목록에 걸리지 않음)과 실제로 접속하는 목적지(127.0.0.1, 표준 정규화 후 루프백으로 확정)가 달라진다. 이런 "검사에 쓰는 파서"와 "실제로 요청을 보내는 파서"가 서로 다른 정규화 결과를 내놓는 상황은 URL 파서 불일치(parser confusion/differential) 계열 취약점의 전형적인 패턴이며, GHSA는 이를 "host confusion"으로 분류했다.
2.4.2/3.1.3/4.0.1은 존재하지도 않는 URL.domainToASCII를 부르는 대신, 표준 WHATWG URL 생성자에 실제로 존재하는 호스트 파싱 경로를 그대로 활용하도록 코드를 바꿨다.
// index.js (패치 후)
try {
parsed.host = new URL('http://' + parsed.host).hostname
} catch (e) {
parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e
}new URL(...)은 인자로 받은 문자열 전체를 URL로 파싱하면서, WHATWG URL 표준이 정의한 호스트 파싱 알고리즘(전각 문자 매핑 → IDNA 처리 → 퓨니코드 인코딩)을 그대로 거친다. 원래의 호스트 문자열 앞에 'http://'를 붙여 임시 URL을 구성한 뒤 그 결과의 .hostname만 꺼내 쓰는 방식으로, domainToASCII라는 없는 함수 대신 이미 검증된 표준 파서의 호스트 정규화 로직을 재사용한다.
[패치 후 동작 흐름 — 입력: "http://127。0。0。1/"]
parse("http://127。0。0。1/")
│
├─ host = "127。0。0。1"
├─ nonSimpleDomain(host) → 참
▼
IDN 변환 시도
│
├─ new URL("http://127。0。0。1").hostname
│ └─ WHATWG 호스트 파싱: 전각 마침표 매핑 → "127.0.0.1"
▼
parsed.host = "127.0.0.1" ◀── fast-uri와 표준 URL/fetch()가 이제 같은 host를 반환
(parsed.error 는 설정되지 않음 — 변환이 실제로 성공했으므로)
이 수정으로 fast-uri가 반환하는 호스트는 Node 표준 URL/fetch()가 같은 입력에 대해 반환하는 호스트와 일치하게 되어, 두 파서 사이의 정책/실사용 불일치가 사라진다. 함께 추가된 회귀 테스트는 전각 마침표로 구분된 IPv4 형태, 전각 알파벳으로 시작하는 도메인, 실제 퓨니코드 인코딩이 필요한 CJK 라벨(納豆.example.org → xn--99zt52a.example.org) 세 가지 경우 모두 parsed.error가 비어 있고 parsed.host가 올바른 ASCII 형태로 나오는지 검증한다.
이 라이브러리를 의존성으로 사용하는 프로젝트(Ajv 기반 스키마 검증, Fastify 생태계 등)는 fast-uri를 2.4.2/3.1.3/4.0.1 이상으로 올려야 하며, 별도의 애플리케이션 코드 우회책은 공식적으로 제공되지 않는다(GHSA는 "Workarounds: None. Upgrade to the patched version"으로 명시한다).
URL.domainToASCII라는 잘못된 호출이 애초에 어떻게 코드 리뷰를 통과했는지(예: 과거 버전의 폴리필/타입 정의가 이 이름을 제공했는지, 혹은 단순 오타가 테스트로 걸러지지 않았는지)는 GHSA 본문과 diff만으로는 확인할 수 없었다. 이 부분은 저장소의 과거 커밋 히스토리를 더 추적해야 하나, 이번 보고서 범위에서는 다루지 않았다.- GHSA는 "host-based policy를 판단한 뒤 같은 URL을 Node URL/fetch에 넘기는 애플리케이션"을 실제 영향 시나리오로 들고 있지만,
fast-uri를 사용하는 구체적인 실제 오픈소스 프로젝트(Ajv 등)가 이 패턴으로 영향을 받는지는 각 프로젝트별로 별도 확인이 필요하며 이 보고서에서 직접 검증하지 않았다.
- CVE-2026-13676 — NVD — 공개일, 영향 버전 범위
- GHSA-4c8g-83qw-93j6 — CVSS 7.5, CWE-436/CWE-551, 상세 시나리오("host confusion")
- PR #188 — fastify/fast-uri — 2.x 라인 백포트 PR
- 커밋
01db480— 2.x 백포트 수정 — 실제 수정 diff, 회귀 테스트 추가 - 커밋
2a6d357— main/4.x 수정 — 동일 수정의 main 브랜치 반영 - 패치 전 취약 코드 — index.js (커밋
919dd8e) —URL.domainToASCII호출부 lib/utils.js—nonSimpleDomain정규식 — IDN 변환 대상 판별 조건lib/schemes.js— HTTP 계열 스킴의domainHost플래그- fast-uri 4.0.1 / 3.1.3 / 2.4.2 릴리스 — 공식 수정 버전
- Node.js 표준
url.domainToASCII()— 원래 의도했던 것으로 보이는 레거시url모듈 함수(전역URL클래스의 정적 메서드가 아님)