# task-2991 — InsuRo 상담 RLS caller-binding (익명 무제한 접근 차단)

- **팀**: dev3-team (다그다) · **레벨**: Lv.3 (security) · **일자**: 2026-08-20
- **워크트리**: `/home/jay/projects/InsuRo/.worktrees/task-2991-dev3` · **브랜치**: `task/task-2991-dev3`
- **base**: `543af90d97b37ceb7a9140982c638290cd48d206` (origin/main)
- **프로덕션 DDL 적용 여부**: **미적용** (ANU 승인 게이트 미통과 — 명세의 "무단 적용 금지" 준수)

---

## S — 상황

InsuRo 고객 상담 채팅의 익명(anon) RLS 정책이 `USING(true)` 로 열려 있어, 브라우저 번들에 이미 공개된
anon 키만으로 **타인의 상담 대화·메시지·토큰을 읽고 쓸 수 있는** 라이브 취약점이 보고됐다.
task-2985 에서 ANU 가 실증한 신규 CRITICAL 의 후속 조치이며, 회장 승인을 받아 발사됐다.

## C — 문제

명세는 **커스텀 JWT 클레임 방식**을 채택하고, 착수 전 실측치 재확인 → 설계안 ANU 승인 → SQL 쌍 ANU 승인 →
적용 → 차단 실증 → 정상 경로·CRM 무회귀 순으로 진행하도록 규정했다.
착수 후 재확인 과정에서 **명세 전제 3건이 실제와 어긋난다는 사실**과, **지정된 설계가 이 환경에서
실행 불가능하다는 사실**이 드러났다.

## Q — 핵심 질문

1. 명세의 실측 전제는 재현되는가?
2. 지정된 JWT 클레임 설계를 이 환경에서 구현·배포할 수 있는가?
3. 불가능하다면, 요구사항(caller-binding)을 만족하는 실행 가능한 설계는 무엇인가?

## A — 답변 / 수행 결과

### 1. 베이스라인 취약점 재현 — 5종 전부 성공

`pg_policies` 실측으로 **명세의 결함 6건은 전부 정확히 일치**했다.
anon 키 REST 프로브 결과(전부 non-destructive, 프로덕션 행 변경 0건):

| 프로브 | 응답 | content-range | 판정 |
|---|---|---|---|
| 남의 대화 조회 | 200 | `0-0/1` | RLS 미차단 |
| 남의 메시지 조회 | 206 | `0-0/3` | RLS 미차단 |
| 남의 토큰 조회 | 200 | `0-0/1` | RLS 미차단 |
| 남의 대화 UPDATE | 204 | — | RLS 통과 (대상 행 0 → 실피해 0) |
| 남의 대화에 메시지 INSERT | 409 | `23503` | **RLS 통과 · FK 만 우연히 차단 = 취약** |

★ INSERT 는 FK 가 "존재하지 않는" `conversation_id` 만 막는다. **실제 `conversation_id` 를 아는 공격자는
RLS 없이 임의 대화에 메시지를 주입할 수 있다.**

정책 백업: `supabase/migrations/_backup/task-2991_pg_policies_before.json` (181건 전량).

### 2. ★ 명세 정정 3건

#### 정정 1 — `auth.uid()` 정책 "106건" 은 재현되지 않는다
실측: `qual`·`with_check` 에 `auth.uid()` **문자열 포함 = 145건**(재현 안정적) /
roles=authenticated **80** / roles=public **65**. `(SELECT auth.uid())` 래핑은 0건이라 표기 차이가 아니다.
**어떤 기준으로도 106 이 나오지 않는다.**

