배포 실패와 Lambda 권한 오류의 원인이 공개 번들에 인라인된 AWS 키였다
NEXT_PUBLIC_ 접두사는 브라우저에 노출해도 되는 값이라는 선언입니다. 브라우저에서 S3 를 직접 부르는 구조가 그 접두사를 강제하고 있었습니다
서로 무관해 보이는 세 가지가 같은 날 나타났습니다.
- 배포 전면 실패 — CodePipeline 의 Build 스테이지가 8초 만에 죽습니다. 재시도해도 같습니다.
Error calling startBuild: Cannot have more than 0 builds in queue for the account (AccountLimitExceededException) - 외부 시스템 프록시 Lambda 가 IAM 권한 오류로 동작 불능이 되면서, 그 Lambda 에 의존하는 통계 화면들이 같이 멈췄습니다.
- 외부 경로로 "탈취된 키로 결제가 시도됐다" 는 통보가 왔습니다.
1번을 파이프라인이나 프로젝트 설정 문제로 보면 헛다리를 짚습니다. 실행 중인 빌드는 0개였고 프로젝트의 concurrentBuildLimit 도 설정돼 있지 않았습니다. 프로젝트가 아니라 계정 단위 제한이었습니다.
원인
어드민의 공개 JS 번들에 AWS 액세스키와 시크릿키가 평문으로 들어 있었습니다. 로그인 없이 URL 하나로 가져갈 수 있는 상태였고, 노출된 키를 탐지한 AWS 측 계정 제한이 1번과 2번 증상으로 나타난 것이었습니다.
세 요소가 겹쳐 있었습니다.
(1) 브라우저에서 S3 를 직접 호출하는 구조 — 게시판 계열 화면이 클라이언트 컴포넌트에서 new AWS.S3() 를 만들어 listObjects 와 putObject 를 호출했습니다. 이 구조는 자격증명이 브라우저에서 실행되는 코드 안에 존재할 것을 요구합니다.
(2) NEXT_PUBLIC_ 접두사 — Next.js 에서 이 접두사는 "브라우저에 노출해도 되는 값" 이라는 선언이고, 빌드할 때 클라이언트 번들에 문자열로 치환됩니다. (1) 을 동작시키려면 붙일 수밖에 없으니 (2) 는 (1) 의 필연적 결과입니다. 시크릿에 붙이면 안 되는 접두사라는 점이 코드만 봐서는 드러나지 않습니다.
(3) 정적 자산 경로는 인증 대상이 아님 — middleware.ts 의 인증 검사에서 /_next 가 제외돼 있습니다. 통상적인 설정이고, 문제는 그 정적 자산 안에 시크릿이 있는 쪽입니다.
파일명 해시를 몰라도 됩니다. /_next/static/<buildId>/_buildManifest.js 가 전체 청크 목록을 공개하므로 자동 수집을 막는 게 없습니다.
조치
브라우저 → 백엔드 → S3 로 바꿨습니다.
- 클라이언트 5곳의
aws-sdk직접 호출을 제거하고 신규GET /api/image/list,POST /api/image/upload를 경유하게 했습니다. 인증은 기존과 같은 검증을 씁니다. - 서버 라우트 16곳의
NEXT_PUBLIC_AWS_*를AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_S3_BUCKET/AWS_REGION으로 바꿨습니다. 서버에서만 참조되면 클라이언트 번들에 안 들어갑니다. - 노출된 키를 폐기하고 새 키를 발급했습니다.
ECS task role 로 키 자체를 없애는 방안은 이 환경에서 쓸 수 없어서 채택하지 않았습니다. 서버는 계속 env 기반 장기 키를 씁니다.
다음 날, 인라인 메커니즘 자체를 제거했습니다
위 조치까지는 "클라이언트가 참조하지 않으니 안전하다" 는 사람 판단에 기대는 상태였습니다. next.config.js 의 env: 블록에 38개 키가 그대로 있었기 때문입니다.
블록을 통째로 지웠습니다. 이후로는 NEXT_PUBLIC_ 이 아닌 변수를 클라이언트 코드가 참조해도 undefined 가 됩니다. 유출이 아니라 그냥 버그로 끝나는, 프레임워크 기본 보호막이 돌아온 상태입니다.
env 블록이 원래 필요했던 이유는 standalone 서버가 .env 를 런타임에 로드하지 않기 때문이었는데, Dockerfile 에서 node --env-file=.env.local server.js 로 주입하는 방식이 그 역할을 대신합니다.
삭제해도 되는지는 전수 검색으로 확인했습니다. 38개 키를 클라이언트 도달 가능 코드에서 값으로 참조하는 곳은 0건이었고, 유일한 히트는 import type 이라 컴파일 시 제거됩니다.
노출 여부를 확인하는 방법
공개 번들 전수 확인 — 실제 배포본을 훑습니다. 빌드 ID → 청크 목록 → grep 순서이고, 인증 없이 됩니다.
BASE=https://admin.example.com
BUILD=$(curl -s $BASE/login | grep -o '/_next/static/[A-Za-z0-9_-]*/_buildManifest.js' | head -1)
curl -s "$BASE$BUILD" | grep -o 'static/chunks/[A-Za-z0-9._/-]*\.js' | sort -u > chunks.txt
while read -r c; do
curl -s "$BASE/_next/$c" | grep -q "AKIA" && echo "노출: $c"
done < chunks.txt
카나리 빌드 — 배포 전 회귀를 막는 쪽입니다. 자격증명 자리에 가짜 값을 넣고 빌드해서, 클라이언트 산출물에 그 값이 나타나는지 봅니다. 실제 키를 쓰지 않고 확인할 수 있습니다.
AWS_ACCESS_KEY_ID=AKIATESTLEAKCANARY01 AWS_SECRET_ACCESS_KEY=canary-secret-value \
npx dotenv -e .env.local -- next build
grep -r "AKIATESTLEAKCANARY01" .next/static/ && echo "클라이언트 노출" || echo "미노출"
grep -rl "AKIATESTLEAKCANARY01" .next/server/ | head # 서버엔 주입돼야 정상
.next/static 에 없고 .next/server 에만 있으면 정상입니다.
정리하면서 남긴 것들
NEXT_PUBLIC_이 붙은 값은 전부 공개입니다. private 레포든 인증이 걸린 화면이든 무관합니다. 빌드 산출물이 공개되는 순간 노출됩니다.next.config.js의env블록에 있어도 클라이언트 참조가 없으면 번들에 안 들어갑니다. 실제로 DB 비밀번호, JWT 시크릿, 외부 API 키, 슬랙 웹훅이 같은 블록에 있었지만 서버 코드에서만 참조돼 노출되지 않았습니다. 위험 판정 기준은 "블록에 등재됐는가" 가 아니라 "클라이언트 코드에서 참조하는가" 입니다. 다만 그 판정을 사람이 계속 해야 하는 상태였고, 그래서 블록 자체를 지웠습니다.- dev 와 prod 의
.env가 같은 키를 쓰고 있어서 개발 환경 번들에도 같은 키가 노출됐습니다. 환경별 키 분리가 안 돼 있으면 노출 범위가 두 배가 됩니다. - 계정 제한이 걸리면 배포나 Lambda 처럼 무관해 보이는 기능이 먼저 깨집니다. 여러 AWS 서비스가 동시에 권한이나 쿼터 오류를 내면 계정 레벨 조치를 의심하는 편이 빨랐습니다.