Joonmook 프로필 사진

@Joonmook

Blog

댓글 기능을 직접 만들면서 정한 것들: 비밀번호, 허니팟, 속도 제한

댓글 기능을 직접 만들면서 정한 것들: 비밀번호, 허니팟, 속도 제한 썸네일

블로그에 댓글을 달 수 있게 했다. giscus나 Disqus를 붙이면 한 시간이면 끝나는 일인데 직접 만들었다. 이유는 두 가지다. 댓글 하나 남기려고 GitHub 로그인을 시키고 싶지 않았고, 댓글 데이터가 남의 서비스에 있는 게 싫었다. 작은 기능인데 정해야 할 게 열 개쯤 나왔다. 그 결정들을 적어둔다.

익명 댓글은 어떻게 지우게 할 것인가

로그인이 없으니 "내 댓글"이라는 개념이 없다. 한국 블로그들이 오래 써온 방식을 그대로 가져왔다. 닉네임과 비밀번호를 받고, 비밀번호는 bcrypt 해시로만 저장한다. 삭제할 때 비밀번호를 다시 받아 비교한다. 관리자는 로그인 상태면 비밀번호 없이 지울 수 있고, 관리자 댓글에는 배지가 붙는다.

CREATE TABLE IF NOT EXISTS comments (
  ID INT NOT NULL AUTO_INCREMENT,
  POST_ID INT NOT NULL,
  NAME VARCHAR(30) NOT NULL,
  PASSWORD VARCHAR(255) DEFAULT NULL,   -- bcrypt 해시, 관리자 댓글은 NULL
  CONTENT TEXT NOT NULL,
  IS_OWNER TINYINT NOT NULL DEFAULT 0,
  IP_HASH CHAR(64) DEFAULT NULL,
  CREATED_AT DATETIME NOT NULL,
  PRIMARY KEY (ID),
  KEY idx_comments_post (POST_ID, CREATED_AT)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

IP는 원문 대신 해시로만 남긴다. 스팸 대응에는 해시면 충분하고, 원문 IP를 쌓아두면 그것 자체가 관리 대상이 된다. 문자셋을 utf8mb4로 한 건 이모지 때문이다. 기존 테이블이 utf8mb3라서 4바이트 문자가 들어오면 STRICT 모드에서 그대로 에러가 난다. 새 테이블만이라도 제대로 만들었다.

스팸은 어디까지 막을 것인가

캡차는 넣고 싶지 않았다. 댓글 쓰는 사람에게 일을 시키는 방식이라서다. 대신 봇에게만 일을 시키는 방법 두 가지를 썼다.

첫째, 허니팟. 사람에게는 보이지 않는 입력 필드를 하나 두고, 그 필드가 채워져 오면 저장하지 않고 성공한 것처럼 응답한다. 봇은 폼의 모든 필드를 채우는 경향이 있어서 의외로 잘 걸린다. 실패 응답을 주면 봇이 학습하니까 성공한 척하는 게 핵심이다.

둘째, 속도 제한. IP당 10분에 댓글 5개, 삭제 시도는 10분에 20회. 삭제 쪽 제한은 비밀번호 무차별 대입을 막기 위한 것이다. 관리자는 제외했다. 메모리 Map 하나로 만든 단순한 구현인데, 프로세스가 하나라서 충분하다. 여러 프로세스로 늘리면 그때 Redis로 옮기면 된다.

const commentCreateLimiter = createRateLimiter({
  windowMs: 10 * 60 * 1000,
  max: 5,
  skip: (req) => Boolean(req.auth),
  onLimit: (req, res) => res.redirect(backTo(req.params.id, 'rate')),
});

여기서 한 번 삽질했다. 서버가 nginx 뒤에 있어서 Express가 보는 IP는 전부 127.0.0.1이었다. 이 상태로 IP당 제한을 걸면 전 세계가 한 명으로 묶인다. trust proxy를 켜고 nginx가 X-Forwarded-For를 넘기도록 해야 한다. 속도 제한을 붙이는 사람은 제일 먼저 req.ip를 찍어보는 게 좋다.

출력은 무조건 이스케이프

댓글 본문은 이스케이프한 뒤 줄바꿈만 br로 바꾼다. 닉네임도 마찬가지다. 예전에 최근 검색어를 innerHTML로 넣다가 저장형 XSS 흔적을 발견한 적이 있어서, 이번에는 사용자 입력이 HTML에 닿는 지점을 한 함수로 통일했다. 이스케이프는 저장할 때가 아니라 출력할 때 한다. 저장할 때 하면 나중에 API로 내보낼 때 두 번 이스케이프되는 문제가 생긴다.

작은 실패담

삭제 폼은 Delete 버튼을 눌러야 나오게 hidden 속성을 줬는데, 배포하고 보니 항상 보였다. 원인은 CSS였다. 폼에 display: flex를 준 클래스가 브라우저 기본 스타일인 [hidden] { display: none } 보다 우선순위가 높았다. 한 줄로 해결했다.

[hidden] { display: none !important; }

이런 건 코드 리뷰로는 절대 안 잡힌다. 화면을 봐야 안다. 그래서 배포 전에 목 데이터로 페이지를 렌더링해서 스크린샷을 찍는 스크립트를 만들었다. 기능 하나 추가하는 데 검증 도구가 하나씩 따라 붙는다.

그래서

댓글 기능 하나에 결정이 열 개였다. 저장 방식, 삭제 권한, 스팸 정책, 속도 제한 기준, 프록시 뒤의 IP, 이스케이프 시점, 문자셋, 그리고 CSS 우선순위까지. 외부 서비스를 붙였으면 이 결정들을 남이 대신 했을 거다. 그게 편한 건 맞는데, 이 블로그에서만큼은 내가 정하고 싶었다. 댓글 남겨주면 직접 확인한다.

Comments 0

No comments yet. Be the first!

← Back to Blog