[Moija] 개발 일지 — RLS 보안 구멍 막고 기능 5개 추가한 날
오늘 한 일
작업하던 세션이 갑자기 끊겨서 "어디까지 했더라" 확인하는 걸로 하루를 시작했다. 다행히 다 날아간 건 아니었고, 커밋 기록이랑 남겨둔 메모를 뒤져서 이어붙일 수 있었다. 거기서부터 보안 구멍 하나를 마저 막고, 미뤄뒀던 기능 5개를 오늘 다 끝냈다.
1. 끊긴 세션 복구하기, 그리고 테스트의 함정
전 세션이 어디서 멈췄는지부터 파악해야 했다. 커밋 로그랑 작업 메모를 살펴보니, 참여자 명단 테이블에 걸려 있던 보안 구멍 — 아무나 API를 직접 두드리면 다른 사람 참여 정보를 볼 수 있던 문제 — 을 막는 작업이 사실 거의 다 끝나 있었다. 다만 그걸 검증하는 테스트 코드에 구멍이 있었다.
이런 식으로 짜여 있었다:
// 존재하지 않는 event_id로 뚫기 시도
const insertResponse = await request.post(`${supabaseUrl}/rest/v1/participants`, {
headers: { apikey: publishableKey, Authorization: `Bearer ${publishableKey}` },
data: { event_id: "00000000-0000-0000-0000-000000000000", name: "뚫기 시도" },
});
expect(insertResponse.ok()).toBe(false);문제는, 존재하지 않는 이벤트 ID를 쓰면 보안 정책이 막든 안 막든 무조건 실패 응답이 온다는 거였다 — 참여자 테이블이 존재하는 이벤트를 참조해야 하는 제약이 걸려 있어서, 그냥 그 제약 때문에 막힌 것과 진짜 보안 정책이 막은 걸 구분할 수가 없었다. 즉 나중에 보안 정책이 실수로 풀려도 이 테스트는 계속 초록불을 켤 수 있는 상태였다.
실제로 존재하는 이벤트로 뚫기 시도를 하도록, 그리고 응답이 "권한 없음" 에러인지까지 구체적으로 확인하도록 고쳤다:
const { data: event } = await adminDb.from("events").insert({ ... }).select("id").single();
const insertResponse = await request.post(`${supabaseUrl}/rest/v1/participants`, {
headers: { apikey: publishableKey, Authorization: `Bearer ${publishableKey}` },
data: { event_id: event.id, name: "뚫기 시도" }, // 이제 실제 이벤트
});
expect(insertResponse.status()).toBe(401);
const body = await insertResponse.json();
expect(body.code).toBe("42501"); // "권한 없음" 에러 코드까지 확인전체를 한 번 더 훑어보니 비슷한 종류의 구멍이 세 개 더 있었다. 전부 고치고 다시 검증한 뒤에야 안심하고 넘어갔다.
느낀 점: 보안 테스트는 "통과하는지"보다 "정말 그 취약점을 재현하는지"를 한 번 더 의심해봐야 한다.
2. 미뤄뒀던 기능 5개 정리
시간을 너무 많이 쓴 것 같아서, 애매한 부분은 몇 가지 선택지를 한꺼번에 물어보고 빠르게 정하는 식으로 밀어붙였다.
- 닉네임으로 참여하기 — 확인해보니 이미 이름 입력란이 자유 텍스트라 별도 작업 없이 종료.
- 내가 만든 모임을 내 링크로 열었을 때 — 예전엔 그냥 다른 참여자랑 똑같이 보였는데, 이제 "이건 당신 모임입니다 → 관리하기" 배너가 뜨도록 했다.
- 로그인한 사람에게 상단 메뉴 노출 — 참여 페이지에서도 로그인한 상태면 다른 페이지랑 똑같은 헤더(내 이벤트, 새 이벤트 등)가 보이게 했다.
- 모임에 암호 걸기
- 여러 날짜에 걸친 모임 지원
암호 걸기 기능, 신경 쓴 부분
암호를 걸면 암호를 모르는 사람에겐 모임 제목이나 날짜조차 안 보이게 만들었다. 여기서 제일 신경 쓴 건 암호(정확히는 안전하게 변환한 값)를 모임 정보랑 같은 테이블에 저장하지 않은 것이다. 모임 정보는 원래 링크만 있으면 누구나 조회할 수 있게 설계돼 있는데, 거기에 암호까지 같이 넣으면 나중에 실수로 새어나갈 위험이 생긴다. 그래서 완전히 별도의, 아무도(서버 말고는) 못 들여다보는 테이블에 따로 뒀다:
create table event_passwords (
event_id uuid primary key references events(id) on delete cascade,
password_hash text not null
);
-- RLS는 켜두고 정책은 하나도 안 만든다 — 서버 전용 키로만 접근 가능하게
alter table event_passwords enable row level security;암호를 안전한 값으로 바꾸는 것도 새 라이브러리를 추가하지 않고 Node에 내장된 기능으로 처리했다:
function hashPassword(plain: string): string {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(plain, salt, 64).toString("hex");
return `${salt}:${hash}`;
}
function verifyPasswordHash(plain: string, stored: string): boolean {
const [salt, hash] = stored.split(":");
const candidate = scryptSync(plain, salt, 64);
return timingSafeEqual(Buffer.from(hash, "hex"), candidate);
}한 번 암호를 맞히면 브라우저가 기억해서 다음에 또 안 물어본다. 위조 못 하게 서명을 붙여서.
마지막에 전체 점검을 하다가, 새로 짠 보안 테스트 하나가 특정 조건에서 무력화될 수 있다는 걸 발견해서 고쳤다 — 이번에도 "테스트가 진짜로 지켜야 할 걸 지키는지" 한 번 더 의심해본 덕분에 잡은 문제.
여러 날 모임 지원
2박 3일 캠프 같은 모임을 "8월 15일 ~ 8월 17일"처럼 표시할 수 있게 됐다. 날짜 하나로 표시할지 범위로 표시할지 판단하는 함수 하나만 만들어서 필요한 곳에서 재사용했다:
function formatEventDate(event: { event_date: string; end_date: string | null }): string {
const start = formatSingleDate(event.event_date);
if (!event.end_date) return start;
return `${start} ~ ${formatSingleDate(event.end_date)}`;
}구현하면서 유혹이 하나 있었는데, 코드베이스 여기저기 흩어진 날짜 포맷 함수를 다 하나로 합치고 싶었다. 근데 참았다 — 이번 기능이랑 상관없는 곳(가입일 표시 같은)까지 건드리면 괜히 위험만 커지니까, 딱 필요한 곳만 손댔다.
3. 사고: 테스트가 진짜 데이터베이스를 오염시키고 있었다
기능 다 만들고 나서 확인해보니, 자동 테스트를 돌릴 때마다 그게 연습용 DB가 아니라 진짜 서비스 DB에 가짜 모임을 만들고 있었다. 그것도 정리하는 코드가 없어서 테스트를 돌릴 때마다 계속 쌓이고 있었다. 오늘만 여러 번 돌렸더니 어느새 가짜 모임이 217개나 쌓여 있었다.
다행히 전부 테스트 전용 계정 명의로만 만들어져 있어서, 그 계정 소유 데이터만 지우니 실제 가입자들 정보는 하나도 안 건드리고 깨끗하게 정리됐다.
배운 점: 예전 점검에서 "테스트가 만든 데이터를 안 지운다"는 걸 이미 한 번 발견했었는데, "테스트 자체엔 문제없으니 괜찮다"고 넘겼던 게 패착이었다. 테스트가 잘 도는지만 봤지, "그 데이터가 어디에 쌓이는지"는 놓쳤던 것. 다음엔 테스트 전용 DB를 따로 두거나, 테스트 끝나면 자동으로 지우는 걸 해야겠다.
다음에 할 것
- 테스트용 DB를 실제 서비스 DB랑 분리하기
- 그 외 자잘하게 남겨둔 것들: 자동 테스트를 CI에 연결하기, 마이그레이션 스크립트 안전장치 보강