자동화 파이프라인7 분 읽기

배포된 건 다른 앱이었다

한 앱의 정책·지원 페이지 네 개가 나흘 동안 다른 앱의 스텁이었습니다. 아무도 몰랐습니다. 이 도메인은 Cloudflare 뒤라 비브라우저 요청에 200과 함께 챌린지 페이지를 줍니다. 즉 curl로는 배포 성공을 판정할 수 없었습니다. 권위 있는 검증은 SSH로 파일 해시를 대조하는 것뿐이었습니다.

#deployment#cloudflare#shipping-infra#gotchas
개념 도식: 배포 파이프라인 끝 검증 단계에 Cloudflare 방패. curl은 200을 받지만 5.4KB 챌린지 페이지라 튕겨나오고, SSH md5 대조만 방패를 우회해 서버 디스크에 닿는다.
HTTP 200을 받았으니 배포 성공, 이라는 가정은 WAF 뒤에서 못 쓴다.

앱 50개의 정책·지원 페이지를 한 도메인 아래 서브패스로 서비스하고 있습니다. /<앱이름>/index.html 형태고, 배포는 rsync/scp입니다.

어느 날 한 앱의 디렉터리를 열어봤더니 index, privacy, terms, support 네 파일이 전부 다른 앱의 스텁이었습니다. title이 다른 앱 이름이었고 크기가 15KB. 정상 페이지는 2779KB입니다. 나흘 전 배포에서 잘못 올라간 것으로 보였습니다.

진짜 문제는 "배포 성공을 확인할 방법이 없었다"는 것

이 도메인은 Cloudflare 뒤에 있고, 비브라우저 요청에는 5.4KB짜리 챌린지 페이지를 줍니다. 즉 curl로 URL을 때리면 앱 페이지가 아니라 챌린지가 옵니다. HTTP 200이 오지만 내용은 챌린지입니다. 배포 성공 판정에 HTTP를 쓸 수 없습니다.

그래서 나흘 동안 아무도 몰랐습니다. 배포 스크립트는 성공을 반환했고, URL을 열어보는 자동 검증은 애초에 불가능했고, 사람이 브라우저로 그 앱 페이지를 열어볼 일이 없었습니다.

유일한 권위 있는 검증은 SSH로 서버 파일의 md5를 로컬과 대조하는 것이었습니다. 배포한 사람이 접근할 수 있는 유일한 진실이 파일시스템뿐이라는 뜻입니다.

한 가지 더. 이 URL은 외부 심사 서류에 "서비스 웹페이지 및 출처 표기 위치"로 명시한 주소였습니다. 심사위원이 열었으면 전혀 다른 앱이 나왔을 겁니다. 사고의 심각도가 파일 4개가 아니라 그 URL이 어디에 적혀 있느냐로 결정되는 종류의 일이었습니다.

코드

오염 판별 신호 — 크기만 봐도 드러납니다.

정상 페이지: 27~79KB
오염 페이지: 1~5KB (다른 앱 스텁)

전수 스윕(50개 앱 디렉터리, SSH 1회):

cd ~/public_html
for d in */; do d=${d%/}; [ -f "$d/index.html" ] || continue
  echo "$d|$(stat -c%s $d/index.html)|$(grep -m1 -oE '<title[^>]*>[^<]*' $d/index.html | sed 's/<title[^>]*>//')"
done

⚠️ 로컬 <title><title data-i18n="title"> 형태라 <title> 정규식으로는 안 잡힙니다. <title[^>]*>로 매칭할 것. 이거 때문에 첫 스윕이 전부 빈 title을 반환했습니다.

복구는 서버 사본 백업 → 로컬 파일 scp → md5 전량 대조 순입니다. 참조 자산(공용 CSS, i18n JS, 아이콘)은 서버에 이미 있어 재배포 불필요했습니다. 스윕 결과 해당 앱 외 49개는 전부 정상이었습니다.

일반화 — CDN/WAF 뒤에선 배포 검증 경로가 사라진다

봇 보호는 배포 파이프라인도 봇으로 취급하기 때문입니다. 선택지는 세 가지쯤입니다.

  • 검증용 경로만 챌린지에서 제외한다 (이 사이트는 .well-known/만 200으로 열려 있다)
  • 서버 파일시스템에 직접 접근해 해시를 대조한다 (여기서 택한 방법)
  • 배포 후 실제 브라우저로 렌더해서 확인한다 (비용이 크다)

어느 쪽이든 "HTTP 200을 받았으니 배포 성공"이라는 가정은 못 쓴다는 걸 먼저 인정해야 합니다.

정직하게

  • 발견이 우연이었습니다. 정기 점검이나 알림으로 잡은 게 아니라 다른 일을 하다 눈에 띄었습니다. 같은 사고가 다른 앱에서 났다면 더 오래 갔을 겁니다.
  • 복구는 했지만 왜 그 디렉터리에 다른 앱 스텁이 올라갔는지는 규명하지 못했습니다. 배포 스크립트의 경로 인자 실수로 추정하지만 그날의 명령을 재구성하지 못했습니다. 원인 불명인 채 증상만 복구한 상태입니다.
  • 전수 스윕은 1회성이었습니다. 자동화된 정기 검증은 아직 없습니다. 지금 상태로는 같은 사고가 또 나면 또 우연히 발견해야 합니다.
  • 스윕에서 오탐이 하나 났습니다. 한 앱의 title이 예상과 달라 오염으로 의심했는데, 알고 보니 그 앱이 실제로 리브랜딩돼서 사이트가 맞는 상태였습니다. 페이지가 8KB로 작은 것도 별도 제작이라 정상이었습니다. "다르다"를 "틀렸다"로 읽으면 안 된다는 걸 확인 과정에서 다시 배웠습니다.

이 글에서 언급하는 앱 상세 페이지 URL(ootssu.com/<slug>/)은 지금도 비브라우저 요청에 403입니다 — 이 403 자체가 글의 소재입니다. 등장 앱: HiddenGem.

당신의 배포 스크립트가 마지막에 확인하는 그 200, 정말 당신이 올린 파일을 받은 겁니까, 아니면 WAF가 대신 답한 챌린지입니까? 둘은 똑같이 초록불입니다.

관련 글