# 작업 보고: task-3000 — H2 푸시 RPC 초크포인트 (엔드포인트 오리진 검증 + 구독 상한)

- 팀: dev3-team (다그다 / Dagda)
- 레벨: security (dispatch) · task md 선언은 Lv.2 — **어긋남, 아래 §게이트 참조**
- base: `96ad2b2` · 브랜치: `task/task-3000-dev3` · head: `deb4273`
- 프로덕션 DDL **적용 완료** · PR 생성까지가 범위 (**머지 금지 — ANU 소관**)

---

## S — 상황

t2999(PR #246, main=`96ad2b2`)로 1단계 DDL(RPC 2종)이 프로덕션에 적용됐다.
그러나 `chat_register_push_subscription` / `chat_unsubscribe_push` 의 `p_endpoint` 검증은
`IS NULL OR btrim = ''` 단 한 줄뿐이고, 스킴·호스트 검증도 구독 수 상한도 없었다.

서버(`send-push`)는 이 endpoint 로 **VAPID 서명 헤더를 붙여 POST** 한다. 지금은 별개 버그(M7,
body 미전송)로 기능이 죽어 있으나 **M7 을 고치는 순간 상담 미리보기가 공격자 서버로 평문 전송**된다.
3단계(anon 정책 3건 DROP) 이후 이 RPC 가 익명 쓰기의 유일한 관문이 되므로 지금이 초크포인트를
세울 마지막 기회다.

## C — 복잡성

최대 리스크가 보안이 아니라 **가용성**이었다. 화이트리스트에 없는 정상 호스트를 막으면
그 브라우저 사용자는 알림을 영영 못 받는다. 게다가 프로덕션 `push_subscriptions` 행 수가 **0**이라
실제 endpoint 표본으로 사전 검증할 방법이 없었다 — 배포 후 첫 실사용 트래픽이 곧 검증이 된다.

## Q — 질문

어떤 호스트를 허용해야 정상 사용자를 깨뜨리지 않으면서 임의 URL 로의 VAPID 서명 POST 를 막는가?
그리고 그 판단을 추측이 아니라 무엇으로 근거화하는가?

## A — 답변 (수행 내용)

### 1. 화이트리스트 근거화 — 명세 목록이 **좁았다**
task md 는 4개 호스트를 예시하며 "이 목록을 그대로 믿지 말고 실측으로 확인하라"고 지시했다.
아네(UX/UI)의 조사 결과 명세 목록으로는 **정상 사용자가 깨진다**:

- **Apple**: WebKit 공식 블로그가 "allow URLs from any subdomain of push.apple.com" 권고.
  `web.push.apple.com` 하드코딩은 Apple 이 리전별 서브도메인을 도입하면 깨진다.
- **Microsoft**: Microsoft Learn 이 "채널 URI 서브도메인은 가변적이므로 검증에 쓰지 말라"고 명시.
  실제 형태는 `wns2-sg2p.notify.windows.com` 처럼 가변 서브도메인.
- 최상위 교차 출처: Pushpad(구독 2억 건 운영) 관리 화이트리스트, 최신 커밋 2026-06-01.

확정 목록(총 8규칙):
- 정확 일치 5 — `fcm.googleapis.com` · `android.googleapis.com` · `jmt17.google.com`
  · `updates-autopush.stage.mozaws.net` · `updates-autopush.dev.mozaws.net`
- 접미사(apex + 모든 서브도메인) 3 — `push.apple.com` · `notify.windows.com` · `push.services.mozilla.com`

### 2. 구현 — 공용 헬퍼 `public.is_allowed_push_endpoint(text)`
두 RPC 가 **같은 함수**를 부르게 해 로직 드리프트를 원천 차단했다.

- **길이 상한 2048자** (NULL 검사 직후) — boolean 계약 보호. 아래 §로키 참조
- **구조 정규식** `^https://[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?/[!-~]*$`
  호스트 문자셋이 `@`·`:`·`\`·공백·비ASCII 를 배제 → userinfo·포트·IDN 호모그래프·IPv6 리터럴이
  구조 단계에서 사망. 호스트 뒤 `/` 강제 → 쿼리 위장(`https://evil.test?x=fcm…`) 사망
- `lower()` 정규화 후 화이트리스트 판정
- 접미사 매칭에 **LIKE 미사용** — `right(v_host, length(s)+1) = '.'||s`
  → `push.apple.com.evil.test` 거부 / `web.push.apple.com` 허용
- 거부 시 `RAISE LOG … host=%` — **호스트만** 기록(전체 endpoint 는 푸시 토큰이 든 비밀값)

`chat_register_push_subscription` 추가분:
- `PERFORM pg_advisory_xact_lock(hashtextextended(v_conversation_id::text,0))` — TOCTOU 경합 차단
- 대화당 활성 구독 상한 **5**, ★ 검사를 `IF v_updated = 0` 블록 **안**에 배치 →
  기존 endpoint 재등록(UPDATE 경로)은 상한과 무관하게 허용

### 3. 롤백 쌍
`20260822T020000_..._ROLLBACK.sql` — 두 RPC 를 백업 원문으로 복원 후 헬퍼 DROP(순서 중요).
`CREATE POLICY` / `DROP POLICY` **0건**. anon 정책을 되살리지 않는다.

---

## 수정 파일별 검증 상태

| 파일 | 변경 | 검증 방법 | 상태 |
|---|---|---|---|
| /home/jay/projects/InsuRo/.worktrees/task-3000-dev3/supabase/migrations/20260822T020001_task3000_push_endpoint_chokepoint.sql | 신규 343줄 | 프로덕션 3회 적용(멱등) + anon 역할 46/46 라이브 PASS + 변이 4종 검출 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-3000-dev3/supabase/migrations/20260822T020000_task3000_push_endpoint_chokepoint_ROLLBACK.sql | 신규 207줄 | 트랜잭션 실행 후 두 RPC sha256 이 적용 전 원본과 바이트 동일, 정책 190→190 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-3000-dev3/supabase/migrations/_backup/task-3000_rpc_before.sql | 신규 149줄 | pg_get_functiondef 덤프, sha256 2종 기록 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-3000-dev3/tests/task-3000-push-endpoint-probe.py | 신규 1262줄 | 46케이스 + 변이 하네스 4종, pyright 0 경고 | verified |

---

## 검증 결과 (명세 요구 7종 — 전부 실측)

### 1. 적용 전 재현 (BEGIN…ROLLBACK)
- `javascript:alert(1)` · `http://evil.test/x` · `not-a-url` · `https://fcm.googleapis.com@evil.test/x`
  → **전부 `true` 반환 + 행 생성**
- 단일 유효 토큰으로 12회 연속 호출 → **12/12 성공**, 행이 16까지 증식(거부 0건)
- `chat_unsubscribe_push` 도 `javascript:` 수용
- 롤백 후 별도 세션 재조회 → 행 0건 (프로덕션 무변경)

### 2. 적용 후 차단
`--mode after --role anon` 라이브 실행 **46/46 PASS**. Group A 차단 21종 전부 `false`
(userinfo · 접미사 위장 · 포트 · 경로없음 · 개행 · IDN · trailing dot · IPv6 · punycode · 길이 3종 포함).

### 3. 정상 경로 무손상 (★ 최우선 회귀)
Group B **12/12 `true`** — FCM · GCM 레거시 · Mozilla 접미사 · Apple 서브도메인 · Apple apex
· WNS 서브도메인 · 대문자 호스트 정규화 · jmt17 · mozaws stage/dev · 길이 경계 2048 정확 · WNS 900자.
로키가 실사실적 endpoint 9종 + 5기기 시나리오로 독립 재확인.

### 4. 재등록 무손상
상한 5 도달 상태에서 동일 endpoint 재등록 → `true` (갱신 경로는 상한 무관).

### 5. 상한 동작
서로 다른 endpoint 1~5번째 `true`, **6번째 `false`**. advisory lock 이 실제로 세션 간 블록함을
로키가 **2-커넥션으로 실증**(A 락 보유 중 B 는 2초 timeout, A rollback 즉시 B 가 0.006초에 획득).

### 6. 변이 봉인 (★ "N passed" 로는 이빨을 증명하지 못한다)
트랜잭션 안 SAVEPOINT 로 변이 4종을 심고 테스트가 실제 FAIL 하는지 확인 후 복원:
- 오리진 검증 킬스위치(`RETURN true`) → 검출
- 접미사 매칭을 LIKE 로 약화(앞점 제거) → 검출
- 상한 상수 999999 → 검출
- 상한 검사를 UPDATE 경로로 이동 → 검출
4종 전부 `caught=True` · `sha256_restored=True`. 마아트가 1종을 독립 재실행해 12/15 FAIL 재현.

★ **추가 차등 증거**: 길이 상한 적용 **전** 라이브에서 동일 스위트가 **43/46**(정확히 길이 3건만 FAIL),
적용 **후** **46/46**. 같은 스위트가 패치 유무로 갈린 것이 봉인의 직접 증거다.

### 7. 빌드 · 회귀
최종 head `deb4273` 에서 재측정: `npx vitest run` **93 files / 1349 tests / 0 failed**,
`npm run build` **EXIT=0**. base(`96ad2b2`) 측정치와 동일 — SQL 만 바꿨으므로 정상.
★ `vitest.config.ts` 가 `tests/e2e/**` 를 제외한다 → **vitest 전량 PASS ≠ 회귀 없음**. E2E 는 CI 소관.

---

## L1 스모크테스트

- **서버 재시작**: 해당없음 (DB 함수 변경. 애플리케이션 서버 무관)
- **API 응답 확인**: ★ 실제 프로덕션 DB 에 `SET LOCAL ROLE anon` 으로 전환해 라이브 RPC 직접 호출.
  정상 4종(FCM/Apple 서브도메인/WNS 서브도메인/Mozilla 접미사) → `true`,
  차단 4종(`javascript:`/`http://`/userinfo/접미사 위장) → `false`,
  헬퍼 직접 호출 → `InsufficientPrivilege`. 전량 BEGIN…ROLLBACK.
  전체 스위트 46/46 PASS(anon), 45/45 PASS(postgres 대조군, G01 은 SKIP).
- **스크린샷**: 해당없음 (UI 변경 0줄)

---

## 프로덕션 적용 실측 (적용 전 → 후)

- `push_subscriptions` 행 수: 0 → **0**
- 전역 `pg_policies`: 190 → **190** (정책 단 한 건도 변경 없음)
- `push_subscriptions` anon 정책: 3건 → **3건 생존** (3단계 미실행, 명세 준수)
- `chat_register_push_subscription` sha256: `e89444e1…` → `5ebffa2b…`
- `chat_unsubscribe_push` sha256: `c522e51f…` → `76e67f66…`
- `is_allowed_push_endpoint`: 부재 → `78b4f328…`, acl `{postgres=X/postgres}`
- 적용 전 본문 전문 백업: `supabase/migrations/_backup/task-3000_rpc_before.sql`

---

## 작업 중 잡은 결함 3건

### D1 — `IMMUTABLE` + `RAISE LOG` 계약 충돌 (팀장 정정)
루가 미확신 사항으로 escalate. 부수효과 있는 함수를 `IMMUTABLE` 로 표기하면 플래너가 결과를
접을 때 **관측 로그가 유실**된다. `VOLATILE` 로 정정.

### D2 — ★ 헬퍼 ACL 이 설계와 달랐음 (적용 후 실측으로 발견)
파일은 `REVOKE ALL … FROM PUBLIC` 만 했는데 적용 후 `proacl` 이
`{postgres=X,anon=X,authenticated=X,service_role=X}` 였다. 원인은 Supabase 가 public 스키마에
`ALTER DEFAULT PRIVILEGES` 로 신규 함수에 **role 별 EXECUTE 를 자동 부여**하기 때문이며
`FROM PUBLIC` 은 그 role GRANT 를 제거하지 못한다. 토큰 없이 화이트리스트를 탐침할 오라클이
열려 있었다. role 별 REVOKE 3줄을 추가해 재적용 → 현재 `{postgres=X/postgres}`.
★ 회수 후 정상 경로가 깨지지 않는지 anon 역할로 재검증 완료(8/8 정확).

### D3 — 초장문 endpoint 에서 boolean 계약 파괴 (로키 레드팀)
`ProgramLimitExceeded` 예외가 boolean 대신 튀어나옴(btree 인덱스 한계). 명세 계약
"거부 시 false 반환, 예외 던지지 말 것" 위반. 길이 상한 **2048자**로 봉인.
★ 로키의 재현 임계값 229,895자는 **반복 문자열이라 TOAST 압축이 먹은** 경우이며,
압축 안 되는 endpoint 는 btree 한계(~2704바이트) 근처에서 훨씬 일찍 터진다. 그래서 상한을
그 아래로 잡았다. 실사용 최대 관측치는 WNS 계열 약 900자로 2배 이상 여유가 있다.

---

## 검증 공백을 메운 것

팀 프로브가 전부 `postgres`(소유자)로 실행되고 있어 **실제 호출자인 `anon` 으로는 한 번도
검증되지 않았다.** SECURITY DEFINER 안에서 INVOKER 헬퍼를 호출하는 권한 경로는 소유자로 돌리면
검증되지 않는다. 팀장이 이를 발견해 `SET LOCAL ROLE anon` 실호출로 확인했고, 스위트에도
`--role {postgres,anon}` 플래그를 추가해 **기본값을 anon** 으로 바꿨다.

---

## 게이트 결과

- **G1 Codex 사전 검증**: **FAIL** — ★ 자가해소하지 않음. 근거: `reports/task-3000-codex-gate-evidence.md`
  `codex_gate_check.py` 는 구현 diff 를 **읽지 않고** `--task-file` 로 준 명세 md 와 affected_files
  존재 여부만 Codex 에 넘긴다. 따라서 지적 4건은 전부 **명세(ANU 작성)의 서술 공백**이며,
  PASS 로 바꾸려면 `memory/tasks/task-3000.md` 를 고쳐야 하는데 그건 수정 허용 경로가 아니다.
  지적 내용 자체는 전부 실구현에 대조했다 — 상한 경합은 이미 advisory lock 으로 구현돼 있었고,
  URL 엣지케이스 7종은 전부 이미 차단되며 검증 스위트에 명시 포함했다.
- **G1 Agent 미팅 (Lv.4)**: **미수행** — task md 는 `Lv.2` 선언, dispatch 는 `security`→Lv.4 지시로
  어긋난다(t2999 와 동일한 불일치). Codex 사전검증 + 로키 레드팀 + 마아트 독립검증으로
  대체했음을 고지하고 ANU 판정에 넘긴다.
- **G2 마아트 독립 검증**: **CONDITIONAL** — 재측정 수치 전부 일치, 명세 위반 0건.
  조건은 "아직 완료 프로토콜(보고서/PR/timer end) 미통과"였으며 본 보고서 작성으로 해소된다.
- **G2 로키 레드팀**: **PASS(조건부)** — 침투 시도 대부분 차단. 상세 아래.
- **sanitize**: 3파일 플래그됐으나 **전부 오탐**(파일명이 API키 패턴에, 공격 벡터
  `…@evil.test` 가 이메일 패턴에 매치). net-new PII **0**.

---

## ANU 판단 요청 4건

### R1 — ★ 이 초크포인트는 아직 실효가 없다 (Critical, 본 태스크 소관 밖)
로키가 **RPC 를 전혀 거치지 않고** 토큰 없이 임의 endpoint 를 `push_subscriptions` 에 직접
INSERT 하는 데 성공했다(rollback 처리). anon INSERT/SELECT/UPDATE 정책 3건이 살아있기 때문이다.
즉 **"이 RPC 가 익명 쓰기의 유일한 관문"은 아직 사실이 아니며, 3단계 집행 전까지 본 변경의
실질 보호 효과는 0에 가깝다.** 3단계 신속 집행 + **기한 명문화**가 필요하다.
(기존 M1 의 재확인이며 Codex C1 과 같은 지점.)

### R2 — 미확인 브라우저
Vivaldi · 중국계 브라우저 · Brave("Google 서비스 사용" 토글 비활성) · Firefox for Android 가
별도 호스트를 쓰는지 **미확인**. 해당 사용자는 등록이 실패한다.
★ 다만 브리짓 실측상 `PushNotificationToggle.tsx:84-96` 이 반환 boolean 을 검사해 `toast.error`
로 **화면에 노출**하므로 조용한 실패는 아니다. 또한 거부 시 `RAISE LOG … host=%` 로 서버 로그에
거부된 호스트가 남으므로 사후 원인 파악이 가능하다. 미확인 정상 호스트가 관측되면
**차단 유지가 아니라 목록 갱신**이 정답이다.

### R3 — 비활성 행 무한 증식 (Medium)
상한은 `is_active=true` 만 센다(명세 지시). 등록→해지→등록 순환으로 비활성 행 누적이 가능하다.
다만 브리짓 실측상 `chat_unsubscribe_push` **프로덕션 호출자 0건**이라 UI 경로로는 도달 불가.
상한을 전체 행으로 바꾸면 정상 다기기 사용자가 6번째 기기를 영영 등록 못 하므로 바꾸지 않았다.

### R4 — 자격증명 노출 (정직 고지)
모리건이 작업 중 `head -c 30` 명령으로 DB 비밀번호 일부를 **자기 서브에이전트 도구 출력에**
노출시켰다고 자진 신고했다. 팀장이 산출물을 전수 스캔한 결과:
**추적 파일 899개 · 커밋 diff 전체(`96ad2b2..deb4273`) · task-3000 보고서 전부 · 3문서 —
비밀번호 및 앞 8/12자 조각 매치 0건.** 노출은 `/tmp` 의 서브에이전트 트랜스크립트에 국한된다.
커밋·보고서 유출은 없으나 **로테이션 여부는 ANU 판단**을 요청한다.

---

## trip-wire 5종 (실측)

- Critical7 = **0** (net-new 기준. R1 의 Critical 은 본 변경이 만든 것이 아니라 기존 조건)
- PII net-new = **0** (sanitize 플래그 3건 전부 오탐 확인 + 비밀번호 전수 스캔 0건)
- 회귀 실패 = **0** (head `deb4273`: 93 files / 1349 tests / 0 failed, build EXIT=0)
- forbidden_paths 침범 = **0** (변경 4파일 전부 `supabase/migrations/**` · `tests/**`)
- nonce = **task-3000**

---

## 모델 사용 기록

- 팀장 다그다: Opus (설계 확정 · 검토 · 통합 · 프로덕션 적용 · 독립 실측). 직접 코딩 없음
  — 예외 1건: `IMMUTABLE`→`VOLATILE` 라벨 정정은 루가 escalate 한 판단 요청이라 통합 단계에서 팀장이 반영
- 루 (Lugh, 백엔드): sonnet — SQL 마이그레이션 쌍 · ACL 정정 · 길이 상한
- 모리건 (Morrigan, QA): sonnet — 백업 덤프 · 재현 프로브 · 46케이스 스위트 · 변이 하네스
- 아네 (Aine, UX/UI): sonnet — 화이트리스트 근거 조사 (분석 작업이므로 haiku 미사용)
- 브리짓 (Brigid, 프론트): sonnet — 호출부 전수 · false 처리 분석 · 기준선 측정
- 로키 (Loki, 레드팀): sonnet — 적대적 침투 감사
- 마아트 (Maat, 독립검증): sonnet — 전 주장 재측정
- haiku 사용 **0건** (전부 보안·분석 작업이라 부적합)

## GLM 코드 품질
GLM 미사용 (SQL 마이그레이션 및 DB 검증 작업).

## 비고
- 프로덕션 DDL 적용은 task md 가 명시적으로 승인한 범위("SQL 작성 + 프로덕션 적용까지 봇이 수행한다").
- **PR 생성까지가 범위이며 머지는 ANU 소관.** 직접 머지하지 않았다.
- `20260822T000002_..._ROLLBACK.sql`('장전된 총', 실제로 `CREATE POLICY` 3건 포함됨을 마아트가 확인)
  은 실행하지도 모방하지도 않았다.

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

