백엔드 없는 정적 사이트 Q&A 보드: Firestore 보안 규칙과 Web Crypto SHA-256Serverless Static Site Q&A Board: Firestore Security Rules and Web Crypto SHA-256
별도 WAS 서버 없이 정적 호스팅 환경에서 Web Crypto API(SHA-256) 비밀번호 해싱, Cloudflare Turnstile 봇 방지 위젯, Firestore 스키마 검증 규칙을 결합해 댓글 보드를 구축하는 아키텍처입니다.Architecting a serverless comment board on static hosting by combining Web Crypto API (SHA-256) password hashing, Cloudflare Turnstile bot protection, and strict Firestore schema validation rules.
crypto.subtle.digest)로 비밀번호를 64자 SHA-256 해시로 변환하고, Firestore Security Rules에서 필드 타입·길이·서버 타임스탬프 일치 여부를 엄격히 검증하면 서버리스 환경에서도 안전한 쓰기 전용 게시판을 구성할 수 있습니다.crypto.subtle.digest) and enforcing strict field types, string lengths, and server timestamps in Firestore Security Rules, you can deploy a secure append-only feedback board without a backend.1. Web Crypto SHA-256 해싱 및 Firestore 스키마 검증 규칙 구현1. Implementing Web Crypto SHA-256 Hashing and Firestore Schema Rules
클라이언트에서는 평문 비밀번호를 네트워크로 전송하기 전에 SHA-256 16진수 문자열(64바이트)로 해싱하고, Firestore 보안 규칙(firestore.rules)에서는 허용된 5개 필드 외의 임의 필드 주입과 위변조된 타임스탬프를 차단합니다.
On the client side, hash plaintext passwords into 64-character SHA-256 hexadecimal strings before network transmission. In firestore.rules, reject any payload containing unauthorized fields or forged client timestamps.
// --- [1. 브라우저 Web Crypto API 기반 SHA-256 해시 생성] ---
async function sha256Hex(plainText) {
const encoder = new TextEncoder();
const data = encoder.encode(plainText);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
// 댓글 문서 생성 페이로드 구성
async function submitComment(db, pageId, nickname, password, bodyText, turnstileToken) {
if (!turnstileToken) throw new Error('봇 방지 인증을 완료해 주세요.');
const passwordHash = await sha256Hex(password);
await addDoc(collection(db, 'comments'), {
pageId: pageId.slice(0, 200),
nickname: nickname.trim().slice(0, 20),
passwordHash: passwordHash, // 길이 64자의 16진수 문자열
body: bodyText.trim().slice(0, 1000),
createdAt: serverTimestamp() // request.time 과 일치 강제
});
}
/* --- [2. Firestore Security Rules (firestore.rules)] ---
service cloud.firestore {
match /databases/{database}/documents {
match /comments/{commentId} {
allow read: if true;
allow create: if isValidComment();
allow update, delete: if false;
}
function isValidComment() {
let d = request.resource.data;
let k = d.keys();
return k.hasAll(['pageId', 'nickname', 'passwordHash', 'body', 'createdAt'])
&& k.hasOnly(['pageId', 'nickname', 'passwordHash', 'body', 'createdAt'])
&& d.pageId is string && d.pageId.size() > 0 && d.pageId.size() <= 200
&& d.nickname is string && d.nickname.size() > 0 && d.nickname.size() <= 20
&& d.passwordHash is string && d.passwordHash.size() == 64
&& d.body is string && d.body.size() > 0 && d.body.size() <= 1000
&& d.createdAt == request.time;
}
}
}
*/
// --- [1. Browser Web Crypto API SHA-256 Hash Generation] ---
async function sha256Hex(plainText) {
const encoder = new TextEncoder();
const data = encoder.encode(plainText);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
// Construct and submit comment document payload
async function submitComment(db, pageId, nickname, password, bodyText, turnstileToken) {
if (!turnstileToken) throw new Error('Please complete the bot verification check.');
const passwordHash = await sha256Hex(password);
await addDoc(collection(db, 'comments'), {
pageId: pageId.slice(0, 200),
nickname: nickname.trim().slice(0, 20),
passwordHash: passwordHash, // Fixed 64-character hex string
body: bodyText.trim().slice(0, 1000),
createdAt: serverTimestamp() // Enforced to match request.time
});
}
/* --- [2. Firestore Security Rules (firestore.rules)] ---
service cloud.firestore {
match /databases/{database}/documents {
match /comments/{commentId} {
allow read: if true;
allow create: if isValidComment();
allow update, delete: if false;
}
function isValidComment() {
let d = request.resource.data;
let k = d.keys();
return k.hasAll(['pageId', 'nickname', 'passwordHash', 'body', 'createdAt'])
&& k.hasOnly(['pageId', 'nickname', 'passwordHash', 'body', 'createdAt'])
&& d.pageId is string && d.pageId.size() > 0 && d.pageId.size() <= 200
&& d.nickname is string && d.nickname.size() > 0 && d.nickname.size() <= 20
&& d.passwordHash is string && d.passwordHash.size() == 64
&& d.body is string && d.body.size() > 0 && d.body.size() <= 1000
&& d.createdAt == request.time;
}
}
}
*/
2. 댓글 문서 스키마 및 보안 검증 조건2. Comment Document Schema and Security Constraints
| 필드명 | 데이터 타입 | Firestore 규칙 제약 조건 | 보안 설계 목적 |
|---|---|---|---|
pageId | string | size() > 0 && size() <= 200 | 게임 페이지별 댓글 격리 및 과도한 키 길이 차단 |
nickname | string | size() > 0 && size() <= 20 | 작성자 표시 이름 길이 제한으로 UI 레이아웃 깨짐 방지 |
passwordHash | string | size() == 64 | 평문 비밀번호 저장 방지 (SHA-256 Hex 64바이트 고정 규격 강제) |
body | string | size() > 0 && size() <= 1000 | 대용량 텍스트 도배를 통한 스토리지 과금 공격 방지 |
createdAt | timestamp | d.createdAt == request.time | 클라이언트의 과거/미래 시간 조작 차단 (서버 타임스탬프 강제) |
| Field Name | Data Type | Firestore Rule Constraint | Security Purpose |
|---|---|---|---|
pageId | string | size() > 0 && size() <= 200 | Isolates comments per game page and prevents oversized key strings |
nickname | string | size() > 0 && size() <= 20 | Caps author display name length to prevent UI layout overflow |
passwordHash | string | size() == 64 | Prevents plaintext password storage (enforces 64-byte SHA-256 hex format) |
body | string | size() > 0 && size() <= 1000 | Mitigates storage billing abuse from oversized text payloads |
createdAt | timestamp | d.createdAt == request.time | Prevents client-side timestamp spoofing by requiring server time |
3. 실무 적용 및 주의사항3. Practical Considerations and Caveats
- DOM 렌더링 시 XSS 방어: Firestore에서 읽어온
nickname과body를 화면에 표시할 때는 절대innerHTML에 직접 넣지 말고,textContent를 사용하거나 HTML 엔티티 이스케이프 함수를 거쳐야 저장형 XSS(Stored XSS)를 막을 수 있습니다. - 클라이언트 캡차의 한계와 보완: 백엔드 없이 브라우저에서만 Turnstile 위젯 성공 여부를 확인하는 방식은 일반 UI 봇은 막아주지만, Firestore REST 엔드포인트로 직접 패킷을 쏘는 공격까지 차단하지는 못하므로 트래픽 증가 시 Firebase App Check 또는 Cloudflare Worker 검증을 연동해야 합니다.
- 수정·삭제 권한 분리: 익명 게시판 특성상 클라이언트에서의
update,delete를 보안 규칙에서false로 잠그면 타인의 댓글 훼손을 근본적으로 방지할 수 있습니다.
- Prevent Stored XSS on Render: When rendering
nicknameandbodyfetched from Firestore, always assign them viatextContentor an HTML entity escaping helper rather than rawinnerHTML. - Client-Side Captcha Limitations: Checking Turnstile widget state on the client blocks automated browser bots, but direct REST requests to Firestore require enabling Firebase App Check or verifying tokens via a Cloudflare Worker.
- Lock Down Update and Delete Permissions: Setting
allow update, delete: if false;in Firestore rules ensures existing comments cannot be tampered with or deleted from the client.
Web Crypto API 해싱과 Firestore의 선언적 스키마 검증 규칙을 결합하면, 별도의 데이터베이스 서버를 운영하지 않고도 정적 사이트에서 가볍고 안전한 사용자 피드백 창구를 유지할 수 있습니다.
Combining Web Crypto API hashing with declarative Firestore schema validation rules allows static sites to host a lightweight, low-maintenance user Q&A board without a dedicated backend.