설명
정규표현식에서 점(.)은 "임의의 한 글자"를 의미하는 메타문자입니다. 호스트네임(도메인)을 검증할 때 점을 이스케이프(.)하지 않으면, 의도한 도메인보다 훨씬 넓은 문자열이 매칭되어 검증을 우회할 수 있습니다. 예를 들어 example.com을 허용하려고 /^example.com$/을 사용하면 exampleXcom, example-com 등도 매칭될 수 있습니다. 공격자는 이를 악용해 오픈 리다이렉트나 SSRF 필터를 우회하여 악성 사이트로 리다이렉트하거나 내부망으로 요청을 보내도록 만들 수 있습니다.
잠재적 영향
- 오픈 리다이렉트: 검증을 우회해 임의의 외부 사이트로 사용자를 리다이렉트할 수 있음
- SSRF 우회: 내부망 또는 민감한 엔드포인트로 서버가 요청을 보내도록 유도 가능
- 접근 통제 우회: 특정 도메인으로만 허용된 연계를 임의의 호스트로 확장해 정책을 무력화
- 피싱/브랜드 훼손: 신뢰 도메인으로 가장한 유사 호스트로 연결하여 사용자 기만
해결 방법
- 정규식에서 도메인 라벨 구분 점(.)은 반드시 이스케이프(.) 처리하세요. 예: ^((www|beta).)?example.com$
- 시작(^)과 끝($) 앵커를 사용하여 전체 문자열을 정확히 일치시키세요.
- 가능하면 정규식 대신 허용 목록(allow-list)으로 정확한 문자열 비교를 사용하세요. 예: host === 'example.com' 또는 host가 Set(['a.example.com', ...])에 포함되는지 확인
- URL 파싱 시 표준 API를 사용하고 hostname(포트 제외)을 기준으로 검증하세요.
- 서브도메인 허용이 필요하다면 경계 검사를 명확히 하세요. 예: host === 'example.com' 또는 host.endsWith('.example.com')
- 정규식에서 모호한 패턴(.* , .?, 중첩 그룹)을 피하고 라벨 길이/문자 규칙을 명시적으로 제한하세요.
예시
URL 리다이렉트
변경 전
javascript
const express = require("express");
const app = express();
// /jump?next=https://example.com
app.get("/jump", (req, res) => {
const next = req.query.next;
if (!next) return res.status(400).send("missing");
let host;
try {
host = new URL(next).hostname; // 사용자가 준 URL의 호스트네임
} catch {
return res.status(400).send("bad url");
}
// 취약: '.'를 이스케이프하지 않아 의도보다 넓게 매칭됨
// 예: staticXexampleYcom, static-exampleZcom 등도 통과 가능
const allowed = /^((static|media).)?example.com$/i.test(host);
if (allowed) return res.redirect(next);
return res.status(400).send("blocked");
});
변경 후
javascript
const express = require("express");
const app = express();
// 엄격한 allow-list 기반 검증
const ALLOW_HOSTS = new Set([
"example.com",
"static.example.com",
"media.example.com",
]);
app.get("/jump", (req, res) => {
const next = req.query.next;
if (typeof next !== "string") return res.status(400).send("missing");
let parsed;
try {
parsed = new URL(next);
} catch {
return res.status(400).send("bad url");
}
const host = parsed.hostname.toLowerCase();
const safeTransport =
parsed.protocol === "https:" &&
parsed.port === "" &&
parsed.username === "" &&
parsed.password === "";
// 정확한 호스트와 HTTPS 기본 포트만 허용
if (!safeTransport || !ALLOW_HOSTS.has(host)) {
return res.status(400).send("blocked");
}
return res.redirect(parsed.toString());
});
// 참고: 정규식을 꼭 써야 한다면 다음처럼 점을 이스케이프하세요.
// const ok = /^((static|media)\.)?example\.com$/i.test(host);
설명:
- 변경 전: 정규식에서 점(.)을 이스케이프하지 않아 "."가 임의의 한 글자로 동작합니다. 따라서 '^((static|media).)?example.com$'는 실제 점 구분자가 없어도 매칭될 수 있고, 공격자가 유사 호스트네임을 만들어 검증을 우회하여 오픈 리다이렉트나 SSRF를 유발할 수 있습니다.
- 변경 후: 문자열 입력을 URL API로 파싱하고 허용 목록의 hostname과 정확히 비교하므로 유사 문자열이나 점 대체 문자로는 우회할 수 없습니다. HTTPS 기본 포트만 허용하고 사용자 정보가 포함된 URL도 거부합니다. 정규식을 쓸 경우에도 점을 '.'로 이스케이프하고 앵커를 사용하여 정확히 의도한 도메인만 허용합니다.
브라우저 호스트네임
변경 전
javascript
function checkLocation(document) {
return /^example.com$/.test(globalThis.location.hostname);
}
현재 페이지의 호스트네임을 검사하는 예시입니다. 점을 이스케이프해 실제 점 구분자와 일치시키세요.
변경 후
javascript
function checkLocation(document) {
return /^example\.com$/.test(globalThis.location.hostname);
}