# task-2996 — InsuRo 접근제어 결함 3건 (A-33 / A-40 / A-39)

- **팀**: dev4-team (비슈누) · **레벨**: Lv.2 (level=security) · **일자**: 2026-08-22
- **워크트리**: `/home/jay/projects/InsuRo/.worktrees/task-2996-dev4` (브랜치 `task/task-2996-dev4`)
- **base**: `a17bd1983126549d6c24d5dff29026831a0cf14c` · **head**: `eaa5d08f1b8fd45871b897573d182ccd5a05a5bb`

---

## S — 상황
task-2994 UI 전수점검에서 범위 밖으로 발견된 접근제어 결함 3건(A-33 관리자 라우트 가드 부재 ·
A-40 `ai_config` 전체 인증사용자 SELECT 개방 · A-39 `push_subscriptions` anon 소유권 미검사)을
구조적으로 봉인하는 작업이다.

## C — 복잡성
- A-33 은 **현재 실피해 0** (13개 페이지가 각자 자체 체크 보유). 구조를 바꾸되 이중 방어를 깨면 안 된다.
- A-40/A-39 는 **DDL 이라 ANU 승인 전 적용 금지**. 그런데 명세는 "적용 후 차단 실증"을 요구한다.
- A-39 를 RPC 로 좁히면 **프론트 `src/lib/push-utils.ts` 수정이 필수인데 그 파일은 allowed_resources 밖**이다.

## Q — 질문
승인 없이 프로덕션을 바꾸지 않으면서, 수정이 실제로 취약점을 막는다는 것을 어떻게 증명하는가?

## A — 답변
**트랜잭션 내 적용 → 차단 실증 → 롤백** 으로 프로덕션을 변경하지 않고 차단을 실증했다.
A-33 은 코드로 완결했고, A-40/A-39 는 SQL 쌍 작성 + 실증까지 완료하되 **적용은 ANU 승인 대기**로 남겼다.

---

## 1. 명세 ↔ 실측 대조 (★ ANU 판단 필요 항목 포함)

