codingstairs
노트에듀라이프연락
⌕검색⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

연락하기

로그인 없이도 보낼 수 있어요. 답변이 필요하면 이메일을 함께 적어 주세요.

  • 익명 폼으로 의견 남기기 →
  • ✉ warragon112@gmail.com
  • 카카오톡 오픈채팅 ↗

© 2026 codingstairs

  • 노트
  • 에듀
  • 검색
  • 라이프
  • 연락
  • 약관
  • RSS
  • GitHub
노트›security

익명 폼 — 최소한의 안전망

2026-05-06 게시· 2026-05-18 갱신·0회 조회

익명 폼 — 최소한의 안전망

문의하기 · 댓글 · 건의함처럼 로그인 없이 공개된 폼은 가장 자주 남용당하는 표적입니다. CAPTCHA 를 안 쓰면 안 된다는 결론으로 건너뛰기 전에, 더 적은 비용으로 상당한 방어가 가능한 수단들이 있습니다.

1. 위협 모델

어떤 공격이 실제로 오는지 구분해 두면 방어 선택이 쉬워집니다.

위협 특징 비용
스팸 봇 양식을 HTML 파싱 후 자동 submit. IP 도 많이 씀 가장 흔함
수동 스팸 사람이 복붙 반복. IP 는 소수 중간
대상 공격 특정 사업체 허위 신고 · 조작 제보 낮음 · 고비용
시스템 낭비 리소스 고갈 (수천 / 초) 낮음 · 드문

공개 문의 폼은 보통 1 번이 90 %. 이 1 번을 막으면 나머지는 운영적 · 수동 대응으로 감당 가능.

2. Honey-pot — 0 UX 비용의 1 차 방어

사람 눈에 안 보이는 필드를 폼에 숨기고, 값이 채워져 들어오면 봇으로 간주.

<form action={submit}>
  <input
    type="text"
    name="website"
    tabIndex={-1}
    autoComplete="off"
    aria-hidden="true"
    style={{ position: 'absolute', left: '-9999px', opacity: 0 }}
  />
  {/* 실제 필드들 */}
</form>

서버에서는:

export async function POST(req: Request) {
  const body = await req.json();
  if (body.website && body.website.trim()) {
    return NextResponse.json({ ok: true }, { status: 200 });
    // 일부러 200 — 봇이 "실패" 를 학습해 재시도하지 않게
  }
  // 정상 경로
}

사람 사용자는 보이지 않는 필드를 채우지 않으므로 UX 비용 0. 봇은 HTML 파싱만 하고 CSS 를 읽지 않는 경우가 많아 이 함정에 잘 걸립니다.

3. IP 해싱 — 원문 없이 중복 판별

request.headers['x-forwarded-for'] 같은 IP 를 원문 저장하면 개인정보 보관 의무가 생깁니다. 중복 · 남용 판별만 필요하면 해시 후 앞자리만 저장.

import { createHash } from 'crypto';

function hashIp(ip: string): string {
  return createHash('sha256').update(ip).digest('hex').slice(0, 32);
}

await db.query(
  'INSERT INTO inquiries (..., ip_hash) VALUES (..., $1)',
  [hashIp(ip)]
);
  • 같은 IP 의 반복 submit 은 WHERE ip_hash = ... AND created_at > now() - interval '1 hour' 로 조회 가능
  • 원문 IP 는 DB · 로그 어디에도 남지 않음
  • 앞자리 32 자 truncate 는 저장 공간 · 충돌 확률의 현실적 절충

X-Forwarded-For 는 맨 앞이 원 IP 이지만 프록시 체인을 고려해 split(',')[0].trim() 으로 추출.

4. 속도 제한 — 운영 부담을 줄이는 두 층

로그인 없는 폼에 속도 제한을 적용하려면 IP 단위.

// Redis 기반 sliding window (권장)
await redis.incr(`inquiries:${ipHash}:${Math.floor(Date.now() / 60000)}`);
// 분당 3 회 제한 같은 식

Redis 가 무거우면 PostgreSQL 로도 가능 (SELECT count(*) FROM inquiries WHERE ip_hash=$1 AND created_at > now() - interval '1 minute'). DB 쓰기 횟수가 같지만 의존성이 줄어듭니다.

5. 입력 검증 — zod · Valibot

폼 필드는 서버에서 다시 검증. 클라이언트 검증은 UX 용, 보안 경계는 서버.

import { z } from 'zod';

const InquirySchema = z.object({
  name: z.string().max(40).optional(),
  email: z.string().email().max(120).optional(),
  subject: z.string().max(80).optional(),
  message: z.string().min(5).max(2000),
  website: z.string().optional(),        // honey-pot
});

const parsed = InquirySchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: 'invalid' }, { status: 400 });

