아래 내용은 여러 보안 원칙을 설명하기 위해 구성한 가상의 예시다.
무슨 일이 일어나는가
서버 사이드 렌더링(SSR) 진입점에서 요청마다 이런 코드가 돈다고 하자.
// getServerSideProps 초입
axios.defaults.headers.common = {
Cookie: serverCookies,
Authorization: `Bearer ${accessToken}`,
};
한 줄로 보면 무해해 보인다. “이 요청의 인증을 Axios 기본값에 넣어둔다.” 그런데 axios.defaults는 그 axios 모듈을 import한 모든 코드가 공유하는 프로세스 전역 상태다. 서버 프로세스는 여러 요청을 동시에 처리한다.
Node는 싱글 스레드지만 비동기다. 위 대입과 실제 axios() 호출 사이에는 await가 있고, 그 사이에 다른 사용자의 요청이 끼어들어 같은 전역을 덮어쓴다.
① 요청 A(사용자 A) → defaults.common = { A의 쿠키, A의 토큰 }
② A가 await로 양보
③ 요청 B(사용자 B) → defaults.common = { B의 쿠키, B의 토큰 } ← 덮어씀
④ A 재개 → axios() 호출 → B의 토큰으로 나간다
여기까지만으로도 권한 혼동과 타 사용자 데이터 노출이 발생할 수 있다. A의 요청이 B의 토큰으로 내부 API를 호출하고 그 응답을 A에게 돌려주면 이미 사고다. 여기에 같은 코드베이스의 외부 URL 프록시가 결합되면 자격증명이 서버 밖으로 직접 전송되는 경로까지 생긴다.
// /api/proxy?url=... — 클라이언트가 준 URL을 서버가 대신 받아온다
export default async function handler(req, res) {
const { url } = req.query;
const response = await axios.get(url, { responseType: 'arraybuffer' }); // ← 전역 default를 그대로 탄다
res.send(response.data);
}
이 핸들러는 전역 axios를 쓴다. 따라서 그 시점에 전역 defaults에 남아 있는 Cookie·Authorization이 이 요청에 실릴 수 있다. 그리고 url은 클라이언트가 준 값을 프로토콜 검사만 하고 그대로 부른다(호스트 검증 없음). 정리하면:
이 프록시가 인증 없이 노출돼 있고 공격자가
url을 제어할 수 있다면, 서버는 전역 defaults에 남은 다른 사용자의 세션 쿠키와 토큰을 공격자가 지정한 서버로 보낼 수 있다.
두 개의 흔한 실수가 겹쳐서 만들어진 자격증명 탈취 경로다. 하나는 요청별 인증정보를 전역 가변 상태에 얹은 것(CWE-488·CWE-362), 다른 하나는 allowlist 없는 서버 사이드 URL 프록시(CWE-918, SSRF).
이미 알려진 위험의 조합
두 축 모두 새로운 종류의 문제가 아니다.
한쪽은 SSRF다. 외부 URL을 서버가 대신 가져오면서 목적지를 충분히 제한하지 않은 경우는 여러 제품에서 이미 취약점으로 분류됐다.
- 이미지 프록시인 imgproxy는 입력 URL 검증 부족으로
CVE-2023-30019을 받았다. - 검색 서비스 LibreY의
image_proxy.php도 임의 목적지로 요청할 수 있어CVE-2023-41054로 분류됐다.
다른 한쪽은 요청별 데이터를 전역에 두는 것이다. 서버는 여러 요청을 한 프로세스에서 동시에 처리하므로, 요청 스코프 값을 모듈 전역이나 공유 객체에 담으면 요청 사이에 섞인다. 이는 특정 프레임워크의 문제가 아니라 동시성 환경에서 공유 가변 상태에 요청 데이터를 둘 때 발생하는 오래된 문제다. Axios의 공유 defaults에 요청별 인증정보를 담는 것도 이 범주에 해당한다.
이 선례들이 가정한 조합 전체와 1:1로 같다는 뜻은 아니다. 다만 두 원칙은 그대로 겹친다 — 사용자가 정한 URL을 서버가 가져오는 경로는 목적지 검증이 보안 경계이고, 요청 인증은 전역이 아니라 요청 스코프에 있어야 한다. 둘이 만나면 일반적인 SSRF보다 직접적인 자격증명 유출 경로가 생긴다.
방어는 두 경계를 분리해서 세운다. 먼저 프록시는 전역 Axios를 사용하지 않는 전용 인스턴스를 만들고, 생성 시 상속됐을 수 있는 민감 헤더를 명시적으로 제거한다. URL은 직접 통제하는 호스트의 allowlist로 좁히고 프로토콜·포트·사설 주소 해석 가능성을 함께 검사한다. 리다이렉트 추적도 끊는다. 최초 URL만 검사하면 302로 검증을 우회할 수 있기 때문이다.
const client = axios.create({ maxRedirects: 0 });
delete client.defaults.headers.common.Cookie;
delete client.defaults.headers.common.Authorization;
const parsed = new URL(url);
if (
parsed.protocol !== 'https:' ||
parsed.port !== '' ||
!ALLOWED_HOSTS.has(parsed.hostname)
) {
return res.status(400).end();
}
await client.get(parsed.toString());
예시는 Cookie와 Authorization 두 인증 헤더만 보여준다. 실제 구현에서는 전역 defaults 오염 자체를 제거하고, 프록시 전용 클라이언트가 허용된 헤더만 사용하도록 구성하는 편이 안전하다.
axios.create()도 생성 시점의 defaults를 상속할 수 있으므로 “인스턴스를 만들었다”만으로는 충분하지 않다. 민감 헤더가 없다는 것을 명시적으로 보장하고, 허용 호스트가 사설 IP로 해석되거나 공격자가 DNS를 바꿀 수 없는지도 운영 경계에서 확인해야 한다.
여기까지는 교과서다. 어려운 지점은 그다음이다.
그런데, 어떻게 검증해야 하나
리뷰에서 이런 구조를 발견했다고 가정해 보자. 다음 쟁점은 “이게 실제로 위험한가”를 어떤 증거로 판단할 것인가다.
“로컬과 배포 환경은 다르니 배포 환경에서 재현해야 한다”는 요구는 합리적으로 들린다. 그런데 이 취약점에는 함정이 있다.
공유 배포 환경에서 실제 자격증명을 대상으로 재현하면 공격과 같은 결과를 만들 수 있다. 서버가 자격증명을 실어 보낼 수신 지점이 필요하고, 격리하지 않으면 그 프로세스를 거친 실제 사용자의 살아 있는 세션 토큰을 수집할 수 있기 때문이다. 이런 재현은 해서는 안 된다.
그렇다고 “공유 환경에서 재현하지 못했다”가 취약점이 없다는 증거는 아니다. 필요한 것은 더 위험한 재현이 아니라 격리된 프로세스, 합성 토큰, 통제된 수신 서버로 구성한 안전한 하네스다. 실제 사용자 요청이 섞일 가능성이 있다면 정적·구조적 증거에서 멈춰야 한다.
대신 증거를 세 단계로 쌓을 수 있다. 먼저 공개된 CVE를 통해 사용자 입력 URL을 서버가 가져오는 구조가 이미 알려진 SSRF 클래스임을 확인한다. 다음으로 전역 defaults가 요청 사이에 공유되는 코드 경로를 추적하고, 격리된 로컬 하네스에서 합성 토큰만 사용해 요청 간 전파 여부를 검증한다. 마지막으로 검토한 코드와 실제 실행될 코드가 동일한지 확인한다. CVE와 가정한 코드가 1:1로 같다고 주장하지 않아도, 위험을 만드는 두 조건이 연결되는지는 설명할 수 있다.
이런 취약점에서는 재현을 한 단계 더 밀어붙이기 전에 “이게 무슨 클래스이고, 그 클래스는 이미 어떻게 판정됐는가”를 먼저 확인해야 한다. 논의의 무게중심을 특정 환경의 우연한 결과에서 구조적 위험과 안전한 검증 조건으로 옮기는 것이다.
정적 분석으로 잡을 성격이다 — 다만
여기까지 오면 드는 생각은 하나다. 이걸 사람의 리뷰에만 맡겨야 하나.
이 버그의 성질을 보면, axios.defaults에 요청 인증을 얹는 것도, allowlist 없는 URL 프록시도, 서버 모듈 스코프의 가변 전역도 사람의 주의력보다 정적 분석이 먼저 찾기 좋은 패턴이다. 더 단순한 예로 테스트 파일이 pages/ 아래에 놓여 Next가 그 파일을 라우트로 빌드하는 경우가 있다. 이런 “위치만 봐도 아는 것”은 CI에서 확실하게 막을 수 있다.
그런데 여기서 “그러니 린트 규칙으로 전부 막자”로 가면 과하다.
- allowlist 없는 프록시나 전역 가변 상태는 데이터 흐름을 봐야 정확히 잡힌다. 순수 문법 룰로 밀어붙이면 오탐이 쌓이고, 오탐이 쌓이면 팀이 룰을 꺼버린다. 그게 룰 없는 것보다 나쁘다.
- 규칙 하나를 CI에 박는 비용은 “작성”이 아니라 “유지”다. 프레임워크와 코드 형태가 바뀔 때마다 룰이 같이 늙는다.
그래서 선은 이렇게 긋는 게 맞다고 본다. 위치·문법으로 확실히 잡히는 것(예: pages/ 하위 테스트)은 CI에서 막는다. 데이터 흐름·휴리스틱이 필요한 규칙은 Semgrep·CodeQL 같은 도구로 먼저 감사 모드에서 운영하고, 오탐이 충분히 낮은 고신뢰 규칙만 차단 게이트로 승격한다. 정적 분석은 사람을 대체하는 게 아니라, 사람이 매번 기억하고 매번 설득하는 비용을 줄이는 쪽으로 쓴다.
그래서 결론
이런 문제에서 느려지기 쉬운 지점은 코드보다 의사결정이다. 수정은 몇 줄이어도 “정말 위험한가”, “어떤 증거면 충분한가”를 정하는 데 시간이 든다. 정적 분석은 그중 일부인 패턴 발견을 앞당길 뿐, 위험의 판정까지 대신해주지는 않는다. 거기에는 다른 재료가 필요하다.
- 증거 등급 기준. “검토 코드가 실행 대상과 동일하고, 격리된 하네스에서 합성 자격증명으로 결정적으로 재현된다”면 충분하다는 기준을 미리 정한다. 매번 실제 사용자 트래픽이 있는 환경에서 재현을 요구하면, 재현 자체가 사고가 될 수 있는 취약점은 안전하게 고칠 수 없다.
- 선례 매핑. 문제를 이미 이름 붙은 클래스(CVE·CWE)와 빠르게 연결하되, 선례와 가정한 코드가 1:1로 같다고 과장하지 않는다. 선례는 판정을 대신하는 답이 아니라 무엇을 검증해야 하는지 알려주는 지도다.
- 안전한 재현 하네스. 실제 모듈의 동작을 격리된 환경에서 확인하고 헤더·응답을 캡처하는 로컬 하네스를 준비한다. 합성 토큰만 사용하고 외부로 아무것도 내보내지 않는다.
전역 가변 상태에 요청 데이터를 얹지 말라는 오래된 규칙이 SSRF와 만나면 위험이 어디까지 커질 수 있는지 살펴봤다. 버그를 고치는 것보다 같은 클래스가 다시 못 들어오게 하는 게 남는 일이고, 그 일부는 정적 분석으로 앞당길 수 있다. 다만 전부 린트로 강제하는 건 과하다 — 확실한 건 막고, 애매한 건 경고로 두고, 나머지는 사람이 빠르게 판단하게 재료(선례·증거 기준·하네스)를 갖춰두는 것. 거기까지가 결론이다.