commits

비공개 서버 방에 다이렉트 링크 붙이면서 보안 구멍 전부 막은 이야기

R
이더
2026. 05. 18. PM 11:45 · 6 min read · 1

🤖 7766 in / 2000 out / 9766 total tokens

비공개 서버 방에도 다이렉트 링크를 붙였다. 자유 방만 가능했던 초대 링크를 서버 방 전체로 확장하면서, 클라이언트가 마음대로 serverId를 세팅하던 구멍, rate limit 우회해서 방 무한 양산하던 문제, 비인증 사용자에게 방 메타가 노출되던 이슈를 한 번에 처리했다. 28개 파일, +1788줄의 변화가 그 이야기다.

원래 자유 방에만 shortcode가 발급됐다. 비공개·공개 서버 방은 디스코드 봇으로만 입장 가능했기 때문에 링크가 필요 없었다. 그런데 서버 멤버들끼리 링크 하나로 바로 게임방에 들어가고 싶다는 요구가 계속 들어왔다. 디스코드에서 봇 명령어 치고, 채널 들어가고, 거기서 또 버튼 누르는 흐름이 너무 길다는 거다.

그래서 모든 방에 shortcode를 붙이기로 했다. URL 구조는 자유 방과 서버 방을 분리했다. 자유 방은 /free/{game}/{code}, 서버 방은 /{game}/{code}. 라우트에 :shortcode 파라미터를 추가해서 각 게임 페이지(주사위, 룰렛, 경마, 다리건너기) 모두 다이렉트 링크를 처리한다.

shortcode 길이도 4자에서 5자로 늘렸다. 32^4가 약 100만, 32^5는 약 3350만이다. 무차별 대입이 32배 어려워진다. resolve API의 rate limit도 분당 30회에서 15회로 낮췄다. 어차피 정상 사용자는 분당 15번 조회할 일이 없다.

javascript const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; const DEFAULT_LENGTH = 5; // 32^5 ≈ 33.5M const FALLBACK_LENGTH = 6;

여기서 가장 신경 쓴 건 비공개 서버의 멤버십 게이트 흐름이다. 비공개 서버 방 다이렉트 링크를 누르면 세 가지 케이스로 나뉜다. 승인된 멤버는 즉시 입장. 미승인 멤버(가입 대기 중)는 안내 메시지. 비멤버는 참여코드 입력 모달을 띄운다. js/free.js에서 check-member → joinServer → redirect 흐름을 하나로 합쳤다.

resolve API에서도 메타 마스킹을 추가했다. 비인증 상태에서 방 정보를 조회하면 hostName, serverName, roomName을 전부 가린다. 링크만 있다고 해서 누가 어떤 서버에서 무슨 방을 팠는지 노출되면 안 된다.

서버 측 보안은 세 겹으로 강화했다. 첫째, socket/server.jssetServerId에서 userName이 함께 오면 멤버십을 강하게 검증한다. 기존에는 클라이언트가 serverId만 보내면 서버가 그대로 신뢰했다. 이제 DB에서 실제 멤버인지 확인하고, 아니면 socket.serverId를 null로 만들어버린다.

둘째, socket/rooms.jscreateRoom에서 serverId, serverName을 클라이언트가 보낸 값으로 쓰지 않고 DB 값으로 강제 덮어쓴다. 클라이언트가 아무 serverId나 넣어서 방을 만드는 게 불가능해졌다.

셋째, joinRoom에 멤버 재검증 레이어를 추가했다. 방어적 코딩이다. setServerId에서 한 번 검증하더라도, 최종 입장 시점에 한 번 더 확인하는 게 안전하다.

자유 방의 DoS 문제도 처리했다. 기존 checkRateLimit는 분당 300회로, 같은 소켓이 빠르게 빈 방을 양산하는 걸 막지 못했다. IP별 슬라이딩 윈도우로 분당 최대 10방으로 제한했다. 5분마다 만료된 엔트리를 정리해서 메모리 누수도 방지한다.

javascript const FREE_CREATE_WINDOW_MS = 60 * 1000; const FREE_CREATE_MAX_PER_WINDOW = 10; const freeCreateRoomCounter = new Map();

UX 개선도 몇 가지 했다. 헤더에 방 이름 옆에 인라인 알약으로 다이렉트 링크 URL을 보여주고, 클릭하면 클립보드에 복사된다. 로딩 화면은 게임별 그라데이션 배경에 큰 이모지가 통통 튀는 애니메이션과 스피너를 붙였다. 경마에 있던 race condition도 수정했다. socket.connected를 체크하지 않아서 연결이 끊긴 상태에서 createRoom/joinRoom을 호출하면 에러가 났던 문제다.

QA 자동화 스크립트도 16개 보안 시나리오를 추가했다. AutoTest/qa-free-page-security.js에서 HTTP API와 Socket.IO 시나리오를 돌려서 보안 가드가 정상 동작하는지 검증한다.

하네스 시스템 쪽에서는 트리아지 강제가 가장 큰 변화다. UserPromptSubmit 훅에서 매 사용자 메시지마다 트리아지 리마인더를 주입한다. 그리고 check-triage.sh에서 정규식을 정확 매칭으로 바꿔서, 본문에 'STANDARD'라는 단어가 나온다고 트리아지를 선언한 걸로 인식하는 false-positive를 없앴다.

meeting-team 프로필도 정리했다. 같은 직군이 여러 명 참여하면 대표만 Opus, 나머지는 Sonnet으로 내려가는 규칙을 명시했다. 토큰 절약과 응답 속도 개선이 목적이다.

이번 커밋의 핵심 교훈은, 기능 확장(다이렉트 링크)이 곧 공격면 확장이라는 거다. 링크 하나로 비공개 방에 들어갈 수 있게 되면, 그 링크를 악용하는 시나리오를 전부 따져야 한다. 무차별 대입, 정보 노출, 권한 우회. 각각에 대해 독립적인 방어 레이어를 만들고, QA 스크립트로 회귀 테스트까지 걸어놨다.

기능 하나 확장할 때마다 공격면 세 개씩 늘어난다. 링크를 붙이면 정보 노출, 권한 우회, 무차별 대입 세 가지를 동시에 막아야 한다.

← 이전 글
AI 업데이트: Claude가 ChatGPT를 역전했다 — 시장 지배권 교체의 기술적 의미
다음 글 →
다이렉트 링크 fast path로 재입장 5초→1초 줄이기