# task-3000 — InsuRo 푸시 RPC 초크포인트 — 적용 전 기준선 (Morrigan / dev3 QA)

- 담당: 모리건 (개발3팀 테스트/QA)
- worktree: `/home/jay/projects/InsuRo/.worktrees/task-3000-dev3` (base=96ad2b2)
- DB: aws-1-ap-northeast-2.pooler.supabase.com:5432 (session mode, sslmode=require)
- 실행 시각 (DB `now()` 기준): 2026-08-22 07:58:25 UTC (백업 덤프), 이후 재현 스크립트는 동일 세션에서 즉시 실행·ROLLBACK
- 모든 쓰기는 `BEGIN ... ROLLBACK` 안에서 수행. 스크립트 실행 직후 `push_subscriptions` 행 수를 재조회해 0건(변경 없음) 확인 완료.

## A. 프로덕션 RPC 본문 전문 백업

- 저장 위치: `/home/jay/projects/InsuRo/.worktrees/task-3000-dev3/supabase/migrations/_backup/task-3000_rpc_before.sql`
- `pg_get_functiondef()` 로 전문 덤프, 파일 상단에 덤프 시각 + 각 함수 sha256 주석 기록
- `public.chat_register_push_subscription` — sha256 `e89444e1fcd80e5417661893b82b440e8beb1b4ab70613473e3a0ccb00da580d`
- `public.chat_unsubscribe_push` — sha256 `c522e51ff05105b3333ed4e77670792f7c24605934100c385bfd241e22893c0c`
- 두 함수 모두 단일 오버로드만 존재 (oid 34045, 34046) — `regprocedure` 캐스팅 대신 `proname`+`pronamespace` 조회로 확정

### 두 RPC 의 현재 ACL (`proacl`)
- `chat_register_push_subscription`: `{postgres=X/postgres,anon=X/postgres,authenticated=X/postgres,service_role=X/postgres}`
- `chat_unsubscribe_push`: `{postgres=X/postgres,anon=X/postgres,authenticated=X/postgres,service_role=X/postgres}`
- 즉 `anon` 롤에게 EXECUTE(`X`) 권한이 살아있음 — 익명 클라이언트가 이 RPC 를 직접 호출 가능

### push_subscriptions 실측
- 현재 행 수: **0건** (덤프 시점 기준, 프로덕션 실사용 데이터 없음)
- anon 정책 3건 모두 생존 확인 (`pg_policies` 조회):
  - `Anon can insert push subs` — INSERT, `WITH CHECK (subscriber_type = 'customer')`
  - `Anon can read push subs` — SELECT, `USING (subscriber_type = 'customer')`
  - `Anon can update push subs` — UPDATE, `USING/WITH CHECK (subscriber_type = 'customer')`
  - (task-2996 이 도입한 caller-binding 강화는 이 3건에는 적용되어 있지 않음 — `conversation_id` 기반 소유권 검사가 없고 `subscriber_type='customer'` 라는 자기선언 값만으로 통과)
- 컬럼 정의 (`information_schema.columns`):
  - `conversation_id uuid NOT NULL`
  - `endpoint text NOT NULL` — **형식 제약(CHECK/도메인/정규식) 없음**
  - `subscriber_type text NOT NULL DEFAULT 'customer'`
  - `is_active boolean NOT NULL DEFAULT true`
  - `expires_at timestamptz NOT NULL DEFAULT now() + 24:00:00`
  - (그 외: `id uuid`, `p256dh text NOT NULL`, `auth text NOT NULL`, `subscribed_at timestamptz`, `created_at timestamptz`)

### RPC 로직 확인 (백업 파일에서 직접 읽은 사실)
- `chat_register_push_subscription`: `p_endpoint` 에 대해 **NULL/빈문자열 검사만** 수행 (`btrim(p_endpoint) = ''`). URL 스킴·호스트·형식 검증 코드가 **전혀 없음**.
- 토큰→대화 해석: `customer_chat_tokens.token` 이 `is_active=true` 이고 대응 `conversations` 가 있으면 통과. 동일 `(conversation_id, endpoint, subscriber_type)` 조합이면 UPDATE, 없으면 INSERT — **호출 횟수·distinct endpoint 개수에 대한 상한(rate limit) 코드가 전혀 없음**.
- `chat_unsubscribe_push` 도 동일하게 `p_endpoint` NULL/빈문자열 검사만 있고 형식 검증 없음.

## B. 적용 전 취약점 재현 (전부 BEGIN…ROLLBACK, 실측)