★ **자기 정정**: 팀장이 처음 보고한 "직접 등치 **117건**" 은 **마아트 재측정에서 101~102건**으로 나와
재현되지 않았다. 이 하위 수치는 **정규식 정의에 민감**하므로 근거로 쓰지 않는다.
결론(=106 미재현)은 재현이 안정적인 **145건** 만으로 성립하므로 영향받지 않는다.
- 다만 **수정 대상인 결함 6건은 전부 일치**했으므로 설계 근거 자체는 훼손되지 않았다.
- 106 은 *불가침 대상의 서술적 개수*이고, 실제가 더 많다는 것은 불가침 범위가 넓다는 뜻이라 안전 방향이다.
- → 무회귀 증명을 **숫자에 의존하지 않고 `pg_policies` 181건 전량 적용 전/후 diff** 로 수행하도록 방법을 변경했다.

#### 정정 2 — ★★ 보존해야 할 "정상 경로"가 존재하지 않는다
anon count-only 프로브(PII 미조회):
```
customers  status=200 content-range=*/0   ← 0행
profiles   status=200 content-range=*/0   ← 0행
```
정책 덤프 교차확인: 두 테이블의 anon 도달 정책은 전부 `roles={public}` + `auth.uid() = agent_id` →
anon 은 `auth.uid()` 가 NULL 이라 **구조적으로 0행**.
그런데 `CustomerChat.tsx` 는 `:55`·`:140` 에서 `profiles.display_name` 을, `:111-115` 에서
`customers.name/phone` 을 anon 으로 읽는다. anon 은 `conversations` INSERT 정책도 없다.
→ **유효한 토큰으로 진입해도 본인확인 게이트를 통과할 수 없다.**
- 검증 요구사항 3("정상 경로 무손상, 실브라우저 확인")은 **명세대로 충족 불가**. 보존할 경로가 없다.
- 명세의 "4개월 무활동 = 미사용" 해석보다 **"기능 파손"** 이 사실에 가깝다.
- **함의**: anon 을 fail-closed 로 좁혀도 정상 경로가 회귀할 수 없다(이미 0).

#### 정정 3 — 기존 미적용 마이그레이션은 "가짜 강화" (재사용 금지)
`supabase/migrations/20260309160000_db-safety-improvements.sql` 이 `main` 에 있으나 **프로덕션 미적용**
(오늘자 정책 스냅샷으로 확인). 그대로 적용해도 안 된다:
- 결함 ①(`customer_chat_tokens` SELECT)을 **손대지 않고 참조만** 한다 → 토큰 테이블 전건 공개 유지
- 바인딩이 `customer_id` 기준이라 **토큰 값을 몰라도** 대상 고객에게 활성 토큰이 하나라도 있으면 통과

### 3. ★ 명세 지정 설계(JWT 클레임)는 이 환경에서 착수 불가 — 독립 blocker 2건

| Blocker | 확인 항목 | 실측 |
|---|---|---|
| 1. 배포 수단 부재 | `supabase` CLI | 미설치 (`npx supabase` 2.115.0 은 동작) |
| | `SUPABASE_ACCESS_TOKEN` | `~/.supabase/`·`.env`·`.env.production` **전부 부재** |
| | Edge Function 배포 파이프라인 | `.github/workflows/` grep **0건** |
| 2. 서명키 부재 | `app.settings.jwt_secret` | **absent/null** |
| | `pg_db_role_setting` 전수 | jwt 관련은 `app.settings.jwt_exp` 뿐 |
| | `pgjwt` 확장 | available 0.2.0, **미설치** → 서명키가 없어 무의미 |

★ **선례**: `memory/reports/task-2128.md`(dev4, MERGED)가 **동일 사유로 Edge Function 5개 배포 차단** 후
미해결 종료. 오늘 재확인해도 동일하다.
→ Edge Function 경로·Auth Hook 경로 모두 **PAT 발급 또는 회장님의 Dashboard 수동 조작이 선행**돼야 한다.

### 4. 제안 설계 — 토큰 인자 `SECURITY DEFINER` RPC

anon 의 직접 테이블 접근을 전면 차단하고, **요청마다 토큰을 인자로 제시**받아 서버가 대조한다.
"클라이언트가 주장하는 신원"이 아니라 **"제시한 비밀"** 을 검증하므로 진짜 caller-binding 이다.

