API를 막았는데 데이터는 여전히 새고 있었다 — Realtime과 PostgREST라는 두 개의 뒷문
랜챗(랜덤 1:1 화상채팅)에 Google 로그인을 붙이는 작업을 하면서, 소셜 유저의 randchat_users.id를 Supabase auth uid와 같은 값으로 쓰기로 했다. 로그인한 사람과 랜챗 신원을 1:1로 묶는 가장 간단한 방법이었고, 당장은 별문제 없어 보였다. 익명 유저와 소셜 유저를 같은 테이블에서 다루는 구조를 유지하면서도 로그인 상태를 표시하려면 이 방법이 제일 손이 덜 갔다.
그런데 익명 입장 경로를 다시 보니 클라이언트가 보낸 userId를 서버가 uuid 형식인지 정규식으로만 확인하고 그대로 신뢰하고 있었다. randchat_users.id에는 auth.users를 가리키는 FK도 없었다. 즉 남의 auth uid 문자열만 알면 그 값을 자기 신원이라 주장하며 익명으로 입장할 수 있었고, 그 순간 upsert가 실제 계정 행을 익명으로 되돌려버렸다. 신고·차단·메시지 이력이 엉뚱하게 그 계정에 귀속되는 상황이었다. 계정을 가진 사람 입장에서는 자기가 로그인하지도 않은 세션의 신고 이력이 자기 계정에 쌓이는 셈이니, 단순한 버그라기보다는 신원 도용에 가까웠다.
uid는 어떻게 새고 있었나
문제는 그 uid를 어디서 구하느냐였는데, 답은 가까이에 있었다. 블로그 댓글 API가 인증 없이 응답 본문에 user_id를 그대로 실어 보내고 있었다. 댓글 하나만 조회해도 작성자의 auth uid를 얻을 수 있었던 것이다. 댓글 작성자 목록을 순회하며 조회하면 로그인한 사용자들의 uid를 모아둔 목록을 만드는 것도 이론상 가능했다.
여기까지가 계획을 세울 때 파악한 문제의 전부였다. API 응답에서 user_id를 빼고, 서버가 클라이언트의 신원 주장을 거부하도록 고치면 끝날 일이라고 생각했다. 실제로 이 두 가지는 필요한 조치였고, 나중에 그대로 적용했다. 다만 그게 전부가 아니었다.
API만 고치면 끝일 줄 알았다
작업 리뷰 단계에서 같은 데이터에 닿는 다른 경로가 있다는 지적이 나왔다. Supabase Realtime의 postgres_changes는 WAL에 기록된 원본 행을 구독자에게 그대로 브로드캐스트한다. API 라우트가 응답에서 user_id 컬럼을 지워도, 같은 테이블을 postgres_changes로 구독하고 있으면 행 전체가 클라이언트로 넘어간다. 서버가 클라이언트에게 무엇을 보여줄지 결정하는 계층과, DB 변경 이벤트를 그대로 흘려보내는 계층이 완전히 분리되어 있다는 걸 그때 다시 실감했다. API 라우트 코드를 아무리 다듬어도 Realtime 구독 자체는 별개의 통로였다.
댓글 테이블을 다시 확인해보니 실제로 이 publication에 포함돼 있었다. 목록이 갱신될 때 새 댓글을 실시간으로 반영하려고 붙여둔 기능이었는데, 그 기능이 그대로 uid를 새어나가게 하는 통로였던 셈이다. 기능을 만들 때는 "댓글이 실시간으로 보이면 좋겠다"는 생각만 있었지, 그 구독이 어떤 컬럼까지 함께 흘려보내는지는 따져보지 않았다.
PostgREST라는 세 번째 문
최종 리뷰에서 또 다른 경로가 나왔다. Supabase의 anon 키는 브라우저 번들에 그대로 실려 있다. RLS로 행 단위 접근을 막아두지 않는 한, 누구든 그 키로 PostgREST를 통해 테이블을 직접 조회할 수 있다. 브라우저 개발자 도구에서 네트워크 요청 몇 개만 살펴봐도 anon 키를 얻을 수 있고, 그 키로 Supabase REST 엔드포인트에 직접 쿼리를 날리는 데는 특별한 기술이 필요 없다. API 라우트를 어떻게 고치든, 애초에 클라이언트가 테이블에 직접 SELECT를 던질 수 있으면 그 경로는 API 코드와 무관하게 열려 있는 문이었다.
더 나쁜 건 이 두 경로 — Realtime과 PostgREST 직접 조회 — 모두 user_id뿐 아니라 게스트 댓글의 삭제용 비밀번호 bcrypt 해시까지 함께 노출하고 있었다는 점이다. bcrypt 해시라 즉시 원문 비밀번호가 나오는 건 아니지만, 애초에 클라이언트에 노출될 이유가 없는 값이었다. 컬럼 하나를 API 응답에서 지우는 조치로는 애초에 손댈 수 없는 데이터였다.
계층별로 나눠 막았다
결국 대응은 한 곳이 아니라 여러 계층에 걸쳐 이뤄졌다. 순서도 중요했다 — 권한을 먼저 회수하면 서버 자체 조회까지 막혀버리기 때문에, 서버 조회 방식을 먼저 바꾸고 나서 권한을 회수하는 순서로 진행했다.
- 서버가 클라이언트의 신원 주장(
userId)을 더 이상 그대로 신뢰하지 않도록 수정 - DB 트리거로 익명 upsert가 소셜 컬럼을 덮어쓰지 못하게 차단 (마이그레이션 009)
- 댓글 API 응답에서 uid 노출 제거
- Realtime publication에서 해당 테이블 제외 (마이그레이션 010)
- anon 롤의 테이블 SELECT 권한을 회수하고, 공개해도 되는 컬럼만 다시 grant (마이그레이션 011)
마지막 항목을 적용하려면 선행 작업이 하나 더 필요했다. 서버 사이드에서 하던 조회 중 일부가 anon 키에 의존하고 있어서, 그 조회들을 먼저 service key 기반으로 옮겨야 anon의 SELECT 권한을 안전하게 회수할 수 있었다. 순서를 반대로 했다면 권한을 회수하자마자 서버 자체가 댓글을 못 읽는 상태가 됐을 것이다. 마이그레이션 파일 하나로 끝나는 작업이 아니라, 코드 변경과 DB 변경이 서로 앞뒤를 기다려야 하는 작업이었던 셈이다.
다섯 항목을 나열해놓고 보면 별거 아닌 목록처럼 보이지만, 실제로는 각각이 서로 다른 계층을 건드리는 조치였다. 신원 검증은 애플리케이션 코드, 소셜 컬럼 보호는 트리거, uid 노출 제거는 API 응답 형태, publication 조정과 권한 회수는 DB 설정. 하나의 원인에서 시작된 문제였지만 대응은 네 개의 서로 다른 표면에 걸쳐 있었다.
교훈
같은 데이터에 닿는 경로를 전부 세어보지 않으면 "막았다"는 말은 거짓이 된다. Supabase는 특히 REST·Realtime·RLS·컬럼 권한이 서로 독립적으로 동작하는 구조라, 한 곳만 막아두고 안심하기가 쉽다. API 라우트는 그중 하나의 문일 뿐이었고, 나머지 문들은 애초에 "API"라는 개념 바깥에 있었다.
그리고 이 세 경로 — API 응답, Realtime, PostgREST 직접 조회 — 를 각각 다른 리뷰 단계가 찾아냈다는 점도 남겨둘 만하다. 처음 계획할 때는 API 하나만 보였고, 작업 리뷰에서 Realtime이 나왔고, 최종 리뷰에서야 PostgREST가 나왔다. 한 번의 검토로는 다 나오지 않았다. 데이터가 새는 경로를 찾는 일은 코드를 한 번 훑어서 끝나는 작업이 아니라, "이 데이터에 또 어떻게 닿을 수 있지"를 매번 다시 되묻는 과정에 가까웠다. 리뷰 단계를 여러 번 거치도록 남겨둔 게 실제로 문제를 잡아냈다는 점에서, 검토 횟수 자체가 방어선이라는 생각이 들었다.