dev.to

The No-Backend Backend: Neon Data API, RLS and 27 Attacks

백엔드 없는 백엔드: Neon Data API, RLS, 그리고 27가지 공격

브라우저에서 Neon Data API를 직접 호출하는 멀티테넌트 작업 보드의 보안 구조를 만들고, 27가지 공격과 네 가지 설정 실수로 검증합니다. RLS와 컬럼 권한을 함께 쓰면 대부분의 공격을 막지만, 토큰 만료 전 멤버를 제거해도 최대 15분가량 접근이 이어질 수 있어 실시간 멤버십 확인이 필요합니다.

AI 요약

이 글은 별도의 API 서버나 서버리스 함수를 두지 않고 멀티테넌트 작업 보드를 구현한 뒤, 권한 설정이 실제 공격을 막는지 검증합니다. React 정적 파일이 Neon Auth로 로그인하고 Neon Data API에 직접 요청합니다. 팀은 Neon Auth 조직으로 표현하며, 데이터 접근 권한은 PostgreSQL의 행 수준 보안(Row-Level Security, RLS) 정책과 컬럼 권한, 함수 한 개로 통제합니다. 서버 코드가 없어지는 대신 보안 검토와 권한 로직이 SQL에 집중됩니다.

토큰에 담긴 조직 정보로 행을 격리합니다

Neon Auth는 사용자가 속한 조직을 JWT의 클레임에 기록합니다. PostgreSQL의 auth.organization_id()가 이 값을 읽고, 각 RLS 정책은 행의 org_id와 비교합니다. 테이블의 org_id 기본값도 토큰에서 가져오므로 클라이언트가 다른 팀을 지정할 필요가 없습니다. 기본 키는 (org_id, id)로 구성합니다. ID를 다른 팀과 공유하지 않으므로 공격자가 다른 팀 데이터에 존재하는 ID를 알아내는 상황도 줄입니다.

RLS는 접근 가능한 행을 정하고, GRANT는 접근 가능한 테이블·컬럼·함수를 정합니다. 글에서는 먼저 기존 권한을 회수한 뒤, 인증된 사용자에게 필요한 권한만 부여합니다. 작업 생성과 수정은 title, done 컬럼으로 제한합니다. org_id와 created_by는 클라이언트가 직접 지정하거나 바꿀 수 없습니다. 삭제는 RLS 정책에서 조직 내 owner 또는 admin으로 제한합니다.

대시보드 요약 함수 org_summary()는 security definer로 실행합니다. 이 함수 안에서는 RLS가 적용되지 않으므로 WHERE 절에 호출자의 조직을 제한하는 조건을 직접 넣어야 합니다. 함수 실행 권한은 PUBLIC에서 회수하고 인증된 사용자에게만 부여합니다. 작성자는 security definer 함수를 별도의 보안 경계로 보고, 테넌트 필터와 실행 권한을 따로 검증해야 한다고 설명합니다.

27가지 공격을 막고, 설정 실수도 재현합니다

공격 스크립트는 Acme 조직의 소유자 Alice, 멤버 Bob, 다른 조직 Evil Corp의 소유자 Eve를 사용합니다. Eve가 Acme의 데이터를 노리는 공격 25가지와 Acme 내부에서 잘못된 역할을 시험하는 공격 두 가지를 보냅니다. 테스트는 클라이언트 라이브러리 대신 원시 HTTP 요청을 사용합니다. 공격마다 기대한 HTTP 상태와 응답을 정확히 검사하고, 공격 전후 Acme 데이터의 모든 컬럼을 비교합니다. 오류 응답 뒤에 데이터가 바뀌는 경우도 성공으로 처리하지 않습니다. 결과는 27건 모두 차단입니다.

공격에는 필터 조작, 관계 데이터 삽입 조회, 전체 행 수 집계, 다른 조직 ID로 삽입·수정·삭제, 다른 팀 작업 ID에 대한 upsert, 위조 JWT, alg: none 토큰, 공격자 키로 서명한 토큰이 포함됩니다. 집계 결과도 RLS가 허용하는 행만 셉니다. RLS가 차단한 UPDATE와 DELETE는 오류 대신 HTTP 200과 빈 결과를 반환할 수 있습니다. 따라서 애플리케이션은 상태 코드만 보지 말고 반환된 행도 확인해야 합니다.

이어 현실적인 설정 실수 네 가지를 하나씩 적용합니다. 삽입 정책을 WITH CHECK (true)로 바꿔도 컬럼 권한이 좁으면 공격은 통하지 않습니다. 클라이언트가 org_id를 쓸 권한이 없기 때문입니다. 하지만 여기에 테이블 전체 권한까지 부여하면 다른 조직 ID를 지정한 삽입 공격이 성공합니다. security definer 함수에서 조직 필터를 빼면 요약 RPC가 다른 조직의 집계를 노출합니다. task_comments 테이블에서 RLS를 끄면 다른 팀 댓글을 읽고 작성자 권한 정책도 우회합니다. 테스트는 각 실수에서 예상한 공격만 통과하는지 검사한 뒤 원래 설정으로 복구합니다.

멤버 제거 뒤에도 기존 토큰은 살아 있습니다