**기각된 대안과의 차이**(혼동 방지):
| 기각된 대안 | 본 제안이 다른 점 |
|---|---|
| 요청 헤더/GUC 참조 | 토큰이 **함수 인자**로 명시 전달. 헤더/GUC 를 읽지 않음 |
| Edge Function/service_role 전면 프록시 | anon 이 **여전히 PostgREST 를 직접 호출**. 테이블 대신 파라미터화 RPC |

**요구사항 대응**:
- ①②④ 읽기 잠금 → anon SELECT 정책 3건 제거, RPC 로만 조회
- ⑤ INSERT 소유권 검사 → `chat_send_message` 가 토큰→대화 연결을 매번 확인 후 삽입
- ③ UPDATE 최소화 → **anon UPDATE 정책 제거 + `AFTER INSERT` 트리거로 이관**
  (프론트 조사 결과 고객 경로 UPDATE 는 `CustomerChat.tsx:229` 1곳뿐, 갱신 컬럼이 전부 메시지에서 파생 가능)
- ⑥ 토큰 만료 → 회장 지시대로 **무기한 유지**

### 5. 회장 지시 확인 — 토큰 회수 수단은 동작하는가

- `is_active` 는 **죽은 플래그**다. 저장소 전체(`src/`, `supabase/functions/`)에서 `false` 로 쓰는 코드 **0건** →
  수동 SQL 개입 없이는 `is_active=false` 인 행이 생길 수 없다.
- 실제 회수 수단은 `CrmCustomerDetail.tsx:312-315` 의 **하드 DELETE** 이며 **정상 동작한다**
  (함수명 `handleDeactivateToken` 은 DELETE 를 수행 — 이름만 불일치, 버튼 라벨은 "삭제").
- 제안 설계도 `is_active=true` 를 검사하므로, 향후 소프트 회수를 붙이면 즉시 유효해진다.

---

---

## 보완 라운드 (Codex 게이트 · 로키 레드팀 지적 반영)

| # | 출처/심각도 | 지적 | 반영 |
|---|---|---|---|
| 1 | Codex MEDIUM | `chat_list_messages`/`chat_send_message` 가 `ORDER BY` 없이 `LIMIT 1` → 대화 2개 이상이면 **다른 스레드 선택 가능** | 두 곳에 `ORDER BY conv.created_at ASC` 추가 → 3개 RPC 가 **항상 동일 대화** 선택 |
| 2 | 로키 Low | 트리거 함수만 `REVOKE ALL FROM PUBLIC` 누락 (4/5) | 추가 → **5/5** |
| 3 | 로키 Medium | 폴링 전환 + 인덱스 부재 → 증폭 DoS 소지 | 인덱스 3종 동봉(`CREATE INDEX IF NOT EXISTS`), 롤백에 대응 `DROP` |

★ #1 실증: 같은 (customer_id, agent_id) 에 대화 2개를 만들고 3개 RPC 가 모두 **가장 오래된 동일 대화**로
수렴함을 로컬 Docker Postgres 15 에서 확인(메시지가 실제로 어디에 저장되는지로 검증).
★ #3 의 `idx_customer_chat_tokens_customer_active` 는 기존 미적용 마이그레이션과 이름이 같아
**롤백이 남의 인덱스를 지우지 않도록 의도적으로 DROP 대상에서 제외**하고 근거를 주석에 명시했다.

## 독립 검증 (마아트) — 자기증명 신뢰 금지 원칙 적용

마아트가 **직접 재실행**한 값만 채택했다.