길이 상한 (max) 이 중요합니다. 메가바이트 단위 payload 로 DB · 메모리를 낭비하는 공격을 저비용으로 막습니다.

6. XSS — 저장 시점이 아니라 표시 시점

message 본문은 그대로 저장하고 표시할 때 sanitize. DB 에 HTML 을 저장하면 "원문 보존 · 재편집" 이 곤란.

import DOMPurify from 'isomorphic-dompurify';
const safe = DOMPurify.sanitize(marked.parse(message));
<div dangerouslySetInnerHTML={{ __html: safe }} />

마크다운 허용 폼이면 DOMPurify. 순수 텍스트면 React 의 자동 이스케이핑이 이미 충분.

7. 이메일 · 이름 등 PII 선택 저장

CREATE TABLE inquiries (
  id         BIGSERIAL PRIMARY KEY,
  name       TEXT,                       -- NULL 허용
  email      TEXT,                       -- NULL 허용
  subject    TEXT NOT NULL DEFAULT '',
  message    TEXT NOT NULL,
  user_agent TEXT,
  ip_hash    TEXT,
  status     VARCHAR(12) NOT NULL DEFAULT 'new'
             CHECK (status IN ('new','read','replied','archived')),
  admin_note TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
  • email 을 필수로 두면 가짜 이메일이 많아져 오히려 노이즈. 회신 받고 싶으면 입력 로 선택 유지.
  • user_agent 는 500 자 truncate (봇은 종종 터무니없이 긴 UA 를 씀).
  • status 는 new → read → replied → archived 4 단계 flow 만으로도 운영자가 트래킹 가능.

8. 운영자 답신 동선

수신 폼만 있고 답신 경로가 없으면 운영자가 피로해집니다.

  • 이메일 입력이 있으면 관리자 UI 에서 mailto:${email}?subject=Re: ...&body=... 링크
  • status 가 replied 로 바뀌면 해당 inquiry 는 대시보드에서 숨김
  • archived 는 법적 보관 기간 (예: 3 년) 후 배치로 DELETE 또는 email=NULL 익명화

9. CAPTCHA 가 필요해지는 시점

위 다섯 (honey-pot · IP 해시 · rate limit · zod · XSS 방어) 로 대부분의 소규모 사이트는 버팁니다. 다음 중 하나가 현실이 되면 CAPTCHA 를 추가.

  • 하루 수십 건 이상의 스팸이 꾸준히
  • 타겟팅 공격 (경쟁사 · 특정 개인의 반복 허위 제보)
  • 결제 · 쿠폰 발급 같은 금전 가치가 있는 액션

hCaptcha · Cloudflare Turnstile 이 reCAPTCHA 보다 UX · 프라이버시가 낫다는 평가가 일반적.

10. 자주 걸리는 자리

CAPTCHA 먼저 도입 — UX 비용이 크고 봇은 CAPTCHA 도 돈 주고 뚫는 서비스가 있음. 먼저 honey-pot + rate limit 로 80 % 를 막고 후속 도입 판단.

IP 원문 저장 — GDPR · 한국 개인정보보호법의 개인식별정보 범주. 보유 기간 · 삭제 정책이 필요. 해싱은 이를 회피.

실패 시 4xx 반환 — 봇이 "이 폼은 안 되는구나" 를 학습해 다른 폼으로 이동하거나 우회합니다. honey-pot 걸린 봇에는 200 OK + 실제 저장 안 함 이 심리전 관점에서 유리.

user_agent 검증으로 봇 차단 — 정상 브라우저 UA 를 위장한 봇이 많아 효과가 미미. 차단 로직보다는 로깅 용도.

관리자 UI 에 raw message 직접 렌더 — admin 페이지라고 XSS 안전하지 않습니다. 관리자 브라우저 탈취가 타깃인 공격도 존재.

하고픈 말

공개 폼의 보안은 CAPTCHA 같은 정면 방어보다 다층 저비용 방어의 합이 대체로 더 효과적입니다. honey-pot 하나만으로도 첫 해는 버틸 수 있고, 운영 로그에서 실제 위협 패턴이 보이고 나서 더 무거운 수단을 추가하는 순서가 아프지 않습니다.

Next

  • input-validation-zod
  • rate-limit-redis

OWASP Automated Threats Handbook · Cloudflare Turnstile · DOMPurify · RFC 7239 X-Forwarded-For · 개인정보보호법 을 참고합니다.

security 카테고리의 다른 글

카테고리 전체 보기 →
  • 공개 라우트 화이트리스트 — 신규 도메인 도입 시 같이 갱신
  • 보안 헤더와 CORS
  • 비밀번호 해싱 — bcrypt · scrypt · Argon2
  • 입력 검증 — 경계에서 다듬는다
  • 레이트 리밋 — 알고리즘과 구현
  • OAuth — state · PKCE · OIDC