와일드카드 인증서는 HTTP-01으로 못 받습니다 — DNS-01이 강제하는 IAM 결정
멀티테넌트 서비스에서 {테넌트}.example.com 형태의 주소를 주려면 와일드카드 인증서가 필요합니다. 간단한 작업이라고 생각하고 시작했는데, 실제로 시간을 들여야 했던 곳은 인증서가 아니라 DNS 쓰기 권한을 누구에게 얼마나 주느냐였습니다.
와일드카드는 HTTP-01으로 발급되지 않습니다
Let's Encrypt의 ACME 챌린지는 크게 두 가지입니다. 평소 쓰던 것은 HTTP-01이었습니다. 서버가 /.well-known/acme-challenge/ 아래에 파일을 하나 놓으면 CA가 그걸 읽어 소유를 확인하는 방식입니다. 프록시가 알아서 처리하니 신경 쓸 일이 없었습니다.
그런데 와일드카드는 이 방법으로 받을 수 없습니다. 규격상 금지되어 있습니다. 생각해보면 당연한 이야기입니다. HTTP-01은 특정 호스트 하나에 파일을 놓아 그 호스트의 통제권을 증명합니다. 그런데 *.example.com은 아직 존재하지도 않는 무한한 호스트들을 포함합니다. 어느 호스트에 파일을 놓아야 그 전부를 증명한 것이 되는지 정의할 수가 없습니다.
그래서 남는 것은 DNS-01 하나입니다. _acme-challenge.example.com에 TXT 레코드를 심어 도메인 자체의 통제권을 증명하는 방식입니다. 호스트가 아니라 존(zone)을 증명하니 와일드카드가 성립합니다.
| 항목 | HTTP-01 | DNS-01 |
|---|---|---|
| 증명 대상 | 호스트 하나 | 도메인(존) |
| 와일드카드 | 불가 | 가능 |
| 필요한 권한 | 없음 (파일 쓰기) | DNS 레코드 쓰기 |
표의 마지막 행이 이 글의 전부입니다. 챌린지 방식을 바꾸는 순간 프록시 컨테이너가 DNS를 고칠 수 있어야 한다는 새로운 요구가 생깁니다.
권한을 주는 순간 폭발 반경이 생깁니다
DNS가 Route53이었으므로 Traefik의 route53 프로바이더를 쓰면 됩니다. 문서를 보면 자격증명을 환경변수로 넣으라고 나옵니다. 액세스 키를 발급해서 .env에 박는 흔한 방법입니다.
여기서 잠깐 멈췄습니다. 이 서버는 EC2였고 이미 인스턴스 역할이 붙어 있었습니다. 정적 키를 새로 만들어 파일에 적어두는 것보다, 인라인 정책 하나를 기존 역할에 얹는 편이 훨씬 깨끗합니다. 회수도 정책 삭제 한 줄이면 끝납니다.
그런데 그 다음이 문제였습니다. 필요한 권한은 route53:ChangeResourceRecordSets입니다. 아무 조건 없이 이 권한을 주면 어떻게 되는지 곰곰이 생각해보니 답이 명확했습니다. 그 존의 A 레코드를 임의의 IP로 바꿀 수 있습니다. 도메인 탈취입니다.
"컨테이너가 뚫려야 가능한 일 아닌가요"
처음에는 저도 그렇게 생각했습니다. 프록시 컨테이너가 장악당하는 상황이면 어차피 다 끝난 것 아니냐는 것이죠. 그런데 이 서버의 IMDS 설정을 확인하고 나서 생각이 바뀌었습니다.
EC2 인스턴스 메타데이터 서비스(IMDS)에는 HttpPutResponseHopLimit이라는 설정이 있습니다. 기본값 1은 인스턴스 자신만 접근할 수 있다는 뜻이고, 2로 올리면 컨테이너 안에서도 IMDS에 닿습니다. 이 서버는 애플리케이션 컨테이너가 S3를 쓰기 위해 이미 2로 떠 있었습니다.
즉 프록시 컨테이너만이 아니라 같은 호스트의 웹 애플리케이션 컨테이너도 인스턴스 역할의 자격증명을 얻을 수 있습니다. 웹 앱에 SSRF 하나만 있어도 IMDS를 통해 Route53 쓰기 권한에 도달합니다. "프록시가 뚫려야"가 아니라 "애플리케이션에 SSRF가 있으면"으로 조건이 훨씬 느슨해집니다.
애석하게도 이건 이론적인 이야기가 아닙니다. IMDS를 경유한 자격증명 탈취는 클라우드 침해 사고의 고전적인 경로입니다.
조건키로 폭발 반경을 좁힙니다
해결은 권한 자체를 없애는 것이 아니라 범위를 ACME 챌린지에만 묶는 것이었습니다. Route53에는 이걸 위한 조건키가 있습니다.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AcmeChallengeTxtOnly",
"Effect": "Allow",
"Action": "route53:ChangeResourceRecordSets",
"Resource": "arn:aws:route53:::hostedzone/ZONE_ID",
"Condition": {
"ForAllValues:StringEquals": {
"route53:ChangeResourceRecordSetsRecordTypes": ["TXT"]
},
"ForAllValues:StringLike": {
"route53:ChangeResourceRecordSetsNormalizedRecordNames": [
"_acme-challenge.*"
]
}
}
},
{
"Sid": "ReadForPropagationCheck",
"Effect": "Allow",
"Action": [
"route53:GetChange",
"route53:ListHostedZonesByName"
],
"Resource": "*"
}
]
}
이제 이 자격증명으로 할 수 있는 일은 _acme-challenge로 시작하는 TXT 레코드를 만들고 지우는 것뿐입니다. A 레코드는 건드릴 수 없습니다. 컨테이너가 뚫려도 잃는 것이 "ACME 챌린지 TXT를 어지럽힐 수 있음"으로 줄어듭니다.
ForAllValues 접두사를 빠뜨리지 않는 것이 중요합니다. 이 조건키들은 값이 배열로 들어오는데, 접두사 없이 쓰면 요청에 담긴 값 중 하나만 일치해도 통과합니다. 여러 레코드를 한 번에 바꾸는 요청에 TXT 하나를 끼워 넣는 방식으로 우회될 수 있습니다.
나머지 결정들도 같은 사상이었습니다
돌이켜 생각해보면 이 작업에서 내린 판단은 전부 "실패했을 때 무엇이 함께 죽는가"를 묻는 것이었습니다.
기존 resolver를 바꾸지 않고 하나 더 만듭니다
이미 살아 있는 HTTP-01 resolver를 DNS-01로 갈아끼우는 것이 언뜻 깔끔해 보입니다. 하지만 그렇게 하면 지금 서비스 중인 인증서들의 갱신 경로가 함께 바뀝니다. 설정 실수 한 번이면 멀쩡하던 사이트가 내려갑니다.
그래서 기존 letsencrypt는 그대로 두고 letsencryptdns를 신설했습니다. 새 실험이 실패해도 기존 호스트는 건드려지지 않습니다.
ACME 저장소도 분리합니다
resolver를 나눠도 저장소 파일을 공유하면 의미가 절반으로 줄어듭니다. 실패한 실험이 살아 있는 인증서가 담긴 파일을 건드리게 되기 때문입니다. acme.json 옆에 acme-dns.json을 따로 뒀습니다.
첫 발급은 staging CA로 합니다
Let's Encrypt에는 발급 제한이 있습니다. 동일한 호스트 조합에 대한 중복 인증서는 주당 5개, 검증 실패는 시간당 5회입니다. 와일드카드는 DNS 전파 대기가 끼어 있어 디버깅 중에 이 한도를 태우기 쉽습니다.
staging으로 한 번 성공을 확인한 뒤 prod로 바꾸면 이 위험이 사라집니다. 한 줄 차이인데 하루를 아낍니다.
와일드카드 A 레코드는 마지막에 넣습니다
*.example.com A 레코드를 만드는 순간 그 주소는 공개됩니다. 인증서도, 라우터도, 접근 제어도 다 선 뒤에 DNS를 여는 것이 맞습니다. 그 전까지는 로컬 /etc/hosts로 충분히 검증할 수 있습니다.
여기서 하나 더 놓치기 쉬운 것이 있었습니다. 기존 사이트에 걸어둔 Basic Auth는 그 라우터에 붙은 미들웨어입니다. 와일드카드용으로 새로 만든 라우터는 별개이므로 그 미들웨어를 자동으로 상속하지 않습니다. 명시적으로 붙이지 않은 채 DNS를 열면 인증 없는 표면이 그대로 생깁니다.
정리하면
와일드카드 인증서 발급은 기술적으로 어려운 작업이 아니었습니다. Traefik 3.x는 route53 DNS-01을 이미 지원하고, 설정은 인자 몇 개입니다.
정작 시간을 쓴 곳은 "이 권한을 주면 최악의 경우 무엇을 잃는가"를 계산하는 부분이었습니다. IMDS hop limit 하나를 확인하지 않았다면 조건 없는 정책을 그냥 붙였을 것이고, 그것이 문제가 되는 날은 아마 한참 뒤였을 것입니다. 그때는 원인을 찾기도 훨씬 어려웠겠지요.
다만 이 글은 아직 절반입니다. *.example.com 인증서를 손에 넣어도 덮이지 않는 영역이 있고, 그 사실이 결국 프록시 선택 자체를 다시 보게 만들었습니다. 다음 글에서 이어가겠습니다.
관련 글
무중단 서버 이전: DNS 컷오버와 Let's Encrypt 타이밍 함정
이미지를 옮기고 DNS만 바꾸면 끝일 줄 알았는데, Let's Encrypt 인증서가 발급되지 않았습니다. 무중단 이전의 설계와, TTL을 미리 낮추지 않아 인증서 발급이 지연된 함정을 로그와 함께 정리했습니다.
EC2를 띄우기 전에 AWS CLI로 먼저 잰 것들 — 계획서가 틀려 있던 8가지
Coolify를 EC2에 올리기 전, 읽기 전용 describe만으로 계정을 먼저 쟀습니다. 계획서의 1순위 단계는 불필요했고, 정작 필수였던 것들은 계획서에 아예 없었습니다. 비용 0인 준비만 먼저 끝낸 과정을 정리했습니다.
Vercel 커스텀 도메인 연결하기 — CLI, 가비아 A레코드, HTTPS 검증까지
여름 멘토링용 정적 웹앱을 Vercel에 올린 뒤 개인 블로그 도메인의 서브도메인을 연결한 과정을 정리했습니다. Vercel CLI로 도메인을 추가하고 가비아 DNS에 A레코드를 등록한 다음, dig·curl·openssl로 전파와 HTTPS 인증서 발급까지 계층별로 확인하는 방법을 담았습니다.