대니
danny's blog@dannywon_dev
발행 16 · 대기 73

1인 개발자. 클로드 코드를 팀처럼 굴린다. 매일 겪은 것만 쓴다.

버그와 함정

httpOnly 쿠키라 안전하다고 적었다 — 같은 값이 DB 평문 컬럼에 있었다

익명 대화 귀속 함수에 authenticated 권한을 줬다. 리뷰 첫 문장에서 뚫린 경로가 나왔다.

권한 목록을 조회해서 anon 이 없는 걸 확인하고 통과시킨 적이 있다면 이 글이 맞다.

2026-08-04, 내 서비스에 「비회원으로 쌓은 대화를 가입하면 그 계정에 붙여준다」 기능을 붙였다. 관람객이 QR 을 찍으면 익명으로 챗봇 대화방에 들어가는 구조라서, 로그인 전에 쌓인 대화를 잃지 않게 하는 게 목적이었다.

익명 사용자는 visitor_id 라는 UUID 쿠키로 식별된다. 그래서 귀속 함수(Postgres RPC)의 인자가 자연스럽게 visitor_id 가 됐다. 문제는 그 값이 정말 호출자 것인지 함수가 증명할 방법이 없다는 점이었다. 로그인한 사람이 누군지는 auth.uid() 로 알 수 있지만, 「이 익명 id 가 그 사람 것인가」를 검사할 연결고리가 애초에 없다. 익명이니까.

알면서도 authenticated(로그인한 아무나) 에게 실행 권한을 줬다. 마이그레이션 주석에 근거를 이렇게 적어뒀다.

"p_visitor_id 는 호출자 쿠키에서 온다. 남의 visitor_id 를 알면 그 사람의 익명 대화를 가져갈 수 있다는 뜻이지만, 그 쿠키는 httpOnly(브라우저 JS 가 못 읽는다) + 추측 불가능한 UUID 다. 장바구니 병합류에서 쓰는 것과 같은 수준의 노출이라 그대로 둔다."

운영 DB 에 배포했다. 권한도 확인했다 — proacl{postgres=X, authenticated=X, service_role=X}. anon 은 없다. 깔끔해 보였다.

리뷰 첫 문장에서 끝났다

배포하고 나서 리뷰를 한 번 돌렸다. 첫 문장이 이거였다.

httpOnly 는 브라우저 JS 만 막는다. 서버에는 visitor_id 가 갤러리 계정에게 열려 있다.

같은 visitor_id 값이 두 테이블에 평문 컬럼으로 저장돼 있었다. 그리고 그 테이블들엔 "자기 부스에 해당하는 행은 읽을 수 있다" 는 행 수준 보안(RLS) 정책이 걸려 있었다. 내가 직접 만든 정책이다.

그래서 경로가 이렇게 뚫린다. 갤러리 계정이 자기 화면에서 관람객의 visitor_id 를 조회한다. 그 값을 들고 자기 관람객 계정으로 로그인해서 함수를 직접 호출한다. 그러면 그 관람객이 다른 갤러리와 나눈 대화까지 통째로 넘어온다.

부수 피해도 둘 있었다. 진짜 주인은 나중에 로그인해도 영영 자기 대화를 가져갈 수 없다. 그리고 공격자에게 더 오래된 세션이 있으면 병합 로직이 피해자 세션을 삭제한다.

확인한 것과 확인하지 않은 것

내가 확인한 건 「누가 이 함수를 부를 수 있나」 였다. proacl 을 조회했고, anon 이 없는 걸 봤고, 통과시켰다.

확인하지 않은 건 「이 비밀이 어디어디에 있나」 였다.

쿠키 플래그를 비밀성의 근거로 쓴 게 실수의 핵심이다. httpOnly하나의 경로(그 브라우저의 자바스크립트)를 막는 플래그지, 그 값의 성질이 아니다. 나는 그걸 "남이 모른다" 로 번역했다. 번역하는 순간 값이 저장된 다른 자리들이 시야에서 사라졌다.

더 나쁜 건 비유가 검증을 대신했다는 점이다. "장바구니 병합류와 같은 수준" 이라고 적는 순간 판단이 끝나버렸다. 익숙한 패턴에 갖다 붙이면 문장이 그럴듯해지는데, 그 패턴이 내 구조에서도 같은 조건을 갖는지는 안 봤다.

함수를 다시 썼다

service_role 전용으로 좁혔다. 서버만 가진 키다. 호출 표면은 로그인 콜백 하나로 만들었다. 회원 id 는 서버가 확인한 값을 인자로 넘긴다. 그러면 호출자가 남의 익명 id 를 넣을 자리 자체가 없어진다.

검증은 예상과 반대 방향으로 했다. 함수를 직접 불러보니 permission denied for function 이 떴다 — 에러가 나는 게 정답인 상황. 잠금이 실제로 걸렸다는 뜻이다.

그리고 전역 규칙 파일(~/.claude/rules/supabase.md)에 두 절을 새로 넣었다. 이 판단은 프로젝트를 안 가리기 때문이다. 핵심은 판정 기준 한 줄이다.

그 값이 DB 어느 컬럼에 있고, 그 테이블을 누가 읽나. 비밀의 수명은 가장 넓게 열린 경로가 정한다 — 쿠키 플래그가 아니라.

← 목록으로