| 항목 | 명세 | 실측 | 판정 |
|---|---|---|---|
| `adminOnly: true` 라우트 | 13건 | 13건 (+menuSections 1) | 일치 |
| `App.tsx` adminOnly 참조 | 0건 | 0건 | 일치 |
| 13개 페이지 자체 체크 누락 | 0건 | 0건 | 일치 |
| **ContextSubMenu 경로** | `src/components/ContextSubMenu.tsx` | **`src/components/navigation/ContextSubMenu.tsx`** | ★ **명세 오기** |
| ContextSubMenu 분기 | `pathname.startsWith("/admin")` | `activeTab === "admin"` | 서술 차이(의미 동일) |
| `ai_config` api_key | 빈값(길이 0) | **NULL** | 결론 동일(유출 0건) |
| **`push_subscriptions` anon 정책** | **2건** | **3건** (INSERT/UPDATE/**SELECT**) | ★ **명세 누락** |
| `auth.uid()` 정책 | 106건 | **155건** | 정규식 민감 — 근거로 미사용 |
| `pg_policies` 전체 | — | **191건** | 무회귀 기준 |

★ 2건은 **자가 판단하지 않고 ANU 에 보고**한다(명세 지시).
- **명세 누락분 `Anon can read push subs`(SELECT)** 는 타인 구독의 `endpoint`/`p256dh`/`auth` 키가
  anon 에게 열려 있던 경로다. 같은 테이블·같은 결함군이라 3건 모두 처리했다(범위 이탈 아님).

## 2. 취약점 재현 (적용 전 기준선)

프로덕션, **트랜잭션 롤백**으로 수행. 적용 후 잔여행 0 확인.

- **A-40**: `SET LOCAL ROLE authenticated` + 임의 sub 클레임 → `ai_config` **1행 노출**.
- **A-39**: anon 롤로 **실제 타인 conversation_id** 에 INSERT → **성공**(행 생성 확인).
- ★ **함정 회피 2건**
  - anon REST 프로브의 `23503`/HTTP 409 는 **RLS 가 아니라 FK 가 우연히 막은 것**이다(task-2991 동일 유형).
  - `push_subscriptions` SELECT 가 `[]` 인 것은 차단이 아니라 **행 수가 0** 이기 때문이다.
  - 두 경우 모두 응답코드가 아닌 **DB 직접 확인**으로 판정했다.

## 3. 적용 후 차단 실증 (트랜잭션 내 적용 → 검증 → 롤백)

| 프로브 | 적용 전 | 적용 후 | 판정 |
|---|---|---|---|
| 비관리자 authenticated → `ai_config` SELECT | 1행 | **0행** | 차단 |
| `system_admin` → `ai_config` SELECT/UPDATE | 1행 | **1행 / 1행** | 과잉차단 없음 |
| anon → `push_subscriptions` INSERT(타인 대화) | 성공 | **42501 RLS 거부** | 차단 |
| anon → 직접 SELECT/UPDATE/DELETE | 개방 | **0행** | 차단 |
| RPC(무효 토큰) | — | **false**, 기록 0행 | 정보 누출 없음 |
| 토큰 보유 고객이 타인 대화 지정 | — | **불가**(인자에 conversation_id 없음) | 차단 |

**롤백 무결성**: 롤백 후 `pg_policies` 스냅샷이 베이스라인과 **완전 일치**, 신규 RPC 0건, 인덱스 0건.
**프로덕션 최종 상태**: 정책 **191건 그대로**, 신규 RPC **0건** → **미적용 확인**(설계대로).

## 4. 소비자 전수조사 (명세 "무작정 잠그지 말 것" 대응)

- **`ai_config`**: `AdminAIConfig.tsx`(관리자 세션 → `cmd=ALL` 정책으로 계속 통과) +
  `generate-content/index.ts:322`(**service_role → RLS 우회**). 참조 뷰 0건.
  → **깨지는 정상 경로 없음. 뷰/컬럼 분리 불필요.** `cmd=ALL` 이 SELECT 를 포함함을 실측 확인.
- **`push_subscriptions`**: `src/lib/push-utils.ts`(anon 고객) · `PushNotificationToggle.tsx` ·
  `CrmMessenger.tsx`(설계사=authenticated, 정책 이미 올바름 → 무변경) · `send-push`(service_role).

## 5. A-33 구현

- `src/App.tsx` — 인라인 `AdminRouteGuard` + `buildRouteElement`. `adminOnly === true` 만 래핑,
  그 외 라우트는 무영향. `loading` 중 자식 미렌더(`PageLoader`) → **fail-closed**.
  (별도 파일 대신 인라인인 이유: `src/components/` 신규 파일은 allowed_resources 밖)
- `src/components/navigation/ContextSubMenu.tsx` — 관리자 분기에 `adminLoading || !isAdmin → return null`.
- **13개 페이지 자체 체크 무수정** — base blob 대비 **sha256 13/13 완전 동일**(이중 방어 보존).
- **불변식 봉인**(로키 Medium 대응): `/admin*` 라우트는 `adminOnly === true` 여야 한다는 불변식과
  "실제 `buildRouteElement` 결과가 가드로 감싸진다"를 테스트로 강제. 개수 하드코딩(`toBe(13)`) 제거.

## 6. 검증 결과

| 항목 | 결과 |
|---|---|
| 회귀 (base `a17bd198` 재측정) | **88 files / 1265 tests** |
| 회귀 (worktree head) | **90 files / 1295 tests, 실패 0** |
| `npm run build` | **EXIT=0** |
| `npx tsc --noEmit` | **EXIT=0** |
| 돌연변이 검증(마아트) | **4/4 killed** (가드 무력화 시 전부 FAIL) |
| 돌연변이 검증(불변식) | `/admin/notices` 플래그 제거 → **3건 FAIL, 에러에 경로 노출** |
| forbidden_paths | **0건** |
| CRM 무회귀 | DROP 대상 4건뿐. `Agents can ...` 3건 · `System admins can manage ai_config` 존속 확인 |

## L1 스모크테스트

- **서버 재시작**: 해당없음(SPA 정적 산출물). 대신 `dist` 를 **실제 HTTP 서버(127.0.0.1:8791, SPA fallback)로 기동**해 실브라우저 접속.
- **API 응답 확인**: `curl /` → HTTP 200 · `curl /admin/notices` → HTTP 200(SPA fallback).
- **스크린샷**: `memory/reports/assets/task-2996/t2996-02-admin-blocked-to-intro.png`
- **실브라우저 결과 (Playwright, 빌드 산출물 대상)**
  1. 익명 접속 `/admin/notices` → `/login` (이는 **AuthGuard** 이지 신규 가드가 아님 — 구분해 기록)
  2. **비관리자 인증 세션**(합성 세션 + 유효 consent 메타데이터 주입)으로 `/admin/notices` → **`/intro` 로 리다이렉트**
  3. `/admin/crm` → **`/intro` 로 리다이렉트** (2건째 확인)
  4. **대조군**: `/help-guide`(비관리자 라우트) → **리다이렉트 없음**(정상 렌더) → 과잉차단 없음
  5. 착지한 `/intro` 화면에 **관리자 메뉴 미노출**(사이드바·상단탭 모두)
- ★ **귀속 근거(confound 배제)**: 13개 페이지의 자체 체크는 **URL 을 유지한 채 "권한이 없습니다" 문구를 렌더**한다.
  **URL 이 `/intro` 로 바뀌는 것은 `AdminRouteGuard` 의 `<Navigate to="/intro" replace />` 뿐**이므로
  관측된 리다이렉트는 신규 가드에 유일하게 귀속된다.
- **빌드 산출물 실측**: 엔트리 청크에 `adminOnly===!0` **1건**(가드 분기) + `adminOnly:!0` 13건.
  base 에서는 `.adminOnly` 읽기가 **0건**이었다. (minified 1-line 함정 회피 위해 `grep -o | wc -l` 사용)

### L1 한계 (정직 고지)
- **비관리자 실계정 자격증명이 없어** 합성 세션을 주입해 검증했다. 실계정 E2E 는 미수행.
- 초기 스크린샷 1건(`t2996-01`)은 `about:blank` 상태에서 촬영돼 **순백 이미지(1색)** 였다. 증거가치가 없어 **삭제**했다.
  (마아트가 이를 독립 지적했고, 픽셀 검사로 확인 후 폐기)
- **DDL "적용 후 프로덕션 프로브"는 미수행** — 미적용 상태가 설계상 정상이기 때문이다.
  차단 실증은 **트랜잭션 내 적용 후 롤백** 방식으로 대체했다.

---

## ★ ANU 판단 필요 (자가해소 금지 — 4건)

### E1. [BLOCKING] scope-guard FAIL 1건 — 명세 경로 오기
```
VIOLATION: src/components/navigation/ContextSubMenu.tsx: paths 미포함 (scope 외 파일)
```
- `allowed_resources.paths` 는 `src/components/ContextSubMenu.tsx` 를 허용하나 **그 경로는 이 리포 역사상 존재한 적이 없다**(`git log --all` 0건). 실제 파일은 `navigation/` 하위다.
- **stale snapshot 아님**: task 파일 sha256 이 capability 스냅샷 `source_sha256` 과 **일치**.
  즉 task-2995 유형(정정 前 스냅샷)이 아니라 **명세가 처음부터 없는 경로를 적은 것**이다.
- → **ANU 가 `paths` 를 `src/components/navigation/ContextSubMenu.tsx` 로 정정 + capability 재스냅샷** 필요.
  나머지 8파일은 전부 allowed. 추가 위반 0건.

### E2. [HIGH] A-39 DDL 단독 적용 금지 — 범위 밖 프론트 수정이 필수
`src/lib/push-utils.ts:57–88, 110–116` 은 테이블에 직접 접근하면서 **`{ error }` 를 한 번도 검사하지 않고**
try/catch 만 걸어 `return true` 한다. supabase-js 는 RLS 거부를 **throw 가 아니라 `{error}` 로 반환**하므로
catch 에도 걸리지 않는다. 따라서 DDL 만 적용하면 화면에는 "알림이 활성화되었습니다" 토스트가 뜨고
DB 에는 0행이 남는 **조용한 실패(silent failure)** 가 되어 고객 푸시가 무음으로 사망한다.
- ★ **역순도 동일하게 죽는다**(프론트만 RPC 로 바꾸면 `PGRST202` 가 역시 삼켜짐)
  → **안전한 순차 배포 순서가 존재하지 않는다.** 동시 배포 또는 error-check 선행이 필요하다.
- 또한 `PushNotificationToggle` 은 토큰을 받지 않아 **토큰 prop 배선**이 필요한데 이 역시 범위 밖이다.
- → **ANU 결정 필요**: 범위 확장(`src/lib/push-utils.ts` + `PushNotificationToggle.tsx`) or A-39 분리 위임.
- 참고: **A-40 은 이런 결합이 없어 단독 적용이 안전**하다.

### E3. [MEDIUM] `Agents can update own push subs` 의 `with_check = NULL`
UPDATE 정책에 WITH CHECK 가 없어 **인증된 설계사가 자기 대화의 구독 행을 타인 `conversation_id` 로 이전** 가능.
anon 경로가 아닌 **인증 설계사 경로**의 별개 이슈이고 A-39(anon IDOR) 범위 밖이라 **미수정·박제만** 했다.

### E4. [MEDIUM] ROLLBACK 파일이 APPLY 보다 먼저 정렬됨
`…T000000_ROLLBACK` < `…T000001_apply`. 순서 실행형 러너가 붙거나 **접두 일부만 재생되는 환경**에서는
롤백이 먼저 돌아 취약 정책을 되살린다. 현재 `.github/workflows/*` 에 마이그레이션 실행 스텝 **0건**이고
task-2991 도 동일 관행이라 지금은 무해하나 구조적 지뢰다.

## 범위 밖 발견 (박제만 — 수정하지 않음)
- **[High]** `analytics_daily_summary` 테이블이 프로덕션에 **부재** → `AdminAnalytics.tsx` 는 깨진 페이지
- **[Medium]** `profiles` SELECT `USING(true)` {authenticated} — 로그인만 하면 전체 프로필 조회
- **[Medium]** A-39 RPC 로 **임의 endpoint 무제한 등록** 가능(푸시 증폭). 기존 anon INSERT 에도 없던 제한이라 회귀는 아니나 상한 도입 기회를 놓침
- **[Low]** SW(`generateSW`)가 `Admin*-*.js` 13개를 **precache** → 관리 화면 코드가 모든 방문자에게 내려감(정적 번들 특성, 기존결함)
- **[Low]** `GRANT EXECUTE TO anon` 후에도 `proacl` 에 authenticated 포함(Supabase 기본권한) — task-2991 함수 5종과 동일
- **[Low]** `AppSidebar.tsx` import 0건 = 죽은 코드 · `adminMenuItems` 12건 vs adminOnly 라우트 13건

---

## 수정 파일별 검증 상태

| 파일 | 변경 | 검증 | 상태 |
|---|---|---|---|
| /home/jay/projects/InsuRo/.worktrees/task-2996-dev4/src/App.tsx | 라우터 단일 지점 가드 | vitest 25 + 돌연변이 4/4 + 번들 실측 + 실브라우저 | verified |
| .../src/components/navigation/ContextSubMenu.tsx | 관리자 메뉴 권한 조건 | vitest 5 + 돌연변이 + 실브라우저(메뉴 미노출) | verified ★E1 |
| .../src/components/__tests__/AdminRouteGuard.test.tsx | 가드 + 불변식 테스트 | 25 passed, 돌연변이 3 FAIL 확인 | verified |
| .../src/components/__tests__/ContextSubMenu.admin.test.tsx | 메뉴 가드 테스트 | 5 passed | verified |
| .../supabase/migrations/20260822T000001_..._ai_config_admin_only.sql | SELECT 정책 제거 | 트랜잭션 적용→차단 실증→롤백 | verified (미적용) |
| .../supabase/migrations/20260822T000000_..._ROLLBACK.sql | 정책 원문 복원 | 롤백 후 스냅샷 완전 일치 | verified (미적용) |
| .../supabase/migrations/20260822T000003_..._push_subscriptions_caller_binding.sql | anon 3정책 제거 + RPC 2종 | IDOR 4종 관통 실패 실증 | verified (미적용) |
| .../supabase/migrations/20260822T000002_..._ROLLBACK.sql | RPC drop + 정책 복원 | 롤백 무결성 PASS | verified (미적용) |
| .../supabase/migrations/_backup/task-2996_pg_policies_before.json | 정책 191건 백업 | 라이브 pg_policies 와 정확 일치 | verified |

## 3 Step Why

- **1st Why — 왜 이 설계가 필요한가? → A**
  방어가 13개 페이지에 흩어져 있어 신규 관리자 페이지에서 체크 한 줄만 빠져도 즉시 노출된다.
  `adminOnly` 는 선언만 되고 아무도 읽지 않는 죽은 메타데이터였다. 단일 지점 강제가 필요하다.

- **2nd Why — 왜 A(라우터 인라인 가드)가 최선인가? → B**
  라우터는 모든 페이지 진입이 반드시 통과하는 유일한 공통 길목이다. `routes.ts` 의 `adminOnly` 를
  라우터가 읽게 만들면 선언이 곧 강제가 되어, 신규 페이지는 플래그만 붙이면 자동으로 보호된다.

- **3rd Why — 왜 B가 대안보다 나은가? → C**
  대안1 페이지별 HOC 부착 = 여전히 사람이 매번 붙여야 함(누락 가능) → 기각.
  대안2 AuthGuard 확장 = `AuthGuard.tsx` 는 task-2990 결과물로 범위 밖 → 기각.
  대안3 별도 파일 컴포넌트 = `src/components/` 신규 파일은 allowed_resources 밖 → 기각.
  → `App.tsx` 인라인이 유일하게 범위 내이면서 단일 지점을 만족한다.
  A(단일 지점 필요) → B(라우터가 그 지점) → C(인라인이 유일한 범위 내 구현) 로 일관.
  ★ 나아가 "불변식을 테스트로 봉인"까지 해야 A 가 실제로 달성된다(로키 지적 수용).

## 게이트
- **Codex 사전 검증**: PASS (`memory/events/task-2996.codex-gate`)
- **마아트 독립 검증**: V1~V7 PASS, **V8 scope-guard FAIL** → 종합 `NEEDS_WORK`(= E1/E2 미해소 상태 판정)
- **로키 레드팀**: A-40/A-39 공격면 **실측 차단 확인**, A-33 불변식 Medium → **본 실행에서 봉인 완료**
- **Gemini**: sunset(OFF) — 리뷰는 Codex + 마아트 + 로키로 대체

## trip-wire 5종 (실측)
| 항목 | 값 |
|---|---|
| Critical7 | 0 |
| PII net-new | 0 |
| 회귀 실패 | 0 (base 88/1265 → head 90/1295) |
| forbidden_paths 침범 | 0 |
| nonce | task-2996 |

## 결론
- **A-33 완료** — 라우터 단일 지점 가드 + 메뉴 조건 + 불변식 봉인. 이중 방어 보존. 실브라우저 차단 실증.
- **A-40 SQL 쌍 완료·차단 실증 완료 — 적용은 ANU 승인 대기.** 단독 적용 안전.
- **A-39 SQL 쌍 완료·차단 실증 완료 — 적용은 ANU 승인 대기.** ★ **프론트 동반 수정 없이는 적용 금지**(E2).
- **PR 생성까지가 범위.** 머지는 ANU. **E1(scope 정정)** 해소 전에는 머지 불가.

## PR
- **PR #243** — https://github.com/Jeon-Jonghyuk/InsuRo/pull/243 (state: open)
- remote head `34695a04e2fba49d35a7a9096911f185fe67eb43`
- **blob identity 9/9 MATCH** · tree sha `f49f3357...` 로컬=원격 동일
- `gh` 호출은 전부 봇 토큰(`BOT_GITHUB_TOKEN`) 사용 — 회장 개인 PAT 미사용
- **머지 금지** — ANU 판정 대기

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