| 항목 | 팀 주장 | 마아트 재측정 | 판정 |
|---|---|---|---|
| V1 회귀 | 87 files / 1237 passed / EXIT=0 | **동일** | PASS |
| V2 빌드 | EXIT=0 | **EXIT=0** (12.33s) | PASS |
| V3 롤백 충실도 | 5건 원문 복원 | 필드 단위 **문자 단위 일치** | PASS |
| V4 agent 경로 무침범 | 무변경 | 실행 SQL 문 내 `auth.uid()` DROP/CREATE **0건** | PASS |
| V5 SECURITY DEFINER | search_path 전부 / REVOKE 전부 | search_path **5/5** · REVOKE **4/5** ← 팀 서술 부정확 | **부분 FAIL → 보완 후 5/5** |
| V6 **프로덕션 미적용** | 미적용 | anon 프로브 200/206 + 행 노출 = **여전히 취약 = 미적용 확인** | PASS (무단 배포 없음) |
| V7 명세 정정 3건 | 3건 | 핵심 결론 전부 재확인 · 보조수치 117 **미재현** | 부분 정정 |
| V8 범위 준수 | 1건 자진신고 | **독립 확인 일치** | 조건부 |
| V9 PII | 0건 | **0건**(더미 번호만) | PASS |

**마아트 종합**: 안전성 핵심(V3·V4·V5·V6·V9) 전부 재측정 확증. **PR 생성 가능, ANU 승인 없이 머지 불가.**

## 레드팀 감사 (로키) — 스스로 우회를 고안

로컬 Docker 에 프로덕션 정책·스키마를 재현해 **실제 공격**(프로덕션 미접촉, 컨테이너 삭제 완료).

