빌드노트
상대 경로가 엉뚱한 서버를 가리킨다면 base 태그를 확인한다
상대 링크의 원문이 그대로여도 base 요소가 문서의 기준 URL을 바꿀 수 있다. 두 loopback 출처의 여섯 조건에서 baseURI·절대 href·위반 이벤트를 비교하고, 같은 출처의 경로 변경도 허용하는 self와 base 설정을 막는 none의 선택 기준을 설명한다.
HTML에는 href="help.html"이라고 적혀 있는데, 브라우저가 해석한 링크 주소는 다른 서버를 가리킬 수 있다. 주소창이 그대로여도 <base href="…">가 문서의 상대 URL 기준을 바꾸기 때문이다. 먼저 document.baseURI와 링크의 절대 href를 비교한다. 필요 없는 기준 변경은 CSP의 base-uri로 제한한다. default-src 'none'만으로는 이 기준 변경을 막지 못한다.
링크에 적힌 값과 브라우저가 해석한 주소를 나란히 본다
문제가 생긴 페이지의 개발자 도구 Console에서 다음 조회를 실행한다. #help는 확인할 링크의 CSS 선택자로 바꾼다. iframe 안의 링크라면 Console의 실행 문서도 그 프레임을 선택해야 한다. 링크를 클릭하거나 요청을 보내지 않고 현재 값을 읽는 코드다.
(() => {
const link = document.querySelector('#help');
return {
page: location.href,
base: document.baseURI,
bases: [...document.querySelectorAll('base[href]')]
.map(b => b.getAttribute('href')),
rawHref: link.getAttribute('href'),
resolvedHref: link.href
};
})()
rawHref는 HTML 속성에 적힌 값이고 resolvedHref는 해석된 주소다. link가 null이라는 오류가 나면 선택한 문서에 그 링크가 없으므로 선택자와 프레임부터 확인한다. DOM 표준의 baseURI 정의 (새 탭에서 열림)는 문서의 기준 URL을 문자열로 돌려주도록 정한다.
rawHref가 그대로인데 base와 resolvedHref가 함께 예상 밖 주소로 바뀌었다면 <base>를 조사할 근거가 생긴다. Elements에서 현재 DOM을 보고, Network의 HTML 응답과 비교한다. 응답에는 없던 요소라면 클라이언트 코드의 삽입 경로를 확인한다. 승인한 템플릿 설정일 수도 있으므로 이 관찰만으로 공격이라고 단정하지 않는다.
bases를 목록으로 읽는 데도 이유가 있다. HTML 표준의 base 요소 규칙 (새 탭에서 열림)에 따르면 href가 있는 base 요소가 여러 개면 문서 순서상 첫 요소가 기준을 결정한다. 뒤에 정상 base를 하나 더 넣는 방법으로 앞의 값을 덮을 수 있다고 생각하면 진단을 놓친다.
반대로 base가 예상대로라면 이 원인을 계속 붙잡을 필요가 없다. 링크의 원문 자체가 절대 URL인지, 클릭 처리 코드가 별도 주소로 이동시키는지, 서버가 리다이렉트하는지를 구분한다. href 조회는 클릭 뒤의 모든 동작을 보여 주지는 않는다.
주소창은 그대로인데 기준 URL만 바뀌었다
원인을 비교하기 위해 새 프로필의 Chromium에서 합성 페이지를 열었다. 문서 출처 A는 http://127.0.0.1:13157, 비교 출처 B는 http://127.0.0.1:13158이었다. 같은 loopback 호스트라도 포트가 달라 서로 다른 출처다. 각 조건은 A의 새 문서에서 시작했고, 링크 원문은 항상 help.html이었다.
첫 조건은 default-src 'none' 헤더와 base 없는 문서였다. 위 조회에서 base는 http://127.0.0.1:13157/docs/page.html?case=normal, resolvedHref는 http://127.0.0.1:13157/docs/help.html이었다.
다음 조건도 헤더는 default-src 'none'으로 유지했다. 자동화에서 base 요소 하나를 만들어 href를 B의 /alternate/로 설정한 뒤 head에 삽입했다. 아래는 삽입 후 위 조회가 돌려준 실제 값이다.
{
"page": "http://127.0.0.1:13157/docs/page.html?case=default-cross",
"base": "http://127.0.0.1:13158/alternate/",
"bases": ["http://127.0.0.1:13158/alternate/"],
"rawHref": "help.html",
"resolvedHref": "http://127.0.0.1:13158/alternate/help.html"
}
주소창에 해당하는 page는 A에 남았고 링크 원문도 그대로였다. 바뀐 것은 상대 URL을 해석할 기준이었다. 이 조건에서는 base-uri 위반 이벤트가 없었다. 별도로 둔 href="/help.html"도 B의 /help.html로 해석됐다. 앞에 /를 붙이면 경로는 출처의 루트에서 시작하지만, base가 정한 출처까지 A로 고정되지는 않았다. A의 완전한 절대 URL로 적은 링크는 그대로였다.
이 결과는 B로 요청이 전송됐다는 뜻이 아니다. 링크를 클릭하지 않았고 fetch도 호출하지 않았다. 서버에는 A의 실험 문서 GET 여섯 건만 도착했고 B의 수신은 0건이었다. URL을 어디로 해석하는지와 그 URL의 요청을 허용하는지는 별도 판단이다.
base가 필요 없는 문서와 필요한 문서의 정책을 구분한다
CSP3의 default-src 절 (새 탭에서 열림)은 이 지시어를 다른 fetch 지시어의 대체값으로 정의한다. base-uri 절 (새 탭에서 열림)은 문서의 base URL을 따로 검사하며, 정책에 base-uri가 없으면 그 검사를 건너뛴다. 확인한 문서는 2026년 9월 16일자 Working Draft다. default-src가 엄격해도 base 설정까지 제한됐다고 볼 수 없는 이유다.
기준 URL을 바꾸는 기능이 필요 없다면, HTML 문서를 보내는 기존 Content-Security-Policy 값에 다음 지시어를 세미콜론으로 구분해 추가한다. 이미 base-uri가 있다면 그 값을 수정한다.
base-uri 'none'
이것은 기존 정책에 넣을 지시어이지 별도 헤더 이름이 아니다. 실험에서 쓴 default-src 'none'까지 기존 서비스에 그대로 복사하라는 뜻도 아니다. 필요한 스크립트와 이미지 등의 정책을 유지하면서 base 설정을 제한한다. 변경 후 Network에서 HTML 문서 응답의 강제 적용 헤더 Content-Security-Policy를 확인하고 페이지를 새로 불러온다.
같은 출처의 base가 실제로 필요한 문서라면 base-uri 'self'를 선택할 수 있다. 다만 'self'는 원래 문서의 디렉터리를 고정하지 않는다. 아래는 모든 조건에서 default-src 'none'을 유지하고 base만 비교한 관찰 요약이다. 각 행의 문서 쿼리 문자열은 조건 구분용이다.
- base 없음: 기준은 A의 문서 URL, 링크는 A의
/docs/help.html, base-uri 이벤트 0건. - base-uri 생략, B의
/alternate/삽입: 기준과 링크가 B로 변경, 이벤트 0건. 'self', B의/alternate/삽입: 기준은 A의 문서 URL, 링크는 A의/docs/help.html, 이벤트 1건.'self', A의/alternate/삽입: 기준은 A의/alternate/, 링크는 A의/alternate/help.html, 이벤트 0건.'none', A의/alternate/삽입: 기준은 A의 문서 URL, 링크는 A의/docs/help.html, 이벤트 1건.'none', B의/alternate/삽입: 기준은 A의 문서 URL, 링크는 A의/docs/help.html, 이벤트 1건.
이 비교에서 'self'는 다른 출처로 바뀌는 것을 막았지만 같은 출처의 다른 디렉터리로 바뀌는 것은 허용했다. base 자체가 불필요한 페이지에서 경로 변경까지 막으려면 'none'을 고르는 이유가 여기에 있다.
base 요소가 보인다고 적용된 것은 아니다
'self'로 B를 차단한 조건에서도 DOM에는 삽입한 base 요소가 남았다. 그 요소의 href 속성과 .href 프로퍼티 모두 B를 가리켰다. 하지만 document.baseURI는 A의 문서 URL을 유지했고, 상대 링크도 A의 /docs/help.html로 해석됐다. base 요소의 존재나 그 요소의 .href만 읽으면 정책이 실패했다고 오판할 수 있다.
차단 조건에서 수집한 위반 이벤트의 핵심 필드는 다음과 같았다. 원격 보고 서버로 보낸 보고서가 아니라 문서에서 관찰한 이벤트다.
{
"effectiveDirective": "base-uri",
"blockedURI": "http://127.0.0.1:13158/alternate/",
"disposition": "enforce"
}
이벤트 관찰기는 자동화에서 미리 등록했다. 관찰을 위해 페이지의 스크립트 허용 범위를 넓히지는 않았다. 자신의 개발 환경에서 정책을 바꿔 비교할 때도 base 속성값, 실제 document.baseURI, 링크의 해석 주소와 Console의 base-uri 위반을 함께 본다. 차단돼야 할 기준이 여전히 적용된다면 먼저 새 문서 응답에 정책이 실렸는지, 헤더가 보고 전용인 Content-Security-Policy-Report-Only인지 확인한다. CSP3의 base 검사 절차 (새 탭에서 열림)는 강제 정책과 보고 전용 정책을 구분한다.
이번 실행 환경은 Windows 11, Python 3.12.10, Playwright 1.59.0, headless Chromium 147.0.7727.15였다. 검증한 것은 일반 HTTP loopback 문서에서 합성 base를 동적으로 삽입했을 때의 DOM URL과 위반 이벤트다. 다른 브라우저, HTTPS 배포, iframe·srcdoc, HTML 파서가 처음부터 base를 읽는 조건, 실제 링크 이동과 리소스 요청의 허용 여부는 실행하지 않았다.
base-uri는 기준 URL 설정을 제한하는 방어다. 예상 밖 base가 들어온 경로는 별도로 고쳐야 한다. CSP3의 소개 (새 탭에서 열림)도 CSP를 입력 검증과 출력 인코딩의 대체물로 보지 않는다. 상대 주소가 이상할 때는 실제 기준부터 읽고, 문서가 base 변경을 필요로 하는지에 따라 정책을 정한다.