사용자로부터 URL을 받아 서버에서 가져오는 모든 기능은 잠재적인 서버 측 요청 위조(SSRF, CWE-918)입니다. 웹훅, 링크 미리보기, URL로 아바타 가져오기, RSS와 캘린더 임포트, PDF 렌더러, "URL에서 가져오기" 버튼이 모두 같은 형태를 공유합니다. 다른 사람이 고른 목적지로 여러분의 서버가 요청을 보내는 것입니다.
문제는 서버가 놓인 위치입니다. 서버는 공격자가 닿을 수 없는 것에 닿을 수 있습니다. 클라우드 메타데이터 서비스, 내부 관리자 패널, 사설망에서 비밀번호 없이 동작하는 데이터베이스, localhost의 서비스가 그렇습니다. SSRF는 여러분의 서버를 그 네트워크로 들어가는 공격자의 프록시로 바꿔 놓습니다. 작은 팀일수록 특히 쉽게 만들어집니다. 문제를 일으키는 기능이 무해해 보이기 때문입니다. 설정 페이지의 URL 입력란, 채팅의 미리보기 카드, 프로필 사진을 내려받는 헬퍼 같은 것들이죠. 어느 것도 네트워크 보안 코드처럼 보이지 않으니 그렇게 검토되는 일도 드뭅니다.
공격자가 노리는 것
- 클라우드 메타데이터 엔드포인트. AWS와 GCP, Azure의 169.254.169.254가 가장 유명하며, 인스턴스 정보와 일부 구성에서는 머신 역할의 임시 자격 증명을 반환할 수 있습니다.
- 관리자 인터페이스, 디버그 서버, 메트릭 엔드포인트처럼 로컬 호출자만 있다고 가정하고 localhost에 바인딩된 서비스.
- 외부에서 접근할 수 없다고 가정해 인증이 없는 사설 대역(10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)의 내부 서비스.
- 응답 시간이나 오류 메시지를 이용해 내부 네트워크를 파악하는 포트 스캔과 서비스 탐색.
- 페치 라이브러리가 file:, gopher:, ftp: 같은 스킴을 지원하는 경우의 비 HTTP 프로토콜.
응답이 공격자에게 돌아가지 않더라도, 블라인드 SSRF는 내부 엔드포인트에 상태를 변경하는 요청을 일으킬 수 있습니다. 웹훅은 보통 블라인드이지만 그렇다고 안전한 것은 아닙니다. 많은 내부 서비스가 캐시 비우기나 URL 뒤에 숨은 관리자 동작처럼 상태를 바꾸는 단순 GET 요청을 받아들이는데, 바로 외부의 누구도 접근할 수 없다고 가정하기 때문입니다.
단순한 검사가 실패하는 이유
처음 떠오르는 생각은 URL을 파싱해 localhost 같은 호스트명이나 10. 또는 192.168.로 시작하는 문자열을 거부하는 것입니다. 이것은 여러 이유로 실패합니다.
- 호스트명은 IP로 해석됩니다. 공격자는 A 레코드가 127.0.0.1이나 169.254.169.254를 가리키는 도메인을 등록하고, 호스트명 검사는 아무 이상도 발견하지 못합니다.
- IP 주소에는 여러 표기법이 있습니다. 10진수(2130706433), 16진수, 127.1 같은 축약형, ::ffff:127.0.0.1 같은 IPv4 매핑 IPv6가 그렇습니다. 문자열 매칭은 이들을 놓칩니다.
- DNS 리바인딩: 검증할 때는 공개 IP로 해석되던 도메인이, 잠시 뒤 HTTP 클라이언트가 연결할 때는 사설 IP로 해석됩니다. 검증과 연결은 별개의 조회입니다.
- 리디렉션: 검증을 통과한 URL이 http://169.254.169.254/로 가는 302를 반환하면, HTTP 클라이언트는 묻지도 않고 따라갑니다.
공통점은 검증이 소켓이 실제로 연결하는 주소가 아닌 다른 것을 대상으로 이루어진다는 점입니다. 해결책은 연결 시점에 해석된 IP를 모든 연결마다, 리디렉션 이후에도 확인하는 것입니다.
강한 것부터 살펴보는 방어 수단
가능하면 목적지를 허용 목록으로
기능이 몇몇 제공자와의 연동처럼 알려진 호스트 집합하고만 통신하면 된다면, 그 호스트명을 허용 목록에 넣고 나머지는 모두 거부하세요. 가장 강력하면서 이해하기도 가장 쉬운 통제입니다. 파싱된 호스트명을 정확히 비교하세요. startsWith나 endsWith 검사는 api.example.com.attacker.net이나 evilexample.com 같은 유사 호스트를 통과시킵니다. 아래 내용은 임의의 공개 URL을 받아야만 하는 기능을 위한 것입니다.
스킴과 포트를 제한하세요
https:만 허용하세요(꼭 필요할 때만 http:도). URL에 담긴 자격 증명을 거부하고, 명확한 필요가 없다면 포트를 443과 80으로 제한하세요. 정규식이 아니라 표준 URL 파서로 파싱하세요. WHATWG 파서는 특이한 IP 표기도 정규화해 주므로 이후 검사에 도움이 됩니다.
해석한 뒤 연결 시점에 IP를 확인하세요
루프백, 사설, 링크 로컬, 캐리어급 NAT, 미지정 대역과 IPv6의 고유 로컬 및 링크 로컬 대역을 차단하세요. 무엇보다 이 검사를 HTTP 클라이언트가 사용하는 DNS 조회 안에서 수행해, 검증된 주소가 곧 연결되는 주소가 되게 하세요. 그래야 DNS 리바인딩 틈이 닫힙니다. Node의 http와 https 모듈은 바로 이 목적으로 커스텀 lookup 함수를 받습니다.
아래 차단 목록은 대부분의 배포 환경에서 중요한 대역을 담고 있습니다. 자체 인프라에 속하는 공개 대역이 있다면 추가하세요. 공개 IP를 가진 로드 밸런서나 내부 API도 네트워크 내부에서 온 요청을 신뢰할 수 있기 때문입니다. 호스트명에 해석된 주소 중 하나라도 차단 대상이면 첫 번째만이 아니라 그 호스트명 전체를 거부하세요. 클라이언트가 어떤 순서로든 시도할 수 있기 때문입니다.
// safe-lookup.js
import dns from "node:dns";
import net from "node:net";
const blocked = new net.BlockList();
blocked.addSubnet("0.0.0.0", 8, "ipv4");
blocked.addSubnet("10.0.0.0", 8, "ipv4");
blocked.addSubnet("100.64.0.0", 10, "ipv4");
blocked.addSubnet("127.0.0.0", 8, "ipv4");
blocked.addSubnet("169.254.0.0", 16, "ipv4");
blocked.addSubnet("172.16.0.0", 12, "ipv4");
blocked.addSubnet("192.168.0.0", 16, "ipv4");
blocked.addAddress("::", "ipv6");
blocked.addAddress("::1", "ipv6");
blocked.addSubnet("::ffff:0:0", 96, "ipv6"); // IPv4 매핑
blocked.addSubnet("fc00::", 7, "ipv6");
blocked.addSubnet("fe80::", 10, "ipv6");
export function isBlockedIp(address, family) {
return blocked.check(address, family === 6 ? "ipv6" : "ipv4");
}
// 소켓이 연결되기 전에 해석된 모든 주소를 검증합니다
export function safeLookup(hostname, options, callback) {
dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
if (err) return callback(err);
const denied =
addresses.length === 0 ||
addresses.some((a) => isBlockedIp(a.address, a.family));
if (denied) {
return callback(new Error("Destination not allowed: " + hostname));
}
if (options.all) return callback(null, addresses);
callback(null, addresses[0].address, addresses[0].family);
});
}놓치기 쉬운 세부 사항이 하나 있습니다. URL에 IP 리터럴이 들어 있으면 Node는 lookup을 전혀 호출하지 않고 바로 연결합니다. 그래서 요청 함수가 넘기기 전에 IP 리터럴을 직접 확인해야 합니다.
import https from "node:https";
import net from "node:net";
import { isBlockedIp, safeLookup } from "./safe-lookup.js";
export function postWebhook(rawUrl, payload) {
const url = new URL(rawUrl);
if (url.protocol !== "https:") throw new Error("Only https is allowed");
if (url.port && url.port !== "443") throw new Error("Port not allowed");
if (url.username || url.password) throw new Error("Credentials not allowed");
const host = url.hostname.replace(/^\[|\]$/g, "");
const family = net.isIP(host);
if (family !== 0 && isBlockedIp(host, family)) {
throw new Error("Destination not allowed");
}
return new Promise((resolve, reject) => {
const req = https.request(
url,
{
method: "POST",
lookup: safeLookup,
timeout: 5000,
headers: { "content-type": "application/json" },
},
(res) => {
// node:https는 리디렉션을 따르지 않습니다. 3xx는 실패로 처리합니다
res.resume();
resolve(res.statusCode);
}
);
req.on("timeout", () => req.destroy(new Error("Request timed out")));
req.on("error", reject);
req.end(JSON.stringify(payload));
});
}Node에서 undici 기반의 fetch를 쓴다면 커스텀 디스패처로 같은 발상을 적용할 수 있습니다. connect.lookup 옵션을 준 undici Agent를 만들어 디스패처로 전달하면 됩니다. 원칙은 달라지지 않습니다. 검사는 연결이 이루어지는 곳에 있어야 합니다. 환경이 아웃바운드 트래픽을 HTTP 프록시로 보낸다면, 대상 호스트에 대한 조회가 프록시에서 일어나므로 프록시 자체가 같은 규칙을 강제해야 합니다.
리디렉션을 끄거나 각 홉을 다시 검증하세요
웹훅이라면 리디렉션을 아예 따르지 마세요. 리디렉션하는 수신자는 잘못 설정된 것이고, 눈에 띄게 실패시키면 고객이 자신의 엔드포인트를 고치게 됩니다. 리디렉션이 정상인 링크 미리보기와 임포터에서는 작은 제한을 두고 수동으로 따라가되, 모든 홉을 동일한 스킴과 포트, IP 검사에 통과시키세요. fetch에서는 redirect: "manual"을 설정하고 Location 헤더를 직접 처리하면 됩니다.
성공한 요청이 할 수 있는 일을 제한하세요
- 연결 타임아웃과 전체 타임아웃을 설정하고 응답 크기에 상한을 두어, 페처가 연결을 붙잡아 두거나 대용량 파일을 끌어오는 데 쓰이지 못하게 하세요.
- 원시 응답이나 상세한 오류 메시지를 사용자에게 반환하지 마세요. 링크 미리보기라면 추출한 제목과 설명, 이미지 URL만 반환하세요.
- 자체 인증 헤더와 쿠키를 제거하세요. 페처가 사용자가 제공한 호스트로 내부 자격 증명을 보내는 일은 결코 없어야 합니다.
이그레스 프록시를 경유시키세요
가장 견고한 구성은 정책을 애플리케이션 코드 밖으로 옮깁니다. 사용자가 유발하는 아웃바운드 페치를 전용 이그레스 프록시를 통해 실행하거나, 내부 서비스와 메타데이터 엔드포인트로 가는 경로가 아예 없는 네트워크 구간의 격리된 워커에서 실행하세요. 그러면 URL 검증에 버그가 있어도 침해로 이어지지 않습니다. 네트워크 자체가 거부하기 때문입니다. 오픈소스 포워드 프록시로 목적지 규칙을 강제할 수 있고, 별도의 워커는 느리거나 악의적인 응답을 주 웹 프로세스에서 격리해 주기도 합니다.
메타데이터 서비스 강화하기
AWS에서는 PUT 요청으로 얻은 세션 토큰이 필요한 IMDSv2를 요구하고, 컨테이너가 호스트를 거쳐 접근하지 못하도록 홉 제한을 1로 유지하세요. GCP와 Azure는 메타데이터 호출에 특정 요청 헤더를 요구합니다. 이런 조치는 단순한 GET 기반 SSRF의 난도를 상당히 높여 주지만, 링크 로컬 주소 차단을 대신하는 것이 아니라 두 번째 계층입니다. 또한 인스턴스나 서비스 역할에는 앱에 필요한 권한만 부여해, 메타데이터 서비스를 통해 탈취된 자격 증명이 열 수 있는 것을 최대한 줄이세요.
방어 수단 테스트하기
- http://127.0.0.1/, http://[::1]/, http://2130706433/, http://127.1/, http://169.254.169.254/latest/meta-data/를 시도해 각각 거부되는지 확인하세요.
- 여러분이 소유한 호스트명을 127.0.0.1로 향하게 한 뒤 조회 검사가 거부하는지 확인하세요. 그런 다음 공개 IP 하나와 사설 IP 하나로 A 레코드를 두 개 두고도 여전히 거부되는지 확인하세요.
- 공개 호스트에서 사설 주소로 가는 302 리디렉션을 제공하고 따라가지 않는지 확인하세요.
- file:, ftp:, gopher: URL과 자격 증명이 담긴 URL, 특이한 포트를 쓴 URL을 시도해 보세요.
코드 리뷰에서는 요청 URL이 입력으로부터 만들어지는 모든 곳을 찾으세요. fetch, axios, got, requests, http.Get, 헤드리스 브라우저의 page.goto 호출, URL을 받는 이미지 처리 라이브러리가 대상입니다. CodeAuditAgent는 공개 저장소와 붙여넣은 스니펫에서 그 데이터 흐름을 추적해, SSRF를 CWE-918로 인용한 호출 지점과 공격 시나리오, 패치와 함께 보고합니다.