본문으로 건너뛰기
AIPida
검증됨실전AI 코딩

바이브 코딩으로 만든 Supabase 앱, 요청의 user_id만 바꾸면 남의 데이터가 보입니다. 어디가 뚫린 건가요?

Cursor + Supabase로 주말에 사이드 프로젝트(회원 메모 앱)를 거의 vibe coding으로 만들었습니다. 로그인/회원가입은 다 됩니다. 그런데 친구가 브라우저 네트워크 탭에서 GET /rest/v1/notes?user_id=eq.<남의 uuid> 요청의 user_id 값만 다른 사람 uuid로 바꿨더니 남의 메모가 그대로 응답으로 왔습니다.

  • 프론트: Next.js(App Router), @supabase/supabase-js 클라이언트에서 직접 쿼리
  • .envNEXT_PUBLIC_SUPABASE_ANON_KEY 사용
  • 코드에서는 .eq('user_id', session.user.id)로 분명히 필터하는데도 우회됩니다

AI가 짜준 코드라 어디가 구멍인지 모르겠습니다. 어디부터 봐야 하나요?

답변 1

  • 채택된 답변에디터 검증

    결론부터: 클라이언트의 .eq('user_id', ...)는 보안 장치가 아니라 그냥 쿼리 파라미터입니다. 네트워크 탭이나 curl로 누구나 값을 바꿀 수 있습니다. 실제 방어선은 DB의 RLS(Row Level Security) 정책인데, vibe coding 결과물은 RLS가 꺼져 있거나(테이블에 enable row level security가 안 걸림) 정책이 한 줄도 없는 경우가 대부분입니다. 응답이 남의 행을 그대로 돌려준다는 건 이 둘 중 하나라는 신호입니다.

    .eq()가 무력한가

    anon 키로 나가는 PostgREST 요청에서 user_id=eq.<uuid>는 SQL로 번역되면 그냥 WHERE user_id = '<uuid>'입니다. 이 값은 클라이언트가 보낸 것이라 공격자가 임의로 바꿀 수 있습니다. RLS 정책이 없으면 Postgres는 그 WHERE를 그대로 실행해 남의 행을 돌려줍니다. "코드에서 필터했으니 안전"은 곧 "클라이언트를 신뢰한다"는 뜻이라 성립하지 않습니다.

    1분 진단

    -- Supabase SQL Editor에서 실행: RLS 꺼진 public 테이블 찾기
    select relname, relrowsecurity
    from pg_class
    where relnamespace = 'public'::regnamespace and relkind = 'r';
    -- relrowsecurity = false 인 테이블이 전부 구멍
    
    -- 테이블별 정책 개수 확인
    select tablename, count(*) from pg_policies
    where schemaname = 'public' group by tablename;
    -- notes가 목록에 없으면 정책 0개 = 무방비
    

    그리고 curl로 직접 재현해 보세요. 이게 데이터를 돌려주면 확정입니다.

    curl 'https://<ref>.supabase.co/rest/v1/notes?user_id=eq.<남의uuid>' \
      -H "apikey: <ANON_KEY>" -H "Authorization: Bearer <ANON_KEY>"
    

    고치는 코드

    alter table public.notes enable row level security;
    
    -- auth.uid()는 요청 JWT에서 서버가 끌어오는 값 → 클라이언트가 위조 불가
    create policy "own notes select" on public.notes
      for select using (auth.uid() = user_id);
    create policy "own notes insert" on public.notes
      for insert with check (auth.uid() = user_id);
    create policy "own notes update" on public.notes
      for update using (auth.uid() = user_id) with check (auth.uid() = user_id);
    create policy "own notes delete" on public.notes
      for delete using (auth.uid() = user_id);
    

    핵심은 클라이언트가 보낸 user_id가 아니라 JWT에서 추출되는 auth.uid()로 비교한다는 점입니다. 적용 후 위 curl이 빈 배열 []을 돌려주면 막힌 겁니다.

    같이 확인할 함정

    • service_role 키 노출: NEXT_PUBLIC_*service_role 키를 넣으면 RLS를 통째로 우회합니다. 클라이언트 번들에 service_role이 섞이면 RLS를 아무리 짜도 무의미합니다. grep -rn service_role .next/ public/로 키가 빌드 산출물에 들어갔는지 확인하세요. 클라이언트에는 반드시 anon 키만 둡니다.
    • insert/updatewith check 누락: select만 막고 쓰기 정책에서 with check를 빠뜨리면 남의 user_id로 행을 심거나 옮길 수 있습니다.
    • 정책 있어도 RLS 비활성: 정책을 만들어 놓고 enable row level security를 안 걸면 정책이 평가되지 않습니다. 위 relrowsecurity 쿼리로 둘 다 확인하세요.
    • storage 버킷: notes에 첨부가 있으면 Storage 객체도 같은 우회가 가능합니다. 버킷 정책도 별도로 확인합니다.

    한국 맥락 / 교훈

    메모 UI 정도는 vibe coding으로 빠르게 만들어도 되지만, PII·결제·인증이 닿는 테이블은 vibe로 맡기면 안 됩니다. 한국이면 회원 메모도 개인정보보호법상 개인정보 처리이고, 이런 유출은 단순 버그가 아니라 신고 대상이 될 수 있습니다. 순서를 뒤집으세요: auth.uid() 기반 RLS를 먼저 "계약"으로 깔고, 그 위에서만 vibe로 UI를 굴립니다. AI가 짠 DB 접근 경로의 diff는 반드시 직접 읽어야 합니다.