### 유효 토큰 확보
- `customer_chat_tokens` 를 `conversations` 와 `(customer_id, agent_id)` 조인해 조회한 결과: **`is_active=true` 이고 대응 conversation 이 있는 실제 유효 토큰이 1건 존재**.
  - `customer_chat_tokens` 총 1행, 전부 active, `conversations` 총 1행 — 프로덕션 데이터가 극히 적음(사실상 이 1건뿐).
  - 유효 토큰이 0건은 아니었으므로 **픽스처 생성(fallback)은 사용하지 않았음** (`fixture_used=False` 로 스크립트 출력에 명시됨).
  - 실제 토큰 값은 로그·보고서에 마스킹(`a8c40e...`)만 남김. conversation_id 는 UUID 라 노출 위험이 낮아 그대로 기록: `62910d97-84e0-4438-8a31-284cc71f4690`.

### 재현 스크립트
- 저장 위치: `/home/jay/projects/InsuRo/.worktrees/task-3000-dev3/tests/task-3000-push-endpoint-probe.py`
- `--mode before` / `--mode after` 로 기대값 전환 가능. 유효 토큰이 없을 경우 트랜잭션 내 임시 픽스처를 만들고 ROLLBACK 으로 제거하는 fallback 경로도 구현되어 있음(이번 실행에서는 미사용).
- 비밀번호는 `/home/jay/projects/InsuRo/.env` 의 `SUPABASE_DB_PASSWORD` 를 코드 내부에서만 읽고 어디에도 출력하지 않음.

### 실행 결과 (`--mode before`, 실측 그대로)

- 케이스 1 — `p_endpoint='javascript:alert(1)'`: `chat_register_push_subscription` → **`true` 반환, 행 생성됨** (호출 후 해당 conversation 의 행 수 1). **[재현 확인]**
- 케이스 2 — `p_endpoint='http://evil.test/x'`: → **`true` 반환 (수용됨)**. **[재현 확인]**
- 케이스 3 — `p_endpoint='not-a-url'`: → **`true` 반환 (수용됨)**. **[재현 확인]**
- 케이스 4 — `p_endpoint='https://fcm.googleapis.com@evil.test/x'` (userinfo 우회): → **`true` 반환 (수용됨)**. **[재현 확인]**
- 케이스 5 — 상한 없음 실측: 동일 유효 토큰으로 서로 다른 endpoint 12회 연속 호출 → **12/12 전부 성공(`true`)**, 해당 conversation 의 `push_subscriptions` 행 수가 케이스1~4의 4건 + 케이스5의 12건 = **16행까지 증가**. 거부되거나 상한에 걸린 호출 0건. **[재현 확인 — 상한 없음]**
- 케이스 6 — `chat_unsubscribe_push(token, 'javascript:alert(2)')`: → **`true` 반환 (수용됨)**. 이 함수도 endpoint 형식 검증이 전혀 없음을 확인. **[재현 확인]**

전체 스크립트 SUMMARY: `mode=before overall=PASS fixture_used=False` (6개 케이스 모두 "취약하다"는 기대와 일치 → PASS로 표시됨. 여기서 PASS 는 "취약점이 실제로 재현됨"을 의미하며 정상 상태를 뜻하지 않음에 유의)

### 트랜잭션 안전성 확인
- ROLLBACK 실행 후 `push_subscriptions` 프로덕션 행 수를 별도 세션에서 재조회 → **0건** (스크립트 실행 전후 동일, 어떤 행도 남지 않음).
- worktree `git status --short` 확인 결과 신규 생성 파일 2개(`_backup/task-3000_rpc_before.sql`, `tests/task-3000-push-endpoint-probe.py`)만 존재, 그 외 워킹트리 변경 없음.

## 핵심 발견 요약

1. `chat_register_push_subscription` / `chat_unsubscribe_push` 두 RPC 모두 `p_endpoint` 에 대해 **NULL/빈문자열 검사 외 어떤 형식 검증도 없음** — `javascript:`, 평문 `http://`, 임의 문자열, userinfo 우회 URL 전부 수용되어 DB 에 그대로 저장/매칭됨.
2. **호출 횟수 및 distinct endpoint 개수에 대한 상한이 전혀 없음** — 유효 토큰 1개로 12회 연속 호출해 전부 성공, 행이 무제한 누적됨(실측 16행까지 증가, 중단 없음).
3. `anon` 롤이 두 RPC 모두에 대해 EXECUTE 권한을 보유 — 익명 클라이언트가 직접 호출 가능한 상태.
4. `push_subscriptions` 의 anon 정책 3건(INSERT/SELECT/UPDATE)이 전부 생존해 있으며, `subscriber_type='customer'` 자기선언 값만으로 통과 — task-2996 caller-binding 강화가 이 테이블에는 적용되지 않은 상태.
5. 프로덕션 `push_subscriptions` 현재 행 수는 0건 — 실사용 데이터는 없으나, RPC 자체의 검증 부재는 그대로 존재하므로 첫 실사용부터 즉시 악용 가능한 상태.
6. 유효 토큰은 정확히 1건 존재해 픽스처 없이 실측 가능했음 (0건이었다면 중대 발견이었을 것이나, 해당 없음).