가장 큰 제한은 토큰의 유효기간입니다. 조직에서 Bob을 제거해도 이미 발급된 JWT는 약 900초 동안 조직 정보를 유지합니다. 테스트에서는 제거 직후에도 기존 토큰으로 작업과 댓글을 읽고, 요약 RPC를 호출하고, 새 작업을 삽입했습니다. 작업 읽기는 토큰 만료 시점에서 약 28초 지난 뒤까지 성공했습니다. 새 토큰 발급 요청은 HTTP 500을 반환했지만, 이미 가진 토큰에는 영향을 주지 않았습니다.

글에서는 이 지연을 줄이려고 RLS 정책마다 Neon Auth의 neon_auth.member 테이블에서 현재 멤버십을 확인하는 security definer 함수를 추가합니다. 이 테이블은 인증된 역할이 직접 읽을 수 없기 때문입니다. 정책에서 (select current_org_role())처럼 호출하면 PostgreSQL이 행마다 반복하지 않고 문장당 한 번 평가합니다. security definer 함수 안에는 RLS가 적용되지 않으므로 org_summary()의 WHERE 절에도 같은 멤버십 검사를 추가합니다. 이 설정에서는 멤버 제거 직후 기존 토큰의 읽기·쓰기·RPC 요청이 차단됐습니다. 기존 공격 27건도 모두 차단 상태를 유지했습니다.

지연 시간과 측정 범위

요청 100건씩을 번갈아 순차 실행한 결과, 토큰만 확인하는 설정에서 p50은 작업 GET 41.4ms, 요약 RPC 41.3ms, PATCH 44.1ms였습니다. 같은 주소로 TCP 연결만 한 측정은 39.7ms였습니다. 멤버십 확인을 추가한 설정에서는 GET 41.8ms, RPC 46.4ms, PATCH 45ms였습니다. 읽기와 쓰기는 1ms 이내 차이였고 RPC는 약 5ms 느렸습니다. 다만 두 측정은 약 16분 간격으로 진행했고 네트워크 변동도 있을 수 있다고 밝혔습니다. 작은 테스트 데이터로 측정했으므로 대규모 데이터에서 정책의 하위 쿼리 비용까지 검증한 결과는 아닙니다.

배포 파일은 JavaScript 587kB, gzip 기준 161kB, CSS 11kB와 HTML입니다. 운영할 서버는 없지만 보안 로직이 SQL에 있으므로 정책과 권한을 꼼꼼히 검토해야 합니다. 글은 테이블 단위가 아닌 컬럼 단위 권한을 쓰고, 모든 security definer 함수의 테넌트 필터를 확인하며, 적대적인 요청과 일부러 약화한 설정을 테스트 스위트에 남기라고 권합니다. 그 밖에 새 함수 등록 뒤 Data API 스키마 캐시를 갱신해야 하는 점, 팀을 바꾸면 새 토큰이 필요해 페이지를 다시 불러오는 점, 브라우저 쿠키 동작은 Chromium에서만 확인한 점도 언급합니다.

dev.to 반응

  • @pierrelaurentmedori — 정말 탄탄한 글입니다. 제가 가져갈 부분은 break 스크립트입니다. 한 번도 실패하지 않은 테스트 스위트만으로는 많은 걸 알 수 없습니다. WITH CHECK (true)만으로는 문제가 없고 기본 테이블 전체 권한을 더했을 때만 공격이 성공한다는 점을 보여준 덕분에, 두 번째 방어벽이 필요한 이유가 어떤 그림보다 잘 드러납니다. 15분 지연도 와닿았습니다. 저희는 서드파티 API 키를 서버에 보관하는 프록시를 운영하고, 테넌트 JWT는 15분짜리로 발급합니다. 결국 엄격 모드와 같은 방식으로 갔습니다. 토큰은 발급 당시 상태를 담은 스냅샷이므로 취소해야 하는 정보는 실시간으로 읽어야 합니다. 저희는 요청마다 주 데이터베이스의 컬럼을 확인하는 킬 스위치를 씁니다. 클레임이나 캐시 항목이 아닙니다. neon_auth.member를 조회하는 방식도 좋습니다. 요청이 이미 사용해야 하는 데이터베이스에 정보가 있으니, 폐기 판단을 위한 별도 장애 지점이 생기지 않습니다. 테넌트 컬럼도 같은 원리입니다. 기본값을 auth.organization_id()로 두고 org_id 권한을 주지 않으면 클라이언트가 테넌트를 지정할 수 없습니다. 저희 위젯도 같은 이유로 호스트가 아니라 상대 경로만 보냅니다. 호출자가 선택할 수 없는 값은 잘못된 권한 판단에 이용하기 어렵습니다. 한 가지 질문이 있습니다. 팀에서 제거된 뒤 /token이 HTTP 500을 반환하면 앱은 ‘팀에서 제거됨’과 ‘Neon Auth 장애’를 어떻게 구분하나요? 모든 500을 제거로 처리하면 장애 때 모든 사용자가 UI에서 팀을 잃습니다. 모두 장애로 처리하면 제거된 사용자는 계속 ‘다시 시도하세요’라는 메시지를 봅니다. 결정 전에 조직 목록을 다시 확인하나요, 아니면 다른 신호가 있나요?

원문: dev.to / 번역·요약: Trawling