로그인 없는 공개 폼을 Firestore로 안전하게 받기: 보안 규칙 설계 노트
출시 알림 신청 폼처럼 로그인 없이 누구나 제출하는 데이터는 서버를 따로 두지 않고 Firestore에 바로 쓰게 하면 편합니다. 대신 보안 규칙을 꼼꼼히 써야 합니다. 이 글은 pocaprint의 대기자 명단(waitlist)과 사용 이벤트(events) 규칙을 예로 듭니다.
원칙: 쓰기는 좁게, 읽기는 관리자만
- create만 허용: 방문자는 새 문서를 만들 수만 있습니다. 수정(update)과 삭제(delete)는 아무도 못 합니다.
- read는 관리자만: 다른 사람의 이메일을 읽을 수 없어야 합니다. 목록 조회(list)와 단건 조회(get) 모두 관리자 UID로 제한합니다.
- 나머지는 모두 거부: 규칙에 적지 않은 컬렉션은 기본적으로 닫혀 있게 합니다.
문서 모양을 규칙으로 검사하기
function validWaitlistDoc(d) {
return d.keys().hasOnly(['email','product','utm_source',
'utm_medium','utm_campaign','referrer','createdAt'])
&& d.keys().hasAll(['email','product','createdAt'])
&& d.email is string
&& d.email.size() > 5 && d.email.size() < 200
&& d.product in ['pocaprint']
&& d.createdAt == request.time;
}
match /waitlist/{docId} {
allow create: if validWaitlistDoc(request.resource.data);
allow read: if isAdmin();
allow update, delete: if false;
}
각 줄이 막는 것은 다음과 같습니다.
hasOnly: 정해진 필드 외의 값을 끼워 넣지 못하게 합니다. 예를 들어isVip: true같은 필드를 몰래 추가할 수 없습니다.hasAll: 필수 필드가 빠진 문서를 거부합니다.- 타입과 길이 검사: 수 MB짜리 문자열로 저장 공간을 채우는 시도를 막습니다. 선택 필드도 "없거나, 있으면 문자열이고 길이 제한 이내"로 검사합니다.
product in [...]: 정해진 값만 허용합니다.createdAt == request.time: 클라이언트가 시각을 마음대로 넣지 못하고, 반드시serverTimestamp()를 쓰게 합니다.
이벤트 기록도 같은 방식
Firebase 연결을 켜면 pocaprint는 PDF를 만들 때 용지 종류와 카드 수, 유입 경로(UTM) 값만 기록합니다. 이벤트 규칙은 유형을 pdf_generated로, 용지를 a4나 letter로, 카드 수를 0~100 사이 정수로 제한합니다. 이미지나 파일 이름 같은 개인 정보는 필드 목록에 아예 없으므로 저장될 수 없습니다.
규칙이 막지 못하는 것
보안 규칙은 문서 하나하나의 모양은 검사하지만, 같은 사람이 짧은 시간에 수천 건을 넣는 것까지 막지는 못합니다. 남용이 걱정되면 App Check로 정상 앱에서 온 요청인지 확인하거나, 서버 함수를 거쳐 제출 빈도를 제한하는 방법을 추가로 검토하세요. 이메일 형식 검사도 규칙에서는 간단한 정규식 수준만 가능합니다.
한 프로젝트를 여러 앱이 쓸 때
Firestore 보안 규칙은 프로젝트(데이터베이스)마다 파일 하나입니다. 앱 A의 저장소에서 규칙을 배포하고, 다음에 앱 B의 저장소에서 규칙을 배포하면 B의 규칙이 A의 규칙을 통째로 덮어씁니다. A의 컬렉션이 갑자기 "모두 거부"가 되는 사고가 여기서 생깁니다.
- 프로젝트를 쓰는 모든 앱의 컬렉션 목록을 먼저 정리합니다.
- 모든
match블록과 공용 함수(isAdmin()등)를 파일 하나로 합칩니다. - 합친 파일 하나만 배포 기준으로 삼고, 다른 저장소의
firebase.json에서는 firestore 항목을 빼서 실수로 배포하지 않게 합니다. - 배포 전에 에뮬레이터나 콘솔의 규칙 시뮬레이터로 각 앱의 읽기·쓰기를 확인합니다.
관리자 UID로 쓰기를 여는 부분은 Firebase Auth UID 게이트 글에서 다뤘습니다.