TechFeedTechFeed
Security

SSRF, 링크 미리보기, 넥스트 이미지, 메타데이터 | 서버가 대신 연 주소는 어디서 막을까?

링크 미리보기와 넥스트 이미지가 사용자 URL을 대신 열면 클라우드 메타데이터가 나간다. 허용 호스트, DNS 뒤 사설 아이피, 리다이렉트 금지, remotePatterns, IMDSv2를 보안, 백엔드, Next.js, API, 클라우드, 개발자 기준으로 같은 표에 둔다. 쿠키 CSRF 글과 웹훅 멱등 글과 각을 섞지 않는다.

by

지난달 링크 카드 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.1http://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와 관리용 서브넷으로 나가는 경로를 보안 그룹에서 끊습니다. 미리보기 전용 워커를 바깥만 보게 두고, 알디에스와 레디스는 그 워커에서 안 열리게 합니다. 분산 락과 캐시 글과 자리를 섞지 않습니다. 여기는 출구 아이피만 봅니다.


서울 리전 인스턴스에서 IMDSv2만 필수로 켠다
aws ec2 modify-instance-metadata-options \ --instance-id i-0123456789abcdef0 \ --http-tokens required \ --http-put-response-hop-limit 1 \ --region ap-northeast-2 # 확인 aws ec2 describe-instances \ --instance-ids i-0123456789abcdef0 \ --region ap-northeast-2 \ --query 'Reservations[0].Instances[0].MetadataOptions'

주의 | 위 명령의 인스턴스 아이디는 자리 표시입니다. 콘솔에서 실제 아이디를 넣고, 컨테이너가 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)를 여는 순간 이 표로 돌아옵니다.


next.config.js에서 원격 이미지 출구를 고정한다
module.exports = { images: { remotePatterns: [ { protocol: 'https', hostname: 's3.ap-northeast-2.amazonaws.com', port: '', pathname: '/my-public-og/**', search: '', }, ], maximumRedirects: 0, dangerouslyAllowLocalIP: false, dangerouslyAllowSVG: false, qualities: [75], }, }
사용자 URL, 서버 검사 다섯 칸, 클라우드 메타데이터 차단을 나란히 그린 비교 그림
스킴·호스트·포트·리다이렉트·DNS 뒤 아이피를 한 줄에 두고, IMDS 주소는 워커에서 끊는다

참고 자료


파서 우회와 이미지 리다이렉트 규칙은 수시로 바뀝니다. 위 표는 2026년 8월 공개 페이지 기준 점검용이며, 적용의 최종 근거는 공식 화면입니다. 배포 전에 스테이징에서 메타데이터 주소와 루프백을 한 번씩 넣어 거절되는지 남깁니다.


자주 묻는 질문

허용 목록 없이 차단 목록만으로 되나?

아닙니다. 치트시트는 차단 목록을 마지막 수단으로 둡니다. 믿을 호스트가 있으면 그 이름만 엽니다. 웹훅처럼 외부가 열려야 하면 사설·링크 로컬·메타데이터 이름을 막고, DNS 뒤 아이피를 다시 봅니다.


넥스트 이미지에 호스트 와일드카드만 넣으면 되나?

안 됩니다. 경로와 쿼리를 비우면 와일드로 열립니다. 버킷 이름과 폴더까지 적고, 리다이렉트는 0으로 둡니다. domains 설정은 폐기 안내입니다.


IMDSv2만 켜면 SSRF는 끝나나?

끝나지 않습니다. v2는 GET만 보내는 단순 경로를 한 겹 막습니다. 헤더를 붙일 수 있는 출구나 홉 제한 2 이상은 남습니다. URL 검사와 보안 그룹을 같이 둡니다.


버셀만 쓰는 사이트도 해당되나?

정적 페이지만 있으면 이미지 최적화 출구가 플랫폼에 있습니다. 링크 미리보기나 서버 액션에서 사용자 URL을 열면 그 함수가 출구입니다. 알디에스나 EC2를 쓰는 밤에 이 표를 엽니다.


리다이렉트를 한 번만 따라가면 되나?

따라가지 않습니다. 허용 호스트가 302로 169.254로 넘길 수 있습니다. 넥스트 이미지도 도착지를 다시 검사하지 않습니다. 미리보기는 Location을 거절합니다.


한국 리전 알디에스만 쓰면 메타데이터는 없나?

알디에스 자체 IMDS는 앱이 직접 열 자리가 아닙니다. 앱이 EC2나 ECS, 라이트세일 위에 있으면 그 인스턴스의 169.254가 열립니다. 서울 리전이어도 아이피는 같습니다. 보안 그룹에서 링크 로컬을 앱 서브넷과 분리합니다.


SSRF링크 미리보기넥스트 이미지메타데이터IMDSv2보안백엔드Next.jsAPI클라우드개발자OWASP

함께 보면 좋은 문제 해결

EXPLORE / Security

이어서 읽어보기

전체 토픽 둘러보기