SSRF, 링크 미리보기, 넥스트 이미지, 메타데이터 | 서버가 대신 연 주소는 어디서 막을까?
링크 미리보기와 넥스트 이미지가 사용자 URL을 대신 열면 클라우드 메타데이터가 나간다. 허용 호스트, DNS 뒤 사설 아이피, 리다이렉트 금지, remotePatterns, IMDSv2를 보안, 백엔드, Next.js, API, 클라우드, 개발자 기준으로 같은 표에 둔다. 쿠키 CSRF 글과 웹훅 멱등 글과 각을 섞지 않는다.
지난달 링크 카드 API를 붙이다가, 사용자가 넣은 주소가 169.254.169.254를 가리켰습니다. 서버가 그 주소를 대신 열면 클라우드 역할 키가 나갑니다. 쿠키 글은 브라우저가 보내는 요청을 보고, 여기는 서버가 대신 여는 주소만 봅니다. 허용 호스트가 없으면 차단 목록은 우회됩니다. 넥스트 이미지의 원격 패턴과 로컬 아이피 플래그를 먼저 좁힙니다. 저는 스테이징에서 메타데이터 주소를 한 줄 넣어 거절 로그가 남는지부터 봅니다.
웹훅이 여러 번 오는 자리는 멱등 키 글에 두고, 키 유출은 환경변수 체크에 둡니다. 숫자는 2026년 8월 OWASP 치트시트와 넥스트 이미지 문서, AWS IMDSv2 기준입니다.
서버가 대신 연 주소가 왜 위험한가
서버 사이드 요청 위조(SSRF)는 앱이 사용자가 준 URL로 대신 요청을 보내는 구멍입니다. 브라우저는 내부망에 못 들어가도, 서버는 같은 VPC와 링크 로컬 주소에 닿습니다. 응답이 그대로 돌아오면 메타데이터와 관리 콘솔이 밖으로 나갑니다.
제가 12사이트 넥스트를 버셀과 알디에스에 나눠 두며 반복한 실수는 이렇습니다. 파트너 글의 오픈 그래프 이미지를 서버에서 받아 썸네일로 쓰려다, URL 칸을 그대로 fetch에 넣었습니다. 로컬에서는 정상이었습니다. 스테이징에서 같은 칸에 메타데이터 주소를 넣자 역할 자격 증명이 본문에 섞였습니다. 쿠키 SameSite는 사파리 로그인 글에 있고, 여기는 서버가 여는 출구만 봅니다.
OWASP 2021 톱10의 A10이 이 자리입니다. 이미지 URL, 웹훅 콜백, 링크 미리보기, PDF 변환, 자막 다운로드처럼 주소 한 칸이 있는 기능이 시작점입니다. 헤더 체크는 CSP 체크리스트에 두고, 그 헤더는 이 출구를 막지 않습니다.
한 줄 가드 | 사용자가 준 전체 URL을 그대로 열지 않습니다. 스킴은 http/https만, 호스트는 허용 목록, DNS 조회 뒤 아이피는 사설·링크 로컬이 아니어야 합니다. 리다이렉트는 끕니다.
링크 미리보기 한 칸이 메타데이터 주소를 친다
링크 카드는 보통 제목과 이미지를 서버가 가져옵니다. 그 순간 서버는 프록시가 됩니다. http://169.254.169.254/latest/meta-data/iam/security-credentials/는 AWS 인스턴스 메타데이터입니다. GCP와 애저도 같은 아이피를 씁니다. 국내 클라우드도 인스턴스 메타데이터 칸이 있고, 주소는 벤더 문서를 봅니다.
IMDSv1은 GET 한 번이면 됩니다. IMDSv2는 먼저 PUT으로 토큰을 받고 헤더에 붙입니다. 단순 SSRF는 GET만 보내는 경우가 많아 v2가 한 겹을 막습니다. 토큰 TTL 기본 홉 제한은 1입니다. 컨테이너에서 쓰려면 홉을 올리는데, 올리면 다시 프록시 경로가 열립니다. 역할 ARN을 액션에서 빌리는 자리는 OIDC 글에 있고, 여기는 인스턴스가 들고 있는 임시 키만 봅니다.
웹훅 URL도 같습니다. 결제사 콜백은 우리가 받는 입구이고, 사용자가 넣는 콜백은 우리가 치는 출구입니다. 재시도 폭주는 멱등 키 글에 있습니다. 출구는 허용 호스트와 POST 본문 토큰을 같이 겁니다. 응답 본문을 사용자에게 그대로 보여 주면, 막힌 줄 알아도 내부 에러 페이지가 새어 나갑니다.
허용 호스트와 DNS 뒤 사설 아이피를 같이 막는다
const { isIP, BlockList } = require('net')
const dns = require('dns').promises
const blocked = new BlockList()
blocked.addSubnet('0.0.0.0', 8)
blocked.addSubnet('10.0.0.0', 8)
blocked.addSubnet('127.0.0.0', 8)
blocked.addSubnet('169.254.0.0', 16)
blocked.addSubnet('172.16.0.0', 12)
blocked.addSubnet('192.168.0.0', 16)
blocked.addAddress('::1', 'ipv6')
blocked.addSubnet('fc00::', 7, 'ipv6')
blocked.addSubnet('fe80::', 10, 'ipv6')
const allowHosts = new Set(['og.example.com', 'img.example.com'])
function isBlocked(ip) {
const v = isIP(ip)
if (v === 4) return blocked.check(ip, 'ipv4')
if (v === 6) return blocked.check(ip, 'ipv6')
return true
}
async function assertSafeHttpUrl(raw) {
const u = new URL(raw)
if (u.protocol !== 'https:' && u.protocol !== 'http:') {
throw new Error('scheme')
}
if (!allowHosts.has(u.hostname.toLowerCase())) {
throw new Error('host')
}
const looked = await dns.lookup(u.hostname, { all: true })
for (const row of looked) {
if (isBlocked(row.address)) throw new Error('private')
}
return u.href
}
링크 미리보기 URL을 열기 전에 호스트와 DNS 뒤 아이피를 같은 함수에서 본다
허용 목록 다섯 칸을 같은 표에 둔다
OWASP 치트시트는 믿을 대상이 정해져 있으면 허용 목록을 먼저 쓰라고 적습니다. 호스트만 믿지 않습니다. 스킴, 포트, 경로, 리다이렉트, DNS 뒤 아이피가 한 세트입니다.
칸
허용
빠지면
스킴
http, https만
file, gopher, dict가 로컬 파일을 연다
호스트
허용 목록 문자열 일치
임의 도메인이 프록시가 된다
포트
443 또는 80만
내부 6379, 9200을 스캔한다
리다이렉트
따르지 않음
허용 호스트가 사설 아이피로 넘긴다
DNS 뒤 아이피
공인만, 조회 직후 연결
이름만 통과하고 아이피는 127.0.0.1
전체 URL을 받아 파서에 맡기지 말라고 치트시트가 적습니다. 언어마다 URL 파서가 다릅니다. http://127.0.0.1과 http://127.1, http://0x7f000001, 괄호 아이피, 사용자 정보 칸의 @가 호스트를 바꿉니다. 라이브러리로 아이피 형식을 검증한 뒤, 그 출력값으로 허용 목록을 비교합니다. 앱 라우터 배포 칸은 프로덕션 체크에 있습니다.
차단 목록만 넣으면 오히려 구멍이 남는다
웹훅처럼 외부 아무 주소나 받아야 하면 허용 목록이 안 됩니다. 그때만 차단 목록이 나옵니다. 치트시트도 마지막 수단이라고 적습니다. 문자열로 169.254.169.254만 지우면, 십육진과 짧은 표기, DNS 리바인딩이 남습니다.
최소로 막을 대역은 루프백 127.0.0.0/8, 사설 10/8·172.16/12·192.168/16, 링크 로컬 169.254.0.0/16, IPv6 루프백과 유니크 로컬입니다. AWS는 metadata.amazonaws.com, GCP는 metadata.google.internal도 같이 막습니다. 막은 뒤에도 DNS를 다시 조회해 공인 아이피인지 확인합니다.
네트워크 칸이 더 셉니다. 앱 서버에서 IMDS와 관리용 서브넷으로 나가는 경로를 보안 그룹에서 끊습니다. 미리보기 전용 워커를 바깥만 보게 두고, 알디에스와 레디스는 그 워커에서 안 열리게 합니다. 분산 락과 캐시 글과 자리를 섞지 않습니다. 여기는 출구 아이피만 봅니다.
주의 | 위 명령의 인스턴스 아이디는 자리 표시입니다. 콘솔에서 실제 아이디를 넣고, 컨테이너가 IMDS를 써야 하면 홉 제한을 올리기 전에 사이드카 경로를 먼저 좁힙니다. IMDSv2는 겹겹이 방어이지, URL 검사를 대체하지 않습니다.
IMDSv2를 필수로 바꾸고, 미리보기 워커에서 메타데이터 대역을 보안 그룹으로 끊는다
넥스트 이미지 원격 패턴을 좁히는 순서
넥스트의 /_next/image는 서버가 원격 이미지를 받아 리사이즈합니다. images.domains는 14부터 폐기 안내입니다. 호스트만 열고 경로를 못 좁혀서, 같은 버킷의 다른 객체를 최적화 엔드포인트로 부를 수 있습니다. remotePatterns로 프로토콜, 호스트, 경로, 쿼리를 같이 고정합니다.
공식 문서는 리다이렉트를 따라갈 때 도착지를 다시 검사하지 않는다고 적습니다. maximumRedirects 기본은 3입니다. 사용자 입력이 섞이면 0으로 둡니다. dangerouslyAllowLocalIP는 16부터 있는 플래그이고 기본은 꺼짐입니다. VPC 스플릿 호라이즌 DNS에서 400이 날 때만 검토하고, 켜기 전에 SSRF 위험을 문서에 남깁니다. SVG는 기본 최적화를 안 합니다. dangerouslyAllowSVG를 켜면 CSP와 contentDispositionType: 'attachment'를 같이 겁니다.
버셀만 쓰는 정적 사이트는 이미지 최적화가 플랫폼 쪽에 있습니다. 그래도 앱이 링크 미리보기 API를 들고 있으면 그 라우트가 출구입니다. 서버 액션 본문은 서버 액션 글에 있고, 액션 안에서 fetch(userUrl)를 여는 순간 이 표로 돌아옵니다.