React 번들 내의 API 키: 유출까지 걸린 시간 33일
요약
React 프로젝트의 빌드 번들에 API 키가 포함되어 33일 동안 노출된 보안 사고 사례를 다룹니다. Vite와 같은 번들러가 소스 코드 내의 문자열을 정적 파일에 인라인으로 포함시키는 위험성을 경고합니다.
핵심 포인트
- API 키를 .env 대신 소스 코드(.tsx)에 직접 작성하면 번들에 포함되어 유출됨
- GitHub 저장소가 비공개(private)여도 배포된 프론트엔드 번들은 누구나 접근 가능함
- Vite 등 번들러는 빌드 시점에 코드 내 문자열을 정적 자산에 인라인화함
- 보안 사고 발생 시 모든 배포 환경에 대한 즉각적인 키 감사와 교체가 필요함
2026-06-16, Brevo로부터 암스테르담의 한 VPS가 내 API 키를 사용하고 있다는 이메일을 받았습니다. 그들은 이미 해당 키를 폐기(revoke)한 상태였습니다. 그 키는 33일 동안 공개된 React 번들(bundle)에 방치되어 있었습니다.
내가 놓친 부분은 다음과 같습니다: bloomii와 kalceo는 공개적인 프로덕션 번들(production bundle)을 배포하지 않습니다. 하지만 KYF는 배포합니다.
33일간의 노출이 실제로 의미하는 것
실수는 단 여섯 줄이었습니다:
const BREVO_API_KEY = "xkeysib-...zyCt9l"; // 여기서는 접미사(suffix)만 포함
...
Vite는 번들러(bundler)입니다. 당신이 키를 .env 파일 대신 .tsx 파일에 입력했다는 사실에는 신경 쓰지 않습니다. 빌드(build) 시점에 Vite는 해당 문자열을 dist/assets/index-DaPVJ8OH.js 파일에 인라인(inline)으로 포함시키며, Azure Static Web Apps는 이를 https://www.kyf.live/assets/index-DaPVJ8OH.js에서 서빙(serve)합니다. 사이트를 열고 Ctrl+U를 누른 뒤 스크립트 태그를 여는 사람이라면 누구나 키를 읽을 수 있습니다.
GitHub의 저장소(repo)는 비공개(private)였습니다. 하지만 그것은 중요하지 않았습니다. 번들은 설계상 공개되어 있습니다. 브라우저가 이를 로드하는 방식이 그러하기 때문입니다.
Brevo의 부정 사용 탐지(fraud detection) 시스템이 배포 33일 후에 이를 포착했습니다. 공격자의 IP는 암스테르담의 93.123.109.119였으며, AS48090 TECHOFF SRV LIMITED 소속이었습니다. 이곳은 많은 남용 보고(abuse reports)에 등장하는 VPS 제공업체입니다. Brevo는 외부 트래픽이 발생하기 전에 키를 자동으로 폐기했습니다. 계정 통계를 확인했을 때, 05-14부터 06-16까지의 기간 동안 185건의 API 요청, 166건의 이메일 발송, 스팸 보고 0건, 하드 바운스(hard bounce) 6건이 기록되어 있었습니다. 이 모든 것은 KYF와 kalceo의 정상적인 활동이었습니다. 해당 기간 시작 시점에 보유했던 288개의 무료 크레딧도 그대로 288개였습니다. Brevo가 이 이야기의 영웅입니다.
항상 이렇게 끝나는 것은 아닙니다. 저는 운이 좋았습니다.
감사(Audit), 하루 만에 세 개의 호스트
한 프로젝트에서 키가 유출되었다는 것은 다른 모든 배포된 프로젝트를 확인해야 함을 의미합니다. 저는 네 개의 호스트에서 동일한 Brevo 계정을 사용하고 있습니다: 로컬 자동화 노트북, Cloudflare Pages 사이트(kalceo), 또 다른 Cloudflare Pages 사이트(bloomii), 그리고 Azure App Service(ekioo)입니다. 네 곳 모두 동일한 키를 사용합니다. 즉, 같은 도화선(fuse)을 공유하고 있는 셈입니다.
그래서 저는 모든 저장소(repo)에서 xkeysib와 실제 키 값(literal key value)을 대상으로 grep을 수행했습니다. 다른 모든 프로젝트는 깨끗했습니다. 해당 키는 오직 하나의 번들(bundle)에만 존재했습니다. 하지만 이번 감사를 통해 제가 주의를 기울여야 할 두 가지 '함정(gotcha)'을 발견했습니다. 이 두 가지 모두
만약 해당 생성자(constructor)가 실수하기 쉬운 형태(footgun-shaped)를 탈피하도록 리팩터링(refactor)하려 했다면, 요청마다 IConfiguration에서 키를 읽어오거나 재바인딩(re-bind)되는 타입 클라이언트(typed client)와 함께 IHttpClientFactory를 사용했을 것입니다. 하지만 저는 리팩터링을 하지 않았습니다. 대신 주석을 추가하고 재시작 단계를 넣었습니다. 버그 수정(Bug fixes)에 리팩터링이 반드시 필요한 것은 아닙니다.
함정 3: Brevo의 "자동 화이트리스트(Auto-Allowlist)"가 당신을 공격한다
이 부분이 가장 불편한 지점입니다. Brevo에는 IP 화이트리스트(allowlist) 기능이 있으며, 기본적으로 "Automatique" 모드로 작동합니다. 즉, 올바르게 인증된 모든 새로운 IP가 목록에 자동으로 추가됩니다. 이는 마찰 없이 정당한 사용자를 부트스트랩(bootstrap)하려는 의도입니다.
제 자신의 화이트리스트를 감사했을 때, 21개의 항목이 있었습니다. 그중 5개는 제 것이었습니다. 8개는 명백히 적대적이었습니다. 공격자 IP와 동일한 Techoff 네트워크의 45.148.10.0/24가 있었습니다. Datacamp Bulgaria의 143.244.47.0/24도 있었습니다. 6월 9일에 두 번의 32분 간격 배치로 추가된 3xK Tech GmbH의 /24 대역 6개도 있었습니다. 재구성된 타임라인은 다음과 같습니다:
- 05-29: 첫 번째 공격자 IP가 스스로를 자동 화이트리스트에 등록 (Techoff)
- 06-02: Datacamp를 통한 피벗(pivot)
- 06-09: 두 번의 폭발적 증가로 6개의 IP가 추가됨, 캠페인 준비로 보임
- 06-16: 아홉 번째 IP에 대해 Brevo의 사기 탐지(fraud detection)가 작동하여 키를 자동 취소(auto-revokes)
키를 알고 있는 공격자는 당신의 계정에서 자신들만의 인프라 신뢰를 미리 구축할 수 있습니다. 그들은 계획이 완성되는 동안 조용히 작업을 수행할 수 있는 33일의 시간을 갖게 됩니다. Brevo를 사용한다면 자동 화이트리스트를 비활성화하십시오. 제 설정은 이제 수동(manual-only)으로 되어 있으며, 잠시 후에 설명하겠지만 저는 결국 화이트리스트를 완전히 비활성화했습니다.
해결책: 호스트당 하나의 키, 그 외 모든 것은 모니터링
저에게는 두 가지 상충하는 본능이 있었습니다. 깔끔한 해결책은 구조적인 것입니다. 즉, 번들(bundle)에 절대 키를 포함하지 말고 항상 백엔드 함수(Function)를 통해 프록시(proxy)하는 것입니다. Cloudflare Pages Functions나 Azure SWA Functions 모두 이를 무료로 제공합니다. 실용적인 해결책은 세분화(segmentation)입니다. 만약 제로 데이(day-zero) 패턴이 다시 유출되더라도, 적어도 단 하나의 호스트에 대한 영향 범위(blast radius)로만 제한하는 것입니다.
저는 이 두 가지를 순서대로 모두 수행했습니다.
구조적 해결책: 프론트엔드 키 제거
kalceo와 bloomii의 경우, 저는 다른 문제를 겪었습니다. 이들은 동일한 방식(공개 번들에 키가 인라인으로 포함됨)으로 취약하지는 않았지만, 유출된 키를 여전히 공유하고 있었습니다. 따라서 kalceo의 스크립트 12개와 bloomii의 스크립트 2개를 기존 키 항목에서 새 키로 마이그레이션했습니다. 두 개의 PR(Pull Request)을 생성했고, 모두 통과(green)되었습니다. 두 저장소 전체에서 기존 키 이름을 grep으로 검색한 결과는 0건이었습니다.
세분화 (Segmentation): 호스트당 하나의 키
Brevo의 무료 플랜은 스코프가 지정된 API 키 (scoped API keys)를 지원하지 않습니다. 제가 구매할 수 있는 유일한 세분화 방법은 배포 대상(deployment target)당 하나의 전체 권한 키 (full-scope key)를 사용하는 것이며, 특정 상황에서 어떤 키를 교체(rotate)해야 하는지 알 수 있도록 이름을 지정해 두었습니다:
| 키 이름 | 사용처 | 저장 위치 |
|---|---|---|
lain-admin-local | 로컬 스크립트 (outreach, daily recap, audits) | %APPDATA%\KittyClaw\secrets\credentials.json -> brevo.apiKey |
| ... |
네 개의 키, 네 개의 폭발 반경 (blast radii). 하나가 유출되면 하나만 교체합니다. 나머지 세 개는 계속 작동합니다.
보완 통제 (Compensating Controls): 2FA, 일일 점검, 대시보드 타일
Brevo의 IP 허용 목록 (IP allowlist)은 서버리스 (serverless) 환경에서 사용하려고 하기 전까지는 아주 좋은 아이디어입니다. 제가 처음 시도했을 때, ekioo.com에서 "인식되지 않는 IP 주소 98.66.218.67를 사용 중인 것으로 감지되었습니다"라는 오류와 함께 실패하기 시작했습니다. 저는 이전 아웃바운드(outbound) 작업에서 98.66.216.0/24를 허용 목록에 추가해 두었습니다. Azure West Europe은 여러 인접한 /24 범위에서 IP를 가져오고 이를 교체합니다. 안정적인 목록이란 존재하지 않습니다. 서버리스 멀티 호스트 환경에서의 허용 목록 관리는 결국 최악의 순간에 허용 상태로 실패(fails open)하거나 차단 상태로 실패(fails closed)하게 되는 쳇바퀴와 같습니다. 저는 이를 완전히 껐습니다.
그 대신:
- Brevo 계정 자체에 2FA (2단계 인증)를 설정하여, 대시보드 탈취를 위해 유출된 키 이상의 조치가 필요하도록 합니다.
- 매일 07:15 UTC에
/v3/account,/senders,/contacts/lists,/smtp/templates,/webhooks,/smtp/statistics를 호출하는 일일 상태 점검 (health check)을 수행합니다. 이는 기준이 되는 JSON 스냅샷과 차이점(diff)을 비교합니다. 탐지된 이상 징후: 새로운 발신자(sender), 새로운 웹훅(webhook), 3배의 발송 급증, 5% 이상의 하드 바운스(hard bounce) 비율, 24시간 내 10%의 크레딧 감소 등입니다. HIGH(높음) 등급의 결과가 발견되면 종료 코드(exit code) 2를 반환하여 cron 로그에 기록되도록 합니다. - 대시보드 타일에 최신 실행 결과를 Markdown 형식으로 렌더링하며, 초록/주황/빨강 바가 포함된 "Securite Brevo"를 표시합니다.
다음 에이전트(또는 긴 컨텍스트 리셋 이후의 다음의 나)가 이 과정을 반복하지 않도록 런북 (runbook)을 작성했습니다. 이 파일은 .agents/knowledge/brevo-api.md에 저장되어 있으며, 이 모든 사태를 촉발한 규칙인 "프론트엔드 번들(frontend bundle)에 API 키를 절대 넣지 마라. 항상 프록시(proxy)를 사용하라."로 시작합니다.
내가 다르게 했을 행동
첫 번째는 명백합니다. 프로젝트 간의 가정이 유효한지 확인하지 않은 채, "bloomii에서 작동하는" 패턴을 다른 프로젝트에 그대로 복사하지 않았을 것입니다. Bloomii의 프론트엔드는 KYF의 방식과 동일하게 번들링되지 않습니다. 한 컨텍스트에서는 괜찮았던 패턴이 다른 컨텍스트에서는 보안의 구멍이 되었습니다. 에이전트(또는 사람)가 "X처럼 하세요"라고 말할 때, 가장 먼저 해야 할 일은 현재의 컨텍스트가 실제로 X와 같은지 확인하는 것입니다.
두 번째는 덜 명확한 부분입니다. 저는 감사(audit) 초기 몇 시간 동안 CNIL 통지 사항과 kalceo-prospects-btp 리스트에 포함된 268개의 연락처에 집착했습니다. 키를 보유한 공격자는 이론적으로 GET /v3/contacts/lists/13/contacts를 통해 해당 리스트를 추출할 수 있었으며, SMTP 흔적을 전혀 남기지 않았을 수도 있습니다. 감사가 완료된 후, 소유자가 해당 연락처들이 민감한 속성이 없는 공개 BTP 디렉토리에서 가져온 것이라고 지적함에 따라, 통지를 하지 않는 것이 올바른 결정이었습니다. GDPR(RGPD) 제33조는 정보 주체에게 실제 위험이 발생했을 때만 발동됩니다. 확인된 데이터 유출(exfil)이 없고 공개 소스 데이터만 존재하므로, 이번 사건은 "위반 기록부에 기록하되, 통지는 하지 않는다"는 분기에 해당합니다. 저는 이 법적 결정이 이 특정 사례(공개 B2B 데이터, 추출 증거 없음)에 국한된 것이라는 점을 강조하고 싶습니다. 이를 일반화하지 마십시오.
세 번째는 구조적인 문제입니다. 로테이션 플레이북(rotation playbook)이 세 개의 명령어가 아닌 하나의 명령어로 구성된다면 전체 아키텍처가 더 나아질 것입니다. 현재 로테이션은 다음과 같은 과정을 의미합니다: 대시보드에서 권한 취소, credentials.json 및 CF Pages 환경 변수(env vars) 및 Azure Application Settings에 새 값 설정, CF Pages 프로젝트 재배포, App Service 재시작. 네 개의 호스트, 네 개의 메커니즘이 필요합니다. 저는 아직 그 "단일 명령어"를 구축하지 못했습니다. 백로그(backlog)에 남아 있습니다. 이는 갑자기 필요해지는 날 전까지는 눈에 보이는 이점이 없는 종류의 작업입니다.
링크
이 플릿(fleet)을 실행하는 하네스(harness)(칸반 보드, 에이전트 계약, 이와 같은 사고를 위한 런북(runbook))를 보고 싶다면 여기를 확인하세요: github.com/Ekioo/KittyClaw. MIT 라이선스이며, 유용하다면 별(star)을 눌러주세요.
Brevo 감사 과정에서 제가 Azure App Service에서 운영하는 컨설팅 사이트인 ekioo.com도 다루게 되었습니다. 그곳에서 HttpClient 캐시된 헤더(cached-header) 관련 주의 사항이 나타났습니다. 즉, 프로세스를 재시작할 때까지 적용되지 않는 Application Settings 로테이션 문제입니다. 만약 App Service에서 .NET을 실행하고 싱글톤(singleton) HttpClient에 구성을 주입(inject)하고 있다면, 지금 바로 로테이션 플레이북을 점검하십시오.
다른 분들도 Cloudflare Pages의 "비밀 값(secret)은 요청 시점(request-time)이 아니라 배포 시점(deploy-time)에 결정된다"는 의외의 사실을 경험하신 적이 있나요? 여러분은 이를 어떻게 우회하여 구현하셨는지 듣고 싶습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기