빌드노트
new URL()이 성공해도 javascript: 링크는 걸러야 한다
new URL()은 javascript:·data:·mailto:도 정상 파싱한다. 합성 입력의 protocol과 실제 로컬 문서 클릭을 비교해 HTTP(S) 허용 목록을 적용하고, textContent로 표시한 문구와 href 정책을 구분한다.
사용자가 등록한 홈페이지 주소를 new URL()로 파싱한 뒤, 예외가 없으면 a.href에 넣는 코드는 javascript: 주소도 통과시킨다. URL 파서는 주소를 해석하지만 그 주소를 서비스의 링크로 허용할지는 결정하지 않는다. 웹 문서로 이동하는 항목이라면 파싱 후 protocol을 https: 또는 http:로 제한하고, 검사한 URL의 href를 사용한다.
여섯 입력 모두 URL로 파싱됐다
Node.js가 설치돼 있다면 터미널에서 node를 실행하고 다음 코드를 붙여 넣는다. 주소 문자열만 해석하며 해당 서버에 접속하지 않는다. Node.js 22.23.2에서 실행했고 Chromium 147.0.7727.15에서도 같은 결과를 확인했다.
for (const raw of [
'https://docs.invalid/guide', 'http://docs.invalid/guide', '/guide',
'javascript:void(0)', 'data:text/plain,example', 'mailto:reader@example.invalid'
]) {
console.log(JSON.stringify(raw), new URL(raw, 'https://app.invalid/').protocol);
}
"https://docs.invalid/guide" https:
"http://docs.invalid/guide" http:
"/guide" https:
"javascript:void(0)" javascript:
"data:text/plain,example" data:
"mailto:reader@example.invalid" mailto:
/guide는 상대 주소라서 기준 주소인 base가 필요하다. 여기서는 https://app.invalid/guide로 해석된다. 반면 javascript:처럼 scheme이 있는 절대 주소는 HTTPS base를 줘도 HTTPS로 바뀌지 않는다. URL 생성자 설명 (새 탭에서 열림)은 상대 주소에 base가 필요하고, 절대 주소에는 base를 사용하지 않는다고 명시한다.
protocol은 scheme 뒤의 콜론까지 포함한다. 따라서 비교값은 'https'가 아니라 'https:'다. MDN의 protocol 설명 (새 탭에서 열림)과 WHATWG URL 표준의 getter 정의 (새 탭에서 열림)에서 확인할 수 있다.
별도 입력 https://[는 TypeError가 났다. 이것은 파싱 실패다. javascript:void(0)는 정상 파싱됐지만 웹 링크 정책에서는 거절해야 하는 입력이다. 두 조건을 try/catch 하나로 처리하면 후자가 남는다. mailto:가 잘못된 주소여서 거절하는 것도 아니다. 연락처 기능에서 허용할 수 있지만, 이 글에서는 웹 문서 링크만 받기로 정한 것이다.
textContent로 넣은 링크도 href는 실행 대상이다
다음은 운영 페이지와 관계없는 작은 HTML이다. 빈 텍스트 파일에 저장하고 이름을 link-check.html로 바꿔 브라우저에서 연다. 링크가 하는 일은 같은 문서의 idle 표시를 clicked로 바꾸는 것뿐이다. 외부 요청이나 계정 정보 읽기는 없다.
<!doctype html><meta charset="utf-8">
<p id="marker">idle</p>
<script>
const raw = "javascript:void(document.getElementById('marker').textContent='clicked')";
const a = document.createElement('a');
a.textContent = '<b>문서 열기</b>';
a.href = new URL(raw).href;
document.body.append(a);
</script>
새 Chromium 문서에서 직접 클릭하자 표시는 idle에서 clicked로 바뀌었다. 링크 문구의 <b>문서 열기</b>는 태그가 아닌 글자로 보였고, 생성된 b 요소는 0개였다. 문구를 HTML로 해석하지 않게 한 것과 링크의 실행 방식을 제한한 것은 별개의 처리였다. textContent 설명 (새 탭에서 열림)은 텍스트 노드 설정을 다룬다. href의 scheme을 검사하는 API는 아니다.
HTML 표준의 javascript: URL 처리 (새 탭에서 열림)는 이 이동 대상의 코드를 실행하는 절차를 정의한다. 예제의 void는 실행 결과가 문자열이 되어 문서 내용으로 쓰이는 것을 피하려고 붙였다. 코드를 실행하지 못하게 하는 장치는 아니다. MDN의 javascript: 설명 (새 탭에서 열림)
이번 로컬 문서에는 CSP를 설정하지 않았다. 같은 예제가 실행되지 않으면 먼저 파일이 HTML로 열렸는지, 콘솔에 CSP 차단이 있는지 확인한다. CSP로 실행이 막힐 수도 있으므로 표식이 그대로라는 사실만으로 URL 검사가 작동했다고 판단하지 않는다. 기존 서비스의 CSP를 풀어 재현할 필요는 없다.
파싱한 결과에서 허용 scheme을 고른다
다음 함수는 비어 있지 않은 문자열을 받아 HTTP(S) 주소만 반환한다. 상대 주소는 서비스가 정한 고정 base로 해석한다. 거절 결과는 null이며, 호출부에서는 링크 대신 평문을 표시한다.
const BASE = 'https://app.invalid/';
function webLinkUrl(raw) {
if (typeof raw !== 'string' || raw.trim() === '') return null;
try {
const url = new URL(raw, BASE);
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
return url.href;
} catch {
return null;
}
}
function linkFor(raw, label) {
const href = webLinkUrl(raw);
const node = document.createElement(href === null ? 'span' : 'a');
node.textContent = label;
if (href !== null) node.href = href;
return node;
}
실제 코드에서는 BASE를 서비스가 승인한 기준 주소로 바꾼다. 새 요소가 필요한 자리에 linkFor(입력주소, 표시문구)의 반환값을 삽입한다. 기존 링크를 갱신한다면 거절됐을 때 이전 href가 남지 않도록 요소를 교체하거나 속성을 제거해야 한다. 검사에 통과한 뒤 원래 입력을 다시 href에 넣지 않는다.
같은 합성 입력을 적용했을 때 HTTPS·HTTP·/guide는 주소를 반환했고, JavaScript·data·메일 주소는 null이었다. 빈 문자열과 문자열이 아닌 값도 거절했다. 파싱만 사용한 문서와 따로 연 비교 문서에서는 같은 JavaScript 주소를 span으로 표시했다. 클릭 전후 표시는 모두 idle, 생성된 링크는 0개였다. 정상 HTTPS 입력으로는 링크 1개가 만들어졌으며, 외부로 이동시키지는 않았다.
JaVaScRiPt:void(0)도 파싱된 protocol은 javascript:였다. 원문에서 소문자 javascript:만 찾는 차단 목록을 늘리기보다, 파서가 해석한 scheme에서 허용할 값만 고르는 쪽이 이 정책에 맞는다. HTTPS만 받을 제품이라면 위 조건을 url.protocol !== 'https:'로 좁힌다.
이 함수는 목적지의 신뢰도까지 판정하지 않는다. 실험의 //outside.invalid/guide도 HTTPS로 해석되어 허용됐다. 외부 웹 문서 링크를 받는 기능에서는 가능한 결과지만, 같은 서비스 안에서만 이동해야 하는 기능이라면 파싱한 url.origin의 허용 목록을 추가해야 한다. HTTP(S) 링크라는 판정과 허용된 사이트라는 판정은 각각 필요에 맞게 정한다.
이번 관찰은 Windows 11의 Node.js URL 파싱과 Chromium의 새 로컬 문서 클릭에 한정한다. data: 문서 이동, 메일 프로그램 실행, 다른 브라우저, 실제 서비스의 CSP·링크 정제기는 실행하지 않았다. 사용자 입력 링크를 점검할 때는 표시 문구를 처리하는 코드에 더해, 최종 href를 만드는 곳에서 허용된 protocol을 검사하는지 확인한다.