infra
공개로 서빙되지만 비밀인 값 — 소유증명 키를 secret으로 다시 분류한 이야기
· Ascendy Engineering
TL;DR
- 검색엔진 색인 통보(IndexNow) Worker를 만들며, 도메인 소유증명 키를 **“URL로 누구나 가져갈 수 있으니 공개값”**으로 분류했다. 그래서 거리낌 없이 설정 파일에 평문으로 커밋했다.
- 그게 틀렸다. 리뷰가 공식 문서를 들고 막았다 — “Only you and the search engines should know the key and your file key location.” 이 키는 공개값이 아니라 준비밀이다. 키를 아는 사람은 우리 도메인 전체에 색인 통보를 위조 제출할 수 있다(low-severity지만 0은 아님).
- 함정은 한 문장이다: “엔드포인트에서 가져갈 수 있다 ≠ git에 평문으로 박아도 된다.” 검색엔진이 fetch하는 것과, 키가 git 히스토리·PR에 박히는 것은 다른 노출이다.
- 해법: 정적 파일 대신 엣지 Worker가 secret에서
/<key>.txt를 직접 서빙. 키도, 키가 든 경로도 git 밖에 두면서 서빙은 공개로 유지된다.
소스 노트. 이 글은 상위 인프라 팀의 보안위생 회고(
docs/intake/from-infra/2026-06-03-ownership-key-semi-secret.md)를 정제한 것이다. 도메인·Worker 이름·키 경로·PR 번호는 일반화했고, 실제 키 값은 본문 어디에도 없다. 이 분류 실수는 게시 전 리뷰에서 잡혀 rotate + 재설계로 끝났다 — 그 자체가 글의 한 교훈이다.
”이 값, 진짜 공개여도 돼?”
검색엔진 크롤을 앞당기려고 색인 자동 통보 Worker를 만들었다. 이 프로토콜은 도메인 소유를 증명하려고 https://<호스트>/<키>.txt에 키 파일을 두고, 통보를 보낼 때마다 그 키를 함께 보낸다.
우리는 이 키를 **“소유 증명용 공개값”**으로 분류했다. 논리는 단순했다 — 어차피 검색엔진이 URL로 가져가야 하는 값이니, 누구나 볼 수 있는 공개값 아닌가? 그래서 거리낌 없이 설정 파일에 평문으로 커밋하고, 정적 사이트가 /<키>.txt로 게시하게 설계했다.
리뷰에서 막혔다. 리뷰어가 공식 문서를 들고 왔다.
“Only you and the search engines should know the key and your file key location.”
즉 이 키는 공개값이 아니라 **준비밀(semi-secret)**이다. 키를 아는 사람은 우리 도메인 전체에 대해 “이 URL이 바뀌었다”는 색인 통보를 위조 제출할 수 있다. 읽기나 삭제는 못 하고 severity는 낮지만, 0은 아니다. “공개여도 된다”는 우리 전제 자체가 틀렸던 것이다.
함정 — “가져갈 수 있다”와 “박아도 된다”는 다르다
키를 git에 커밋한 시점에 우리가 한 판단은 “어차피 URL로 누구나 가져갈 수 있으니 공개값”이었다. 함정은 정확히 거기 있었다.
엔드포인트에서 가져갈 수 있다 ≠ 아무 데나 적어도 된다.
검색엔진이 /<키>.txt를 fetch하는 것과, 그 키가 git 히스토리·PR·핸드오프에 평문으로 박히는 것은 다른 노출이다. 전자는 의도된 공개(소유 검증)이고, 후자는 키를 영구적으로, 검색 가능하게, 되돌릴 수 없이 흩뿌리는 것이다. 문서가 “키 위치도 너와 검색엔진만 알아야 한다”고 한 이유가 이것이다 — obscurity가 약하게나마 방어선의 일부다.
교훈 1 — 값을 커밋하기 전에 “이건 secret인가?”를 명시적으로 분류하라. “공개여도 될 것 같다”는 직감이 아니라, 그 값을 아는 사람이 무엇을 할 수 있는지로 판단한다. 위조 제출이 가능하면, low-severity라도 그건 준비밀이고 git 밖에 있어야 한다.
패턴 — 공개로 서빙하되 git엔 두지 않기
준비밀로 다시 분류하니 까다로운 제약이 생겼다. 키 파일은 /<키>.txt에 공개로 서빙돼야 한다(검색엔진이 인증 없이 fetch하니까). 그런데 키 값과 키가 든 경로는 git에 없어야 한다. 정적 파일 방식은 이 둘을 동시에 만족할 수 없다 — 파일이 곧 repo에 들어가니까.
해법은 Worker가 키 파일을 직접 서빙하는 것이었다.
// 경로가 정확히 /<key>.txt면 secret을 반환. 인증 없음 —
// 검색엔진이 소유 검증용으로 가져가야 하므로 (이게 의도된 공개).
if (env.OWNERSHIP_KEY) {
const keyPath = `/${env.OWNERSHIP_KEY}.txt`;
if (path === keyPath) {
return new Response(env.OWNERSHIP_KEY, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
}
// 그 외 경로는 인증 게이트(운영자 전용).
- 키 = 엣지 Worker secret. 운영자가 직접 생성해 시크릿으로만 주입하고, 값을 아무에게도(에이전트 포함) 공유하지 않는다 → repo·로그·핸드오프 어디에도 안 들어간다.
- 통보의
keyLocation은 런타임에호스트 + "/" + secret + ".txt"로 조립 → 키가 든 URL조차 저장되지 않는다. - route(
/<키>.txt → Worker)는 경로에 키가 들어가므로 설정 파일에 커밋하지 않고, 운영자가 대시보드/CLI로 out-of-band 설정한다.
# 키는 운영자가 직접 생성해 secret으로만 주입 — 값을 공유/커밋하지 않는다.
openssl rand -hex 16 | tr -d '\n' | wrangler secret put OWNERSHIP_KEY
# 검증도 키를 출력하지 말 것(준비밀):
test "$(curl -fsS "https://<host>/$KEY.txt")" = "$KEY" && echo ok
결과: 정적 사이트는 키 파일을 보유하지 않고, 키 값도 키-경로도 git에 없으며, 검색엔진은 평소대로 /<키>.txt를 가져간다. 그리고 이미 노출됐던 옛 키는 rotate했다 — 한 번도 라이브로 게시된 적이 없어 영영 유효해지지 않으니, 폐기로 충분했다.
교훈 2 — “공개로 서빙돼야 하지만 git엔 없어야 하는 값”은, 정적 파일 대신 엣지에서 secret으로 서빙하면 둘 다 만족한다. 파일명·경로가 곧 비밀이면, 그 경로를 담는 설정(route)도 커밋에서 빼고 운영자 주입으로 돌린다.
그리고 — 리뷰가 잡은 게 정상이다
이 이야기에서 빠지면 안 되는 게 하나 있다. 잘못된 분류는 머지 전 리뷰에서 잡혔다. 값을 커밋하기 전에 누가 “이거 secret 아냐?” 한 줄을 물어줬다면 더 빨랐겠지만, 적어도 라이브로 게시되기 전에 잡혀 rotate + 재설계로 깔끔히 끝났다.
교훈 3 — 리뷰가 분류 실수를 잡는 건 사고가 아니라 정상 동작이다. 보안위생에서 “리뷰가 일했다”는 좋은 신호다. 한 번도 게시된 적 없는 키라면, 노출의 비용은 재설계의 시간뿐이다.
가져갈 것
- “엔드포인트에서 가져갈 수 있다”가 “git에 평문으로 둬도 된다”를 의미하지 않는다. 소유증명 토큰류는 공개로 서빙되지만 비밀로 취급돼야 하는 준비밀이다.
- 커밋 전에 명시적으로 분류하라 — 값을 아는 사람이 무엇을 할 수 있는지로. 위조 제출이 가능하면 git 밖.
- 공개 서빙 + git 밖이 동시에 필요하면, 정적 파일 대신 엣지 Worker가 secret에서 서빙한다. 키-경로를 담는 route도 미커밋 out-of-band로.
- 노출된 미게시 키는 rotate가 충분조건. 라이브로 게시된 적 없으면 유효해진 적도 없다.
저작·인용: 이 글은 Ascendy Engineering이 작성했으며 출처 표기 시 재인용 가능합니다. 잘못된 정보를 발견하면 GitHub 이슈로 알려주세요.
Tags: cloudflare-workers, secrets-hygiene, secret-classification, code-review, indexnow