# task-3000 — H2: 푸시 RPC 초크포인트 (엔드포인트 검증 + 구독 상한)

**레벨**: Lv.2 (보안) · **팀**: dev3-team (다그다) · **level=security** · **회장 승인 완료** (2026-08-22)

## 배경 — task-2999 가 남긴 마지막 관문
t2999(PR #246, main=`96ad2b22`)로 1단계 DDL(RPC 생성)이 **프로덕션에 적용됐다.**
3단계(anon 정책 3건 DROP)를 하면 이 RPC 가 **익명 쓰기의 유일한 관문**이 된다.
따라서 **초크포인트를 세울 마지막 기회가 지금**이다.

**ANU 프로덕션 실측 (2026-08-22 16:5x KST)**
```
RPC 2종 적용 확인:
  chat_register_push_subscription(p_token, p_endpoint, p_p256dh, p_auth)
  chat_unsubscribe_push(p_token, p_endpoint)
p_endpoint 검증 = 본문 10행 "IS NULL OR btrim = ''" 뿐.  스킴·호스트 검증 없음 ← H2
anon 정책 3건 잔존(INSERT/SELECT/UPDATE) = 3단계 미실행
push_subscriptions 행 수 = 0
```

**H2 실증(t2999 검토 시 ANU 자작 프로브)**: `javascript:` 및 비-URL 문자열이 엔드포인트로 **수용**되고,
단일 토큰으로 **55행까지 상한 없이** 생성됐다.

**왜 위험한가**: 서버(`send-push`)는 이 endpoint 로 **VAPID 서명 헤더를 붙여** POST 한다.
지금은 `send-push` 가 body 를 안 보내는 별개 버그(M7)로 기능이 죽어 있지만,
**M7 을 고치는 순간 상담 미리보기가 공격자 서버로 평문 전송된다.**
그래서 순서가 **H2 먼저**다. 이 순서를 바꾸지 마라.

---

## 고칠 것

### ① `p_endpoint` 오리진 검증
`chat_register_push_subscription` 과 `chat_unsubscribe_push` **양쪽**에 넣는다.

요구:
- `https://` 스킴만 허용 (`http:`·`javascript:`·`data:`·상대경로·비URL 전부 거부)
- **호스트 화이트리스트**. 최소한 아래 계열을 허용해야 실사용자가 안 깨진다:
  ```
  fcm.googleapis.com                      (Chrome/Edge)
  updates.push.services.mozilla.com       (Firefox)
  web.push.apple.com                      (Safari)
  *.notify.windows.com                    (구형 Edge/Windows)
  ```
  ★ **이 목록을 그대로 믿지 말고 실측으로 확인하라.** 브라우저 벤더가 호스트를 바꾼다.
    `send-push` 서버 코드와 웹푸시 표준을 확인해 근거를 보고서에 남겨라.
  ★ 목록에 없는 정상 호스트를 막으면 **그 브라우저 사용자는 알림을 영영 못 받는다.**
    확신이 없으면 좁히지 말고 **ANU 에 보고**하라. 과도한 차단이 과소 차단보다 안전하다는 판단을
    임의로 하지 마라 — 이건 사용자 기능이다.
- 거부 시 `false` 반환 (예외 던지지 말 것. 기존 계약이 boolean 이다)

### ② 대화당 구독 수 상한
같은 `conversation_id` 에 대해 활성 구독이 상한을 넘으면 신규 INSERT 를 거부한다.
- 상한은 **5**로 시작한다(폰·PC·태블릿 등 다기기 사용을 감안한 값).
- 기존 행 갱신(같은 endpoint 재등록)은 상한과 무관하게 허용해야 한다.
  안 그러면 재접속마다 실패한다.
- 상한 초과 시 `false` 반환.

### ③ 롤백 SQL 쌍
적용 SQL 과 롤백 SQL 을 쌍으로 만든다. 롤백은 **직전 버전 RPC 로 되돌리는 것**이지
anon 정책을 되살리는 것이 아니다.
★ 주의: `20260822T000002_..._ROLLBACK.sql` 은 anon 정책 3건을 `CREATE POLICY` 로 되살리는
'장전된 총'이다(t2996 산출물). **그 파일을 실행하지도, 흉내내지도 마라.**

---

## ★ 적용 범위 — 봇이 어디까지 하나
- SQL 작성 + **프로덕션 적용까지 봇이 수행한다.** `CREATE OR REPLACE FUNCTION` 이므로 무중단이고,
  현재 행 수 0·기능 미동작(M7)이라 실사용 영향이 없다.
- 적용 전 `pg_proc` 에서 **현재 두 RPC 의 본문 전문을 덤프**해 백업 파일로 남겨라.
- **anon 정책 3건 DROP(3단계)은 하지 마라.** 그건 ANU 가 프론트 배포 뒤에 한다.

접속: `psycopg2` + `aws-1-ap-northeast-2.pooler.supabase.com:5432` (session mode),
user `postgres.zayhfjuwviporbzokudr`, 비밀번호는 `/home/jay/projects/InsuRo/.env` 의 `SUPABASE_DB_PASSWORD`.
**비밀번호를 로그·보고서·커밋에 절대 남기지 마라.**

## 검증 (전부 실측)
1. **적용 전 재현** — `javascript:alert(1)` · `http://evil.test/x` · `not-a-url` 을 엔드포인트로
   RPC 호출해 **현재는 수용됨**을 보여라. 단일 토큰 다회 호출로 상한 없음도 재현하라.
   ★ 트랜잭션 + ROLLBACK 으로 하라. 프로덕션에 행을 남기지 마라.
2. **적용 후 차단** — 같은 프로브가 전부 `false` 인지 확인.
3. **정상 경로 무손상 (최우선 회귀)** — 화이트리스트에 있는 실제 형태의 엔드포인트
   (예: `https://fcm.googleapis.com/fcm/send/…`)로 유효 토큰 호출 시 **여전히 true** 인지 확인.
   이게 깨지면 고객 알림이 죽는다.
4. **재등록 무손상** — 같은 endpoint 를 상한 초과 상태에서 다시 등록해도 **갱신은 성공**하는지 확인.
5. **상한 동작** — 서로 다른 endpoint 6개째에서 `false` 인지 확인.
6. **봉인 증명** — 검증 조건을 원복시키는 **변이**를 만들어 테스트가 실제로 FAIL 하는지 확인하고 복원하라.
   "N passed" 재현만으로는 이빨을 증명하지 못한다.
7. `npm run build` EXIT=0 · `npx vitest run` 회귀 유지(**base 재측정 기준선**. 명세 수치 아님).

## 범위 · 금지
**수정 허용**: `supabase/migrations/**`(신규 SQL 쌍) · 관련 테스트

**금지**:
- **anon 정책 3건 DROP** — ANU 담당(3단계)
- **`send-push` 수정** — M7 은 H2 적용 후 별도 태스크. 순서를 앞당기지 마라
- `src/lib/push-utils.ts` — t2999 결과물, main 에 이미 있다
- `customer_chat_tokens` · `conversation_messages` 정책(t2991 결과물)
- `src/components/navigation/**` — t2998 결과물, 배포됨
- `server/**` · `ops/**` · `.github/**`

## allowed_resources

```yaml
allowed_resources:
  paths:
    - "supabase/migrations/**"
    - "tests/**"
    - "e2e/**"
    - "src/lib/__tests__/**"
    - "memory/reports/task-3000.md"
  forbidden_paths:
    - "src/lib/push-utils.ts"
    - "src/components/navigation/**"
    - "server/**"
    - "ops/**"
    - "scripts/**"
    - ".github/**"
  commands:
    - "npm run build"
    - "npx vitest run"
    - "python3"
  merge_policy: "tiered"
  ttl_hours: 48
```

## 운영 계약
- `origin/main`(=`96ad2b22`) 기준 `git pull --ff-only` 후 시작
- ★ `gh` 호출 시 `GH_TOKEN="$BOT_GITHUB_TOKEN"` 주입 필수 (회장 개인 PAT 금지 — 감사기록 오염)
- 워크플로우 `/home/jay/workspace/prompts/DIRECT-WORKFLOW.md` · QC `/home/jay/workspace/teams/shared/QC-RULES.md`
- `WORKSPACE_ROOT=/home/jay/workspace` · `CHAT_ID=6937032012` · 수집자 key `ANU_KEY=c119085addb0f8b7`
- 완료 경로는 `finish-task.sh` 실행이 유일하다. 수동 `.done` 금지.
- **명세와 실측이 다르면 임의 판단하지 말고 ANU 에 보고하라.**

## 완료 조건
양쪽 RPC 오리진 검증 · 대화당 상한 5 · 롤백 쌍 · 프로덕션 적용 + 본문 백업 ·
적용 전후 프로브 대조 · 정상경로/재등록 무손상 실증 · 변이로 봉인 실증 · 빌드 0 · 회귀 유지

## 보고
**★★★ PR 생성까지가 범위다. 머지는 ANU 가 한다. 절대 직접 머지하지 마라.**
`memory/reports/task-3000.md` 작성 후 표준 완료 콜백 등록. 콜백 프롬프트 **UTF-8 3900 bytes 이하**.

## goal_assertions (auto-generated)
- `npm run build`
- `npx vitest run`