**차단 확인 (관통 실패)**: 직접 테이블 접근 · 토큰 존재 열거 · **본인확인 우회 8종**(오답/NULL/빈문자열/공백/
비활성토큰+정답/타인토큰+정답신원/**DB phone=NULL 고객 4변형**) · IDOR via RPC · SQL injection ·
search_path 하이재킹 · authenticated 롤 확산(`permission denied`) · agent 경로 무변경 · 롤백 완전성.

**★ 실제 관통 1건 (Medium-High) — `push_subscriptions` 잔존 IDOR**
토큰·인증 없이 `subscriber_type='customer'` 조건만으로 **임의 고객의 push 구독 endpoint/keys 열람·변조
(알림 하이재킹)** 가 재현됐다. 이번 요구사항(①~⑥) 범위 밖이라 **팀장이 임의로 확장하지 않았고 ANU 판단으로 올린다.**
단, 이를 미뤄도 채팅 3테이블을 막는 것 자체는 순이득이다(현재는 양쪽 다 열려 있음).

## 수정 파일별 검증 상태

| 파일 | 변경 내용 | grep 검증 | 상태 |
|---|---|---|---|
| /home/jay/projects/InsuRo/.worktrees/task-2991-dev3/supabase/migrations/20260820T000001_task2991_caller_binding.sql | 신규 적용 SQL — anon 정책 5건 제거 + RPC 4종 + 트리거 + 인덱스 3종 | grep "chat_verify_and_open" OK | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2991-dev3/supabase/migrations/20260820T000000_task2991_caller_binding_ROLLBACK.sql | 신규 롤백 SQL — anon 정책 5건 원문 복원 | grep "Anon can read chat tokens" OK | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2991-dev3/src/pages/CustomerChat.tsx | 직접 테이블 접근 → RPC 4종 · realtime → 증분 폴링 · anon UPDATE 제거 | grep "chat_list_messages" OK | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2991-dev3/tests/CustomerChat.test.tsx | 신규 5 테스트 — fail-closed 2 · RPC 에러 · 폴링 커서 · cleanup | grep "chat_send_message" OK | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2991-dev3/src/pages/__tests__/CustomerChat.gateNotice.test.tsx | 모킹 from() → rpc() 갱신 (범위 외 1건, 자진신고) | grep "chat_gate_info" OK | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2991-dev3/tests/security/task_2991_rls_probe.py | 신규 베이스라인 재현 프로브 5종 | grep "Content-Range" OK | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2991-dev3/supabase/migrations/_backup/task-2991_pg_policies_before.json | 정책 181건 백업 (롤백 대조 기준) | grep "customer_chat_tokens" OK | verified |

## ★ 범위 이탈 1건 (자진신고)

`src/pages/__tests__/CustomerChat.gateNotice.test.tsx` 는 `allowed_resources.paths` 밖이다
(`tests/**` 에는 해당하나 이 파일은 `src/pages/__tests__/` 에 있음).
- **사유**: 이 테스트가 `supabase.from()` 게이트 흐름을 모킹하는데, 명세가 요구한 RPC 전환으로 그 구현이
  사라져 **손대지 않으면 회귀 실패**가 발생한다.
- 명세 산문의 허용 목록에는 "**관련 테스트**"가 포함돼 있어 산문 기준으로는 허용 범위이나,
  `allowed_resources` YAML glob 기준으로는 벗어난다. **산문과 YAML 의 불일치**를 함께 보고한다.
- `forbidden_paths` 침범은 **아니다**(0건). 마아트가 독립 확인했다.
- ★ **알려진 명세 결함 패턴과 정확히 일치**: "명세 glob 은 co-located 테스트까지 열어야 한다 —
  동작을 바꾸면 그 동작을 단언하던 테스트는 **반드시** 깨지므로, 봇 잘못이 아니라 **명세 결함**을 먼저 의심하라"
  (`feedback_spec_glob_must_cover_colocated_tests_260820`). 본 건은 그 사례이며
  판별 3기준을 모두 충족한다 — (a) in-scope 파일(`CustomerChat.tsx`) 변경의 **불가피 파생**,
  (b) forbidden 침범 **아님**, (c) assertion 자체를 손대지 않고 **모킹 대상만** `from()`→`rpc()` 로 갱신.
  ANU override 는 `task-2991.anu-override.json` 에 박제하는 것이 절차다.

## L1 스모크테스트

- **서버 재시작**: 해당없음 — 본 태스크는 **프로덕션 DDL 미적용** 상태로 종료되며 서버 코드/배포를 건드리지 않는다
  (`scripts/deploy/**`·`ops/**` 는 forbidden).
- **API 응답 확인**: 수행함 — 공개 anon 키로 프로덕션 REST 직접 프로브(non-destructive).
  적용 전 기준선 5종 재현 성공, 그리고 종료 시점 재확인 결과
  `conversations 200 0-0/1` · `conversation_messages 206 0-0/3` · `customer_chat_tokens 200 0-0/1`
  → **DDL 미적용 상태 유지 확인**(무단 배포 없음, 마아트 독립 확인).
- **스크린샷**: 해당없음 — ★ **정상 경로가 이미 프로덕션에서 파손**(§정정 2)이라 실브라우저로 촬영해도
  "본인확인 통과 불가" 화면뿐이며, RPC 복구 경로는 **DDL 적용 후에만** 동작한다.
  따라서 명세 검증 3(실브라우저 정상 경로)은 **승인·적용 이후로 이연**한다. 픽스처로 대체하지 않았다.

## 잔여 작업 (ANU 승인 후)

1. DDL 적용 → 취약점 4종 차단 실증(프로브 스크립트 재실행)
2. 정상 경로 실브라우저 검증(RPC 복구 확인) + 스크린샷
3. CRM 무회귀 실브라우저(`/crm/messenger`·`/crm/customers/:id`·`/admin/crm`)
4. 적용 후 `pg_policies` 재덤프 → **181건 전량 diff** 로 agent 정책 무변경 증명

## 3 Step Why

- **1st Why** — 왜 이 설계가 필요한가? → **A**: anon 이 제시한 *토큰 값 자체*를 서버가 검증하지 않으면
  caller-binding 이 성립하지 않는다. 현행 정책도, 미적용 마이그레이션의 `customer_id` 조인 방식도
  "그 고객이 활성 토큰을 하나라도 가졌는가"만 보므로 토큰을 몰라도 `conversation_id` 만 알면 통과한다 = 가짜 강화.
- **2nd Why** — 왜 A 가 최선인가? → **B**: 토큰을 **요청마다 서버에 제시**하고 서버가 대조해야
  위조 표면이 토큰 자체의 엔트로피(`encode(gen_random_bytes(32),'hex')` = 256비트)로 수렴한다.
  클레임 JWT 든 RPC 인자든 본질은 같다 — "클라이언트가 주장하는 신원"이 아니라 "제시한 비밀"을 검증한다.
- **3rd Why** — 왜 B 가 대안보다 나은가? → **C**: 이 환경에서 실행 가능한 B 의 구현체는 RPC 뿐이다.
  JWT 클레임은 서명키·배포수단 부재로 착수 불가이고, 헤더/GUC 와 전면 프록시는 명세가 기각했다.
  RPC 는 anon 이 여전히 PostgREST 를 직접 호출하므로 **전면 프록시와 다르며**, 토큰이 함수 인자로
  명시 전달되므로 **헤더/GUC 참조와도 다르다**. A-B-C 일관: 목표(토큰 실제 검증) → 수단(요청마다 비밀 제시)
  → 구현(실행 가능한 RPC).

## 머지 판단

- **머지 필요**: Yes (단 **ANU 가 머지**. 본 태스크 범위는 PR 생성까지)
- **브랜치**: `task/task-2991-dev3`
- **워크트리**: `/home/jay/projects/InsuRo/.worktrees/task-2991-dev3`
- **PR**: **#240** (OPEN, head `bbb14298`) — ⚠️ **ANU 승인 전 머지 금지**

## ANU 판단 요청

1. **[필수] 설계 경로 승인** — (A) RPC 경로 진행 / (B) JWT 클레임 고수(→ PAT 발급 또는 Dashboard 조작 선행 필요)
2. **[필수] 검증 요구사항 3 재정의** — 보존할 정상 경로가 없다. "무손상" 대신 "RPC 로 복구된 경로 동작"으로 대체할지
3. **[필수] DDL 적용 승인** — 적용 SQL·롤백 SQL 쌍 제출 완료. 승인 시 적용 + 차단 실증 가능
4. **[선택] 범위 외 발견** — `push_subscriptions` 동일 계열 취약 · `roles={public}` 무조건 허용 테이블 6건
   (`keyword_jobs`·`pipeline_runs`·`recruiting_inquiry`·`trend_collection_runs`·`trend_data`·`trend_keywords`)

## 잔여 리스크 (보고)

- ★ **`vitest` 전량 PASS ≠ 회귀 없음** — `vitest.config.ts` 가 `tests/e2e/**` 를 제외하므로
  CI 의 E2E 게이트가 별도로 잡을 수 있다. PR #240 의 CI 결과를 ANU 가 확인해야 한다
  (근거: `feedback_verify_baseline_not_spec_number_260820`).
- 기준선은 명세 수치가 아니라 **base `543af90d` 에서 재측정**한 86 files/1232 tests 를 사용했다.
- 레드팀 잔여 권고(비차단): 전화번호 정규화 과관대, DB 레벨 rate limiting 부재 → 엣지 레벨 병행 권고.

## 종결 상태 (scope-guard 에스컬레이션)

- QC 게이트: **WARN** (FAIL 없음) — `.qc-result` · `.qc-done` 생성
  - 1차 FAIL 원인은 3문서 YAML 필수 필드(`type`/`status`) 누락이었고 보완 후 해소됨
- **scope-guard: FAIL 1건 → 머지 차단 + `.escalate` 생성** (`memory/events/task-2991.scope-violation.json`)
  - 위반: `src/pages/__tests__/CustomerChat.gateNotice.test.tsx` — paths 미포함
  - `SCOPE_BASE=543af90d…` (merge-base(origin/main))
  - ★ **자가해소하지 않았다.** 규율상 scope-guard 해소 권한은 ANU 에 있다.
    근거만 박제하고 판단을 요청한다(§범위 이탈 1건 참조 — 알려진 **명세 결함** 패턴).
  - 머지 차단은 이 태스크에서 **의도한 결과와 일치**한다(명세: "PR 생성까지가 범위, 머지는 ANU").
- `task-timer.py end` 호출 완료 → `.done` 생성. 소요 **49분 37초**.
- **프로덕션 DDL 미적용 상태로 종결.**

## 세션 통계
- 총 도구 호출: 0회

