bibimbap/.atp/work-session/20260623-104307/implementation/W3-3-posting-board-design.md

46 KiB

phase agent agent_version generated_at workstream concerns concerns_checked self_verification references
design design-advisor 1 2026-06-23T12:00:00+09:00 W3-3-포스팅 보드(공지/블로그 + OG 미리보기 + 유니티블로그 피드 감시)
★SSRF 핵심: SsrfSafeFetcher 의 'DNS resolve → IP 검증 → 그 IP 로 connect(host 헤더는 원본 호스트명)' 핀닝은 java.net.http.HttpClient/RestClient 기본 동작으로는 그대로 안 된다(클라이언트가 호스트명으로 재-resolve). 구현은 (a) custom javax.net.SocketFactory/InetAddress 핀닝 또는 (b) resolve→검증 후 connect→연결된 소켓의 실제 peer IP 재검증(TOCTOU 최소화) 중 (b)를 1차로 명세했다. 구현 단계에서 라이브러리 제약으로 (a)가 필요해질 수 있으니 SSRF 단위테스트(사설/메타데이터/rebinding mock)로 실제 차단을 검증할 것.
pom.xml 신규 의존 3종(commonmark, jsoup, 선택적으로 HTTP 클라이언트는 spring-web 내장 RestClient 로 충족) 추가가 전제다. design-advisor 는 pom.xml 을 수정하지 않으므로 implementation 단계 첫 작업이 의존 추가 + contextLoads 확인이다. 의존 미추가 시 컴파일 불가 — 구현 1보에서 의존 추가를 먼저 커밋.
신규 컨트롤러(PostController/PostAdminController/UnityFeedAdminController)·신규 매퍼 6종·SsrfSafeFetcher·OgPreviewService·UnityFeedPoller 빈 추가는 verification-strategies §30 대상 — BibimbapApplicationTests 에 @MockBean 수동 등록 의무. 누락 시 contextLoads NoSuchBeanDefinitionException. full ./mvnw -o test 로 끝낼 것(test-compile 만 금지).
신규 매퍼 SQL 의 camelCase alias 는 일반 매퍼 표준(snake→camel 직접 alias: og_title AS ogTitle). 집계/큰따옴표 alias 불필요(verification-strategies §33). DB-방언 계약(L2) 대상.
UnityFeedPoller @Scheduled 는 @EnableScheduling 활성화가 전제(현재 미활성, code-fact: @EnableScheduling 0 hit). 신규 @Configuration 또는 메인 클래스에 활성화 필요 — 구현 시 다중 인스턴스 동시 폴링 중복 방지(현 톰캣 단일 인스턴스라 1차는 무방, 스케일아웃 시 분산락 필요는 비목표).
PostMarkdownService.render 시그니처는 최소 인자(markdown 1개)로 명세. 구현에서 sanitize 정책 주입이 필요하면 그때 확장(inflate 방지, 프로토콜 §11.2).
true
checklist_passed
true
requirements research adrs
docs/work-log/2026-06-17-w3-feature-skeletons.md .atp/work-session/20260623-104307/research/W2-W4-grounding.md
.atp/work-session/20260622-180054/implementation/W1-design.md
docs/work-log/2026-06-23-w2-w4-feature-skeletons.md
docs/work-log/2026-06-17-jam-platform-roadmap.md
docs/development/verification-strategies.md

설계: W3-3 — 포스팅 보드 (공지/블로그 + 외부링크 OG 미리보기 + 유니티블로그 피드 감시)

목표 / 비목표

목표 (FR/NFR 추적)

  • G1 포스팅 작성/수정/삭제 (골자 W3-3 핵심): POST_WRITE 권한자만 작성. 일반 유저 읽기 전용. 댓글 없음(골자 Q5 — W3-2 결합 0).
  • G2 카테고리 운영 (골자 Q2): post_categories DB 저장, 운영자 추가/수정/삭제. 정렬·활성 토글.
  • G3 마크다운 본문 + sanitize (골자 Q4): 본문은 마크다운 입력 → allowlist sanitize HTML 캐시. 저장 시 body_markdown(원본) + body_sanitized_html(렌더 캐시) 듀얼.
  • G4 ★외부링크 OG 미리보기 + SSRF 방어 (골자 Q3): 서버측 fetch 로 OG 메타 추출, SSRF 전수 방어, 결과 캐싱, 실패 graceful(링크만).
  • G5 ★유니티블로그 외부 피드 감시 (골자 Q6): RSS/Atom 폴링 + dedupe(last_seen_guid) + 새 글 감지 → 운영자 알림 표면. 피드 URL 도 SSRF 방어.
  • G6 게시판 페이징 (골자/grounding R-F): RecruitPostsMapper 페이징 부재 → keyset 페이징 신규.
  • NFR-보안: 상태변경 CSRF 전수, #{} 바인딩(${} 금지), POST_WRITE 게이트(임시 role 직접체크 금지), SSRF 방어 전수, 마크다운 sanitize XSS 차단, JSP 출력 escape.
  • NFR-정합: DDL 권위 = docs/board-ddl.sql, schema.sql 동기. 멱등·비파괴.

비목표 (스코프 밖)

  • 포스팅 댓글/리액션 (골자 Q5 — 읽기 전용 채널 확정).
  • 마크다운 위지윅 에디터 (입력은 textarea + 미리보기, 에디터 고도화는 후속).
  • 외부 피드 자동 재게시(임포트) — 본 설계는 감지 + 운영자 알림까지. 감지 항목을 정식 포스트로 자동 전환하지 않음(운영자가 수동으로 작성). 피드 항목 클릭 시 원문 링크 이동.
  • 피드 폴링 분산락/멀티인스턴스 중복 방지 (현 단일 톰캣 인스턴스 전제, 스케일아웃 시 후속).
  • OG 미리보기 이미지 프록시/리사이즈 캐싱 (URL 캐시까지, 바이너리 캐시는 후속).
  • 잼 연동 (골자 — 포스팅은 잼 무관 독립 채널).

개요

bibimbap 는 Spring Boot WAR + 톰캣 in-memory HttpSession + MyBatis @Mapper(#{}) + JSP 스택이다. 게시판 선례는 RecruitController(읽기=JSP 뷰이름 반환, 쓰기=ResponseEntity<Map> + CsrfTokens.isValid + 세션 userId 화이트리스트)이며 PermissionGate 미적용·페이징 부재다(code-fact: RecruitController.java:65,159; RecruitPostsMapper 페이징 0).

본 설계는 posts + post_categories 게시판을 신설하고, 작성 경로에 W1 PermissionGate.has(session, PermissionKeys.POST_WRITE.name()) enforcement 를 연결한다(현재 POST_WRITE 키는 enum 선언만·소비처 0 — code-fact: PermissionKeys.java:5, grounding R-A). 임시 role 직접체크는 금지(정석 원칙).

가장 까다로운 두 보안 난제를 다음과 같이 확정한다.

  • 난제1 (마크다운 → sanitize HTML): 단일 정의처 = PostMarkdownService. 입력 마크다운을 commonmark 로 HTML 변환 후 jsoup Safelist(allowlist) 로 sanitize 해 body_sanitized_html 에 캐시한다. 허용 서식은 제목(h1~h3)/볼드/이탤릭/리스트/링크(rel=nofollow noopener, http/https만)/인라인·블록 코드/이미지(http/https src만). 스크립트·이벤트핸들러(onclick 등)·javascript:·style·iframe·object 전부 제거. 저장 시 1회 sanitize → 조회 시 캐시 출력(매 조회 재sanitize 비용 제거 + 정책 변경 시 재생성 배치 여지). JSP 는 sanitized HTML 을 <c:out escapeXml="false"> 가 아니라 이미 sanitize 된 신뢰 HTML 로 직접 출력(단 sanitize 가 유일 신뢰원천).
  • 난제2 (★SSRF 방어 — OG fetch + 피드 fetch 공통): 단일 공용 컴포넌트 SsrfSafeFetcher 로 외부 fetch 를 단일화한다(crossRefs 권장). OG 미리보기와 유니티블로그 피드가 동일 fetcher 를 통과한다. 방어 체크리스트는 §보안(SsrfSafeFetcher 계약)에 전수 기록. 핵심: scheme allowlist(http/https) → 호스트 resolve → 모든 resolved IP 가 공인(public) 인지 검증(사설/루프백/링크로컬/메타데이터 169.254.169.254 차단) → connect → 연결된 소켓의 실제 peer IP 재검증(rebinding 방어) → redirect 매 홉 재검증(최대 N홉) → 응답 size cap + timeout → Content-Type 검증. 실패는 예외가 아니라 graceful 결과(미리보기 없음 / 피드 폴링 skip + 로그).

핵심 결정 요약 (전제 — 재논의 금지, 난제는 본 설계가 확정)

결정 확정값 본 설계의 구체화
D1 게시판 모델 posts + post_categories(DB저장) posts(FK category_id, author_user_id, title, body_markdown, body_sanitized_html, og_*, status, is_delete) + post_categories(name/slug/sort_order/is_active)
D2 작성 권한 POST_WRITE 게이트 enforcement 연결 PermissionGate.has(session, PermissionKeys.POST_WRITE.name()). 임시 role 직접체크 금지. ADMIN 은 게이트 내부에서 암묵 통과
D3 댓글 없음 읽기 전용 채널(골자 Q5). W3-2 결합 0
D4 본문 마크다운 → sanitize HTML allowlist commonmark 변환 + jsoup Safelist. body_markdown 원본 + body_sanitized_html 캐시(저장 시 1회 sanitize)
D5 ★OG 미리보기 서버 fetch + SSRF 방어 + 캐싱 공용 SsrfSafeFetcher 경유. 결과는 posts.og_* 컬럼 캐시. 실패 graceful
D6 ★유니티 피드 감시 RSS/Atom 폴링 + dedupe + 알림 unity_feed_sources + unity_feed_items. @Scheduled 폴링. last_seen_guid dedupe. 운영자 대시보드 미확인 배지
D7 외부 fetch 인프라 공용 SsrfSafeFetcher 단일화 OG·피드 동일 fetcher 경유(SSRF 방어 중복 0)
D8 페이징 keyset 페이징 (created_at, id) 커서. RecruitPostsMapper 전건 조회와 결별
D9 신규 의존 commonmark + jsoup HTTP 는 spring-web 내장 RestClient 사용(신규 의존 0). 마크다운/sanitize 만 신규

데이터 모델 (DDL)

권위 = 신규 docs/board-ddl.sql. db/apply-local-ddl.shdocs/*-ddl.sql 글롭 알파벳순 멱등 적용(ON_ERROR_STOP, search_path=dev). schema.sql 은 동기 사본(recruit_posts/rbac 선례와 동일). 전부 멱등·비파괴. 따옴표 식별자·SEQUENCE+nextval·DO guard FK 등 기존 스타일(schema.sql:263-368) 준수.

신규 파일: docs/board-ddl.sql

-- W3-3 포스팅 보드. 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용.
-- posts / post_categories / unity_feed_sources / unity_feed_items. 추가만, 파괴 없음.

-- 1) post_categories (운영자 CRUD 카테고리 — D1/G2)
CREATE SEQUENCE IF NOT EXISTS "post_categories_id_seq";
CREATE TABLE IF NOT EXISTS "post_categories" (
    "id"          bigint DEFAULT nextval('post_categories_id_seq'::regclass) NOT NULL,
    "name"        character varying(80) NOT NULL,
    "slug"        character varying(80) NOT NULL,
    "sort_order"  integer DEFAULT 0 NOT NULL,
    "is_active"   boolean DEFAULT true NOT NULL,
    "created_at"  timestamp with time zone DEFAULT now() NOT NULL,
    "updated_at"  timestamp with time zone DEFAULT now() NOT NULL,
    PRIMARY KEY ("id")
);
ALTER SEQUENCE "post_categories_id_seq" OWNED BY "post_categories"."id";
CREATE UNIQUE INDEX IF NOT EXISTS "ux_post_categories_slug"
    ON "post_categories" ("slug");

-- 2) posts (D1) — body_markdown 원본 + body_sanitized_html 캐시(D4), og_* 캐시(D5)
CREATE SEQUENCE IF NOT EXISTS "posts_id_seq";
CREATE TABLE IF NOT EXISTS "posts" (
    "id"                  bigint DEFAULT nextval('posts_id_seq'::regclass) NOT NULL,
    "category_id"         bigint NOT NULL,
    "author_user_id"      bigint NOT NULL,
    "title"               character varying(200) NOT NULL,
    "body_markdown"       text NOT NULL,           -- 원본(편집·재렌더 소스)
    "body_sanitized_html" text NOT NULL,           -- 저장 시 sanitize 캐시(D4, 조회 출력원)
    "link_url"            character varying(2048),  -- 외부링크 큐레이션 대상(OG 미리보기 소스)
    "og_title"            character varying(300),   -- OG 캐시(D5). NULL=미리보기 없음
    "og_description"      character varying(600),
    "og_image_url"        character varying(2048),
    "og_site_name"        character varying(200),
    "og_fetched_at"       timestamp with time zone, -- 마지막 OG fetch 시각(재fetch 정책 기준)
    "status"              character varying(20) DEFAULT 'PUBLISHED' NOT NULL, -- DRAFT/PUBLISHED
    "created_at"          timestamp with time zone DEFAULT now() NOT NULL,
    "updated_at"          timestamp with time zone DEFAULT now() NOT NULL,
    "deleted_at"          timestamp with time zone,
    "is_delete"           boolean DEFAULT false NOT NULL,
    PRIMARY KEY ("id")
);
ALTER SEQUENCE "posts_id_seq" OWNED BY "posts"."id";
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_category_id_fkey') THEN
        ALTER TABLE "posts"
            ADD CONSTRAINT "posts_category_id_fkey"
            FOREIGN KEY ("category_id") REFERENCES "post_categories" ("id");
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_author_user_id_fkey') THEN
        ALTER TABLE "posts"
            ADD CONSTRAINT "posts_author_user_id_fkey"
            FOREIGN KEY ("author_user_id") REFERENCES "users" ("id");
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_status_check') THEN
        ALTER TABLE "posts"
            ADD CONSTRAINT "posts_status_check"
            CHECK ("status" IN ('DRAFT', 'PUBLISHED'));
    END IF;
END
$$;
-- keyset 페이징 + 카테고리 필터 커버링 인덱스(D8)
CREATE INDEX IF NOT EXISTS "idx_posts_published_keyset"
    ON "posts" ("status", "is_delete", "created_at" DESC, "id" DESC);
CREATE INDEX IF NOT EXISTS "idx_posts_category_keyset"
    ON "posts" ("category_id", "created_at" DESC, "id" DESC)
    WHERE "is_delete" = false AND "status" = 'PUBLISHED';

-- 3) unity_feed_sources (외부 피드 감시 소스 — D6/G5)
CREATE SEQUENCE IF NOT EXISTS "unity_feed_sources_id_seq";
CREATE TABLE IF NOT EXISTS "unity_feed_sources" (
    "id"             bigint DEFAULT nextval('unity_feed_sources_id_seq'::regclass) NOT NULL,
    "name"           character varying(120) NOT NULL, -- 표시명(예: Unity Blog)
    "feed_url"       character varying(2048) NOT NULL,
    "is_active"      boolean DEFAULT true NOT NULL,
    "last_polled_at" timestamp with time zone,
    "last_seen_guid" character varying(512),          -- dedupe 커서(가장 최근 본 항목 guid)
    "last_error"     character varying(500),           -- 마지막 폴링 실패 사유(graceful)
    "created_at"     timestamp with time zone DEFAULT now() NOT NULL,
    PRIMARY KEY ("id")
);
ALTER SEQUENCE "unity_feed_sources_id_seq" OWNED BY "unity_feed_sources"."id";
CREATE UNIQUE INDEX IF NOT EXISTS "ux_unity_feed_sources_url"
    ON "unity_feed_sources" ("feed_url");

-- 4) unity_feed_items (감지된 새 글 — 운영자 알림 표면 + dedupe 영속화)
CREATE SEQUENCE IF NOT EXISTS "unity_feed_items_id_seq";
CREATE TABLE IF NOT EXISTS "unity_feed_items" (
    "id"             bigint DEFAULT nextval('unity_feed_items_id_seq'::regclass) NOT NULL,
    "source_id"      bigint NOT NULL,
    "guid"           character varying(512) NOT NULL,  -- 피드 항목 고유 식별자(dedupe 키)
    "title"          character varying(500) NOT NULL,
    "link_url"       character varying(2048) NOT NULL,
    "published_at"   timestamp with time zone,
    "is_acknowledged" boolean DEFAULT false NOT NULL,  -- 운영자 확인 여부(알림 배지)
    "detected_at"    timestamp with time zone DEFAULT now() NOT NULL,
    PRIMARY KEY ("id")
);
ALTER SEQUENCE "unity_feed_items_id_seq" OWNED BY "unity_feed_items"."id";
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'unity_feed_items_source_id_fkey') THEN
        ALTER TABLE "unity_feed_items"
            ADD CONSTRAINT "unity_feed_items_source_id_fkey"
            FOREIGN KEY ("source_id") REFERENCES "unity_feed_sources" ("id");
    END IF;
END
$$;
-- 동일 소스 내 guid 중복 차단(dedupe 영속 보장 — DB 레벨 1차 방어)
CREATE UNIQUE INDEX IF NOT EXISTS "ux_unity_feed_items_source_guid"
    ON "unity_feed_items" ("source_id", "guid");
-- 미확인 항목 배지 카운트용
CREATE INDEX IF NOT EXISTS "idx_unity_feed_items_unack"
    ON "unity_feed_items" ("is_acknowledged", "detected_at" DESC)
    WHERE "is_acknowledged" = false;

db/schema.sql 반영 (최초 기동 1회 자동 주입)

  • rbac_audit_log 블록 뒤(schema.sql:369 직후)에 위 1~4번 블록을 신설 추가.
  • 반영 방식은 recruit_posts/rbac 가 schema.sql 에 동기화된 선례(schema.sql:261-368)와 동일 — docs/board-ddl.sql 이 권위, schema.sql 은 그 사본.

카테고리 부트스트랩

  • 카테고리는 D2 에 따라 운영자가 콘솔에서 생성. 시드 불필요(빈 상태에서 운영자가 첫 카테고리 생성). 단 dev 편의를 위해 db/seed-dev.sql 류가 있으면 샘플 카테고리 1건 INSERT 는 documentation/seed 소관(본 설계는 요구만 명시, DDL 에 포함 안 함).

외부 계약 (API)

공통: 모든 상태변경은 CsrfTokens.isValid(request) 검증(실패 시 403 + CsrfTokens.errorBody()). 쓰기 응답은 ResponseEntity<Map<String,Object>>(status/message), 읽기는 JSP 뷰이름 반환(RecruitController 패턴). 작성/카테고리/피드관리 액션은 권한 게이트 통과 후 도달.

401 vs 403 정책 (확정 — W1 정책과 일치)

  • 미인증(세션 userId 없음): 페이지 진입 redirect:/login, 쓰기 API 401 JSON({status:401,message:"로그인이 필요합니다."}).
  • 인증·미인가(로그인됐으나 POST_WRITE 없음): 403({status:403,message:"권한이 없습니다."}). 페이지 작성폼 진입도 403(리다이렉트 금지 — 권한 없음을 로그인으로 오인 유도 방지).
  • CSRF 실패: 403 + CsrfTokens.errorBody().

공개 포스팅 페이지 (뷰 — 인증 불요, 읽기 전용)

method path 권한 응답
GET /posts 공개 posts-list JSP. PUBLISHED 글 keyset 페이징(쿼리 ?categoryId=&cursorCreatedAt=&cursorId=) + 카테고리 탭(is_active)
GET /posts/{id} 공개 posts-detail JSP. body_sanitized_html + (있으면) OG 미리보기 카드. is_delete/DRAFT 면 redirect:/posts

작성/편집 (POST_WRITE 게이트 — D2)

method path 권한 요청 응답(200) 에러
GET /posts/new POST_WRITE (없음) posts-form JSP(활성 카테고리 목록 + CSRF) 401 redirect / 403
POST /posts POST_WRITE categoryId, title, bodyMarkdown, linkUrl?, status {status:200, postId, location:"/posts/{id}"} 400(검증), 401, 403(권한/CSRF), 404(카테고리 없음/비활성)
GET /posts/{id}/edit POST_WRITE (path) posts-form JSP(기존 값) 401/403/404
POST /posts/{id} (수정) POST_WRITE 동일 필드 {status:200, postId} 400/401/403/404
POST /posts/{id}/delete POST_WRITE (path) {status:200} 401/403/404
  • 작성 검증: title ≤ 200자, bodyMarkdown ≤ 20000자, linkUrl ≤ 2048자 + scheme http/https(없으면 OG 생략), status ∈ {DRAFT, PUBLISHED}, categoryId 는 존재·is_active.
  • 저장 부작용: bodyMarkdown → PostMarkdownService.render → body_sanitized_html. linkUrl 있으면 동기 OG fetch(아래 OG 정책) — 단 타임아웃 cap 내 실패 시 og_* NULL 로 graceful 저장(작성 자체는 성공).
  • 권한 모델: 작성·수정·삭제 모두 POST_WRITE 게이트. 작성자 본인 한정이 아니라 POST_WRITE 보유자 전원 편집 가능(공지/블로그 = 운영 채널 특성 — 본인글 제한은 비목표; 단 삭제/수정 시 author_user_id 는 보존, 감사 가능성은 후속). ADMIN 은 게이트 내부에서 암묵 통과(PermissionGate.has 의 role==ADMIN 분기, PermissionGate.java:30).

카테고리 운영 (운영자 — D2/G2)

카테고리 CRUD 는 운영자 전용. 인터셉터 보호 경로 /admin/** 아래에 둔다 → RbacInterceptor 가 ADMIN 게이트 적용(InterceptorConfig.java:19 /admin/**). 카테고리 운영은 ADMIN 권한으로 통일(별도 권한키 불필요 — over-engineering 회피).

method path 권한 요청 응답(200) 에러
GET /admin/post-categories ADMIN(인터셉터) (없음) admin-post-categories JSP 또는 {categories:[...]} 401/403
POST /admin/post-categories ADMIN name, slug, sortOrder {status:200, categoryId} 400, 409(slug 중복), 403
POST /admin/post-categories/{id} ADMIN name?, slug?, sortOrder?, isActive? {status:200} 400, 404, 409, 403
POST /admin/post-categories/{id}/delete ADMIN (path) {status:200} 404, 409(소속 포스트 존재 시 거부 또는 soft), 403
  • 카테고리 삭제 정책: 소속 PUBLISHED 포스트가 있으면 하드 삭제 거부(409) + is_active=false 권고(메시지). 정석: FK 무결성 보존, 포스트 고아화 방지.

유니티 피드 감시 운영 (운영자 — D6/G5)

동일하게 /admin/** 아래 → 인터셉터 ADMIN 게이트.

method path 권한 요청 응답(200) 에러
GET /admin/unity-feeds ADMIN (없음) admin-unity-feeds JSP(소스 목록 + 미확인 감지 항목 + 배지 카운트) 401/403
POST /admin/unity-feeds ADMIN name, feedUrl {status:200, sourceId} 400(SSRF 거부 시 feedUrl 부적합), 409(url 중복), 403
POST /admin/unity-feeds/{id}/toggle ADMIN (path) {status:200, isActive} 404, 403
POST /admin/unity-feeds/{id}/delete ADMIN (path) {status:200} 404, 403
POST /admin/unity-feeds/items/{itemId}/ack ADMIN (path) {status:200} 404, 403
POST /admin/unity-feeds/poll ADMIN (없음, 수동 트리거) {status:200, newCount} 403
  • 피드 등록 시 SSRF 선검증: feedUrl 은 등록 시점에 SsrfSafeFetcher 의 URL 검증(scheme/host resolve/IP 공인 여부)을 통과해야 등록 허용(400 거부). 폴링 시 매번 재검증(rebinding 방어).

보안: SsrfSafeFetcher 계약 (★핵심 — D5/D7, OG·피드 공통)

단일 공용 컴포넌트. OG 미리보기 fetch 와 유니티 피드 fetch 가 둘 다 이 fetcher 만 경유한다(SSRF 방어 중복 0, crossRefs 단일화 권장 채택).

SSRF 방어 체크리스트 (전수 — securityNotes 에 기록)

  1. scheme allowlist: http, https 만 허용. file:/gopher:/ftp:/data: 등 전부 거부.
  2. 호스트명 resolve → IP 검증: InetAddress.getAllByName(host) 로 전체 IP 해석. 모든 resolved IP 가 공인이어야 통과(하나라도 사설/예약이면 거부).
  3. 차단 IP 대역: 루프백(127.0.0.0/8, ::1), 사설(10/8, 172.16/12, 192.168/16, fc00::/7), 링크로컬(169.254/16, fe80::/10), 메타데이터(169.254.169.254), 0.0.0.0, multicast, 와일드카드. InetAddress.isLoopbackAddress/isSiteLocalAddress/isLinkLocalAddress/isAnyLocalAddress/isMulticastAddress + 명시 169.254.169.254 차단 + IPv4-mapped IPv6 정규화 후 재검사.
  4. ★DNS rebinding 방어: resolve 시점 IP 와 connect 후 실제 peer IP 가 다를 수 있다. 1차 명세 = connect 후 소켓의 getInetAddress()(실제 연결된 peer IP) 를 #3 기준으로 재검증 → 실패 시 즉시 abort. (concern: HttpClient 가 host 로 재resolve 하므로 connection-level 검증 필요. 구현에서 custom SocketFactory IP 핀닝이 필요하면 그때 도입.)
  5. redirect 매 홉 재검증: 자동 redirect 따라가기 비활성(RestClient/HttpClient followRedirects=NEVER) → 3xx Location 을 받으면 수동으로 #1~#4 재검증 후 다음 홉. 최대 홉 수 cap(예: 3). 초과 시 abort.
  6. 응답 size cap: 응답 본문 최대 바이트(예: OG=512KB, 피드=2MB) 초과 시 스트림 중단(부분 파싱). Content-Length 신뢰 금지 — 읽는 바이트 누적 카운트.
  7. timeout: connect + read timeout(예: 3s/5s). 무한 대기 방지.
  8. Content-Type 검증: OG=text/html(또는 application/xhtml+xml), 피드=application/rss+xml / atom+xml / xml / text/xml. 불일치 시 파싱 거부(graceful).
  9. 에러 graceful: 위 어느 단계 실패든 예외를 호출자에 전파하지 않고 Optional.empty()/실패 결과 반환. OG = 미리보기 없이 링크만. 피드 = 폴링 skip + unity_feed_sources.last_error 기록.

fetcher 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)

// SsrfSafeFetcher — 외부 fetch 단일 관문(@Component). OG·피드 공통.
// URL 검증 + 안전 fetch 를 한 메서드로 묶는다(검증만 따로 노출하면 검증-fetch 사이 TOCTOU).
Optional<FetchResult> fetch(URI url,          // 대상(검증 통과해야 fetch). scheme/host 검증 진입점
                            long maxBytes,     // 응답 size cap(OG/피드 호출자가 용도별 상한 지정)
                            Set<String> allowedContentTypes) // #8 검증용(용도별 다름)
// FetchResult: int status / String contentType / byte[] body(cap 내) / URI finalUrl

// 등록 시 사전 URL 검증만 필요한 경우(피드 등록 400 판정) — fetch 없이 검증만.
boolean isFetchableUrl(URI url)               // #1~#3 정적 검증(등록 시점 빠른 거부)

⚠️ inflate 마킹(concern): fetchallowedContentTypes 는 호출처가 2곳(OG=html, 피드=xml)뿐이라 인자로 받는 게 정당(상수 분기보다 명시적). maxBytes 도 용도별로 달라 정당. 그러나 구현에서 두 인자가 사실상 호출처별 고정 상수로만 쓰이면 fetchHtml(url)/fetchFeed(url) 2메서드로 분리하는 게 더 깔끔할 수 있음 — 구현 단계에서 재평가.


마크다운 sanitize 계약 (D4)

// PostMarkdownService — 마크다운→sanitize HTML 단일 정의처(@Component).
String render(String markdown)   // 원본 → commonmark 변환 → jsoup Safelist sanitize → 캐시용 HTML
  • commonmark(org.commonmark) 로 markdown → HTML.
  • jsoup Safelist allowlist:
    • 허용 태그: h1,h2,h3,p,br,strong,em,ul,ol,li,blockquote,code,pre,a,img,hr.
    • a: href(http/https/mailto만), rel=nofollow noopener, target=_blank 강제 부여. javascript: 차단(jsoup 기본).
    • img: src(http/https만), alt. 인라인 width/style 제거.
    • 제거: script, style, iframe, object, embed, form, on* 이벤트 속성 전부.
  • Safelist.basicWithImages() 기반 + 커스텀(h1~h3 추가, a rel 강제, protocol 제한). 저장 시 1회 render → body_sanitized_html. 조회는 캐시 출력(재sanitize 없음).
  • concern: render 시그니처는 markdown 1개 최소 인자. sanitize 정책을 호출별로 바꿀 필요가 생기면 그때 확장.

시퀀스

S1. 포스팅 작성(POST_WRITE 게이트 + 마크다운 sanitize + OG fetch)

[POST_WRITE 보유 세션] POST /posts (CSRF, categoryId, title, bodyMarkdown, linkUrl?)
  → PostController.create
      → CsrfTokens.isValid(request) 실패 → 403 errorBody          ← AC-5
      → userId = sessionUserId; null → 401
      → permissionGate.has(session, POST_WRITE.name()) 실패 → 403   ← AC-1 (게이트 enforcement 연결)
          (내부: refreshIfStale epoch 대조 → role==ADMIN 통과 | SUBADMIN&&perms.contains(POST_WRITE))
      → 입력 검증(title≤200, bodyMarkdown≤20000, status 화이트리스트)
      → categoryMapper.getActive(categoryId) 없음 → 404
      → sanitizedHtml = postMarkdownService.render(bodyMarkdown)    ← AC-4 (XSS 차단)
      → linkUrl 있으면:
            ogResult = ogPreviewService.fetch(linkUrl)             ← SsrfSafeFetcher 경유
              - 성공 → og_* 채움
              - 실패/SSRF거부 → og_* NULL (graceful, 작성은 성공)   ← AC-7
      → postsMapper.insert(category, author, title, markdown, sanitizedHtml, og_*, status)
      → 200 {postId, location:"/posts/{id}"}

S2. 포스팅 조회(공개 — sanitize 캐시 출력 + 페이징)

[익명/일반] GET /posts?categoryId=3&cursorCreatedAt=...&cursorId=...
  → PostController.list
      → postsMapper.listPublishedKeyset(categoryId, cursorCreatedAt, cursorId, limit+1)  ← AC-8 keyset
      → limit+1 행이면 hasNext=true, 마지막 행을 다음 커서로
      → posts-list JSP: 카드 목록(title, og_image_url 썸네일, 작성자 display_name)
[익명] GET /posts/42
  → postsMapper.getPublished(42); null/DRAFT/is_delete → redirect:/posts
  → posts-detail JSP: body_sanitized_html 직접 출력(이미 sanitize됨) + OG 카드(og_title/desc/image)

S3. 유니티 피드 폴링(@Scheduled + SSRF + dedupe + 알림)

[스케줄러] UnityFeedPoller.poll() (@Scheduled fixedDelay, 예: 30분)
  → for each source in feedSourcesMapper.listActive():
        result = ssrfSafeFetcher.fetch(source.feedUrl, FEED_MAX_BYTES, FEED_CONTENT_TYPES)
          - 실패 → feedSourcesMapper.updateError(source.id, reason); continue (graceful)  ← AC-7
        items = feedParser.parse(result.body)   // RSS/Atom → (guid, title, link, publishedAt)
        newItems = items where guid not in (DB ux_unity_feed_items_source_guid)  ← AC-6 dedupe
        for each newItem:
            feedItemsMapper.insertIgnoreDup(source.id, guid, title, link, publishedAt)
                (ux_unique 충돌 시 무시 — DB 레벨 2차 dedupe 방어)
        feedSourcesMapper.updateCursor(source.id, lastSeenGuid=items[0].guid, last_polled_at=now)
  → 운영자가 GET /admin/unity-feeds 진입 시 미확인(is_acknowledged=false) 항목 배지 노출  ← AC-6 알림
  • dedupe 이중 방어: (1) 폴링 로직이 guid 대조로 신규만 선별 + (2) DB ux_unity_feed_items_source_guid UNIQUE 가 경합·재폴링 시 중복 INSERT 를 무시(insertIgnoreDup = ON CONFLICT DO NOTHING). 단일 인스턴스라도 수동 트리거(/admin/unity-feeds/poll)와 스케줄 겹침 방어.

S4. 카테고리 운영(ADMIN 인터셉터 게이트)

[ADMIN 세션] POST /admin/post-categories (CSRF, name, slug, sortOrder)
  → RbacInterceptor preHandle: /admin/** → isAdmin 통과(아니면 403/401)  ← AC-3
  → PostAdminController.createCategory
      → CsrfTokens.isValid 실패 → 403
      → slug 중복 → 409
      → categoryMapper.insert → 200 {categoryId}

파일 영향 맵

소유권 분할 가이드(implementation-advisor worker 단위 후보): U-SCHEMA(board-ddl + schema.sql 동기) · U-DOMAIN(data POJO + 매퍼 6종) · U-MARKDOWN(PostMarkdownService + pom 의존) · U-SSRF(SsrfSafeFetcher + OgPreviewService — 보안 핵심, 단독 worker 권장) · U-FEED(UnityFeedPoller + FeedParser + 스케줄 활성) · U-POST-CTRL(PostController 공개/작성 + JSP) · U-ADMIN-CTRL(카테고리/피드 운영 컨트롤러 + JSP). 의존: U-SCHEMA → U-DOMAIN → {U-MARKDOWN, U-SSRF}. U-SSRF → {U-FEED(OgPreview 와 fetcher 공유), OG는 U-POST-CTRL 소비}. U-MARKDOWN/U-SSRF → U-POST-CTRL. U-DOMAIN → U-ADMIN-CTRL.

변경 유형 경로 역할 소유
신규 docs/board-ddl.sql 권위 DDL(posts/post_categories/unity_feed_sources/unity_feed_items). apply-local-ddl.sh 자동적용 U-SCHEMA
수정 db/schema.sql rbac 블록 뒤에 4테이블 동기 추가(board-ddl 사본) U-SCHEMA
수정 pom.xml commonmark + jsoup 의존 추가(HTTP 는 spring-web RestClient 내장) U-MARKDOWN
신규 src/main/java/com/pandoli365/bibimbap/data/PostData.java posts 행 POJO(id/categoryId/authorUserId/title/bodyMarkdown/bodySanitizedHtml/linkUrl/og*/status + 조인 author displayName/categoryName) U-DOMAIN
신규 src/main/java/com/pandoli365/bibimbap/data/PostCategoryData.java post_categories 행 POJO U-DOMAIN
신규 src/main/java/com/pandoli365/bibimbap/data/UnityFeedSourceData.java unity_feed_sources 행 POJO U-DOMAIN
신규 src/main/java/com/pandoli365/bibimbap/data/UnityFeedItemData.java unity_feed_items 행 POJO U-DOMAIN
신규 src/main/java/com/pandoli365/bibimbap/mapper/PostsMapper.java @Mapper posts CRUD + keyset 페이징(listPublishedKeyset/getPublished/insert/update/softDelete, #{}, snake→camel alias) U-DOMAIN
신규 src/main/java/com/pandoli365/bibimbap/mapper/PostCategoriesMapper.java @Mapper 카테고리 CRUD(listActive/getActive/insert/update/softToggle/delete/countPostsByCategory) U-DOMAIN
신규 src/main/java/com/pandoli365/bibimbap/mapper/UnityFeedSourcesMapper.java @Mapper 소스 CRUD(listActive/insert/updateCursor/updateError/toggle/delete) U-DOMAIN
신규 src/main/java/com/pandoli365/bibimbap/mapper/UnityFeedItemsMapper.java @Mapper 항목(insertIgnoreDup=ON CONFLICT DO NOTHING/listUnacknowledged/countUnacknowledged/acknowledge) U-DOMAIN
신규 src/main/java/com/pandoli365/bibimbap/service/PostMarkdownService.java 마크다운→sanitize HTML(commonmark+jsoup Safelist). render(markdown) U-MARKDOWN
신규 src/main/java/com/pandoli365/bibimbap/security/SsrfSafeFetcher.java ★외부 fetch 단일 관문(SSRF 방어 전수). fetch(url,maxBytes,types)/isFetchableUrl(url) U-SSRF
신규 src/main/java/com/pandoli365/bibimbap/service/OgPreviewService.java OG 메타 추출(SsrfSafeFetcher 경유 + jsoup OG 파싱). fetch(linkUrl)→OgPreview U-SSRF
신규 src/main/java/com/pandoli365/bibimbap/service/UnityFeedPoller.java @Scheduled 폴링(SsrfSafeFetcher + FeedParser + dedupe). poll()/pollOnce(sourceId) U-FEED
신규 src/main/java/com/pandoli365/bibimbap/service/FeedParser.java RSS/Atom 파싱(JDK javax.xml — 신규 의존 0). parse(bytes)→List U-FEED
신규 src/main/java/com/pandoli365/bibimbap/config/SchedulingConfig.java @EnableScheduling(현재 미활성, code-fact 0 hit) U-FEED
신규 src/main/java/com/pandoli365/bibimbap/controller/PostController.java 공개 목록/상세(JSP) + 작성/수정/삭제(POST_WRITE 게이트, JSON). PermissionGate 주입 U-POST-CTRL
신규 src/main/webapp/WEB-INF/views/posts-list.jsp 포스트 카드 목록 + 카테고리 탭 + keyset 더보기(JSTL escape) U-POST-CTRL
신규 src/main/webapp/WEB-INF/views/posts-detail.jsp 본문(sanitized html) + OG 카드(escape, og_image src 출력) U-POST-CTRL
신규 src/main/webapp/WEB-INF/views/posts-form.jsp 작성/수정 폼(textarea markdown + CSRF hidden + 카테고리 select) U-POST-CTRL
신규 src/main/java/com/pandoli365/bibimbap/controller/PostAdminController.java 카테고리 CRUD(/admin/post-categories, 인터셉터 ADMIN 게이트 + CSRF) U-ADMIN-CTRL
신규 src/main/java/com/pandoli365/bibimbap/controller/UnityFeedAdminController.java 피드 소스/항목 운영(/admin/unity-feeds, ADMIN + CSRF + ack/poll) U-ADMIN-CTRL
신규 src/main/webapp/WEB-INF/views/admin-post-categories.jsp 카테고리 운영 화면(CSRF) U-ADMIN-CTRL
신규 src/main/webapp/WEB-INF/views/admin-unity-feeds.jsp 피드 소스/미확인 항목 + 배지(CSRF) U-ADMIN-CTRL
수정 src/main/webapp/WEB-INF/views/header.jsp 포스팅 메뉴 링크 추가(/posts) + (운영자) 미확인 피드 배지 노출 후보 U-POST-CTRL
수정 src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java 신규 매퍼 6종·SsrfSafeFetcher·서비스 @MockBean 등록(contextLoads, §30) (검증)
신규 src/test/.../SsrfSafeFetcherTest.java ★SSRF 전수 단위(사설/루프백/메타데이터/rebinding/redirect/size/timeout/scheme 차단) (검증)
신규 src/test/.../PostMarkdownServiceTest.java sanitize XSS 차단(script/onclick/javascript:/iframe 제거) 단위 (검증)
신규 src/test/.../PostControllerTest.java 작성 POST_WRITE 게이트 401/403/CSRF/검증 + keyset 페이징 (검증)
신규 src/test/.../FeedParserTest.java + UnityFeedPollerTest.java RSS/Atom 파싱 + dedupe(신규만 insert) 단위 (검증)

SSR 호출지점: 전부 신규 경로(기존 호출지점 깨짐 0). header.jsp 메뉴 추가는 표시용.

신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)

// PostsMapper (@Mapper, #{} only, snake→camel 직접 alias)
List<PostData> listPublishedKeyset(java.time.OffsetDateTime cursorCreatedAt, // keyset 커서(null=첫페이지)
                                   Long cursorId,        // 동일 created_at 타이브레이커
                                   Long categoryId,      // null=전체 카테고리
                                   int limit)            // 페이지 크기(컨트롤러가 limit+1 요청해 hasNext 판정)
PostData getPublished(long id)                            // 상세(PUBLISHED && !is_delete + author/category 조인)
int insert(PostData post)                                 // 작성(useGeneratedKeys)
int update(PostData post)                                 // 수정(title/markdown/sanitized/og/status)
int softDelete(long id)                                   // is_delete=true, deleted_at=now

// PostCategoriesMapper
List<PostCategoryData> listActive()                       // 공개 탭 + 작성폼 select
PostCategoryData getActive(long id)                       // 작성 시 카테고리 유효성
int insert(PostCategoryData c) / int update(PostCategoryData c) / int delete(long id)
int countPostsByCategory(long categoryId)                 // 삭제 거부(409) 판정

// UnityFeedSourcesMapper
List<UnityFeedSourceData> listActive()                    // 폴링 대상
int insert(UnityFeedSourceData s)
int updateCursor(long id, String lastSeenGuid)            // 폴링 성공 후 커서 전진
int updateError(long id, String lastError)                // 폴링 실패 graceful 기록
int toggle(long id) / int delete(long id)

// UnityFeedItemsMapper
int insertIgnoreDup(UnityFeedItemData item)               // ON CONFLICT(source_id,guid) DO NOTHING
List<UnityFeedItemData> listUnacknowledged(int limit)     // 운영자 알림 목록
int countUnacknowledged()                                 // 배지 카운트
int acknowledge(long itemId)                              // 확인 처리

// OgPreviewService — SsrfSafeFetcher 경유. linkUrl 1개 최소.
Optional<OgPreview> fetch(String linkUrl)                 // 실패/SSRF거부 시 empty(graceful)

// UnityFeedPoller
void poll()                                               // @Scheduled 전체 활성 소스 폴링
int pollOnce(long sourceId)                               // 수동 트리거(반환 newCount)

대안 비교

주제 장점 단점 채택
외부 fetch 인프라 (A) 공용 SsrfSafeFetcher 단일 OG·피드 SSRF 방어 중복 0, 검증 일관 컴포넌트 1개 추상화 채택
(B) OG·피드 각자 fetch 결합 낮음 SSRF 방어 2벌 — 한쪽 누락 시 보안 구멍 기각
DNS rebinding 방어 (A) connect 후 peer IP 재검증 라이브러리 호환, TOCTOU 최소화 host 재resolve 미차단 잔여(concern) 채택(1차)
(B) custom SocketFactory IP 핀닝 완전 핀닝 구현 복잡·HttpClient 통합 부담 보류(필요시 승격)
본문 sanitize 시점 (A) 저장 시 1회 → 캐시 조회 저비용, 정책변경 재생성 여지 컬럼 1개(body_sanitized_html) 채택
(B) 조회 시 매번 sanitize 컬럼 1개 절약 매 조회 CPU, 캐시 이점 0 기각
피드 임포트 (A) 감지+알림(운영자 수동 작성) 큐레이션 품질·법적/저작권 안전 자동화 아님 채택
(B) 감지 후 자동 포스트 생성 완전 자동 저작권·스팸·중복 위험 기각(비목표)
페이징 (A) keyset(created_at,id) 깊은 페이지 일관·성능 커서 전달 채택
(B) OFFSET/LIMIT 단순 깊은 OFFSET 성능·삽입 시 드리프트 기각
카테고리 권한 (A) ADMIN(/admin/** 인터셉터) 신규 권한키 0, 인프라 재사용 카테고리=ADMIN 한정 채택
(B) 신규 CATEGORY_MANAGE 키 세분화 over-engineering(소비 1곳) 기각

롤아웃 / 마이그레이션

순서

  1. 의존 추가(U-MARKDOWN 선): pom.xml commonmark + jsoup → ./mvnw -o compile 확인. (HTTP 는 RestClient 내장, 의존 0.)
  2. 스키마 적용: docs/board-ddl.sqldb/apply-local-ddl.sh. 기존 데이터 무관(신규 4테이블, 추가만).
  3. 권한 시드 불필요: POST_WRITE 키는 W1 PermissionCatalogVerifier 가 이미 enum→DB 시드(grounding R-A). 운영자가 콘솔에서 특정 SUBADMIN 에게 POST_WRITE 토글 부여하면 그때부터 포스팅 작성 가능.
  4. 코드 배포: 매퍼/서비스/컨트롤러/JSP. SchedulingConfig 활성 → 피드 폴링 시작.
  5. 운영자 카테고리·피드 등록: 콘솔에서 첫 카테고리 + 유니티 피드 소스 등록(SSRF 선검증 통과 URL만).

역호환

  • 신규 경로·테이블·의존만 추가 — 기존 기능 영향 0. RecruitController/리뷰/RBAC 동작 불변.
  • /admin/** 카테고리·피드 경로는 기존 RbacInterceptor /admin/** ADMIN 게이트(InterceptorConfig.java:19)에 자동 포섭 — 인터셉터 수정 불필요(경로 패턴 이미 커버).

롤백

  • 코드 롤백: 컨트롤러/스케줄러 미등록 시 포스팅 기능 비활성. 신규 테이블 잔존해도 무해(비파괴).
  • 의존 롤백: commonmark/jsoup 제거 시 PostMarkdownService 컴파일 깨짐 — 코드 동반 롤백.
  • 스키마 롤백: 추가 전용이라 DROP 없이 잔존 무해. 명시적 DROP 은 별도 maintenance.

AC 매핑

AC 요구 만족 설계 요소 비고
AC-1 POST_WRITE 보유자만 작성, 미보유 403 PostController.create 의 permissionGate.has(session, POST_WRITE) (임시 role 체크 0) S1, D2
AC-2 일반 유저 읽기 전용 + 댓글 없음 공개 GET 만 인증 불요, 작성 게이트, 댓글 엔드포인트·테이블 부재 D3
AC-3 카테고리/피드 운영 ADMIN 전용 /admin/** 경로 → RbacInterceptor isAdmin 게이트 S4, §API
AC-4 본문 XSS 차단(sanitize) PostMarkdownService render(commonmark+jsoup Safelist), script/on*/javascript: 제거 D4, §sanitize
AC-5 상태변경 CSRF 없으면 403 전 쓰기 엔드포인트 CsrfTokens.isValid 선검증 §API 공통
AC-6 유니티 피드 새 글 감지·dedupe·알림 UnityFeedPoller guid dedupe + ux_unique 2차 방어 + 미확인 배지 S3, D6
AC-7 OG/피드 fetch 실패 graceful SsrfSafeFetcher Optional.empty → OG NULL/피드 skip+last_error S1/S3, §SSRF #9
AC-8 게시판 keyset 페이징 listPublishedKeyset(cursor) + idx_posts_*_keyset S2, D8
AC-9 ★SSRF 전수 방어 SsrfSafeFetcher 9항목 체크리스트(scheme/IP/rebinding/redirect/size/timeout/type/graceful) §SSRF
AC-10 SQL ${} 0 신규 매퍼 6종 전부 #{} §파일영향맵

검증 포인트 (verification-advisor 점검 대상)

L레벨 매핑(verification-strategies.md): 권한 게이트/인가 플로우 = L1+L2+L3. ★SSRF 방어 = L1(차단 단위) 필수 + L3(실제 외부 차단 스모크). 신규 매퍼 SQL/alias = L1+L2(dev DB contract). 신규 빈 다수 = full ./mvnw -o test 의무(§30).

시나리오 검증

  • VP-1 (AC-1 게이트, L1+L3): PostControllerTest — POST_WRITE 미보유 세션 작성 → 403, 보유(또는 ADMIN) → 200. 미인증 → 401. L3: 콘솔에서 토글 부여 후 작성 통과.
  • VP-2 (AC-9 ★SSRF, L1 필수): SsrfSafeFetcherTest — http://127.0.0.1, http://169.254.169.254/latest/meta-data, http://10.0.0.1, http://[::1], file:///etc/passwd, IPv4-mapped IPv6, redirect→사설IP, size 초과, timeout 전부 차단/abort. rebinding mock(resolve=공인, connect peer=사설) 차단. L3: 실 외부 URL OG 성공 + 사설 URL 차단 스모크.
  • VP-3 (AC-4 sanitize, L1): PostMarkdownServiceTest — <script>, <img onerror=>, [x](javascript:alert(1)), <iframe> 입력이 출력 HTML 에서 제거됨. 정상 마크다운(볼드/링크/리스트)은 보존.
  • VP-4 (AC-6 피드 dedupe, L1): UnityFeedPollerTest — 동일 guid 재폴링 시 insert 0(신규만), 신규 guid 만 unity_feed_items 추가 + countUnacknowledged 증가.
  • VP-5 (AC-5 CSRF, L1): 전 쓰기 엔드포인트 CSRF 누락 → 403 + mapper 미호출(RecruitController/W3-2 패턴 준용).
  • VP-6 (AC-8 keyset, L1+L2): listPublishedKeyset 커서 페이징 정확성 + alias 매핑(snake→camel) DB-방언 계약.
  • VP-7 (contextLoads, L1): BibimbapApplicationTests 에 신규 매퍼 6종·SsrfSafeFetcher·서비스 @MockBean 등록 후 PASS(§30).

집합 전수 체크 AC (시점·표현 self-audit 적용)

self-audit (시점 안정성): 아래 카운트는 본 워크스트림이 신규 생성하는 정적 산출물 또는 DDL/체크리스트 항목으로, verification 시점까지 본 워크스트림 외 변경 주체가 없다(시점 안정). 자기 트리처럼 계속 증가하는 대상 아님. self-audit (표현 견고성): 단일 리터럴 grep 취약성을 피해 (a) DDL 객체 카운트는 CREATE TABLE IF NOT EXISTS 고정 패턴 + 테이블명 집합, (b) SSRF/sanitize 는 의미 불변식 + 수동 판정으로 앵커한다.

  • AC-T1 신규 board 테이블 전수 4건docs/board-ddl.sqlCREATE TABLE IF NOT EXISTS 4건(post_categories/posts/unity_feed_sources/unity_feed_items) AND schema.sql 동기 4건: grep -c 'CREATE TABLE IF NOT EXISTS' docs/board-ddl.sql == 4. 누락·오타 동시 검출.
  • AC-T2 ★SSRF 방어 체크리스트 전수 9항목 — SsrfSafeFetcherTest 가 §SSRF 9항목(scheme/resolve/사설·루프백·링크로컬·메타데이터/rebinding/redirect/size/timeout/Content-Type/graceful) 전부에 대응하는 차단 테스트를 보유. 검증: 9항목 각각 최소 1 테스트 메서드 존재(수동 매핑 — 리터럴 grep 아님, 의미 단위). 1항목이라도 미커버 시 SSRF 보안 구멍 → FAIL(이 전수 AC 가 보안 핵심 가드).
  • AC-T3 쓰기 엔드포인트 CSRF 가드 전수 — PostController/PostAdminController/UnityFeedAdminController 의 모든 상태변경(@PostMapping) 핸들러에 CsrfTokens.isValid 선검증 존재: 각 컨트롤러 내 @PostMapping 수 == CsrfTokens.isValid 호출 수. 핸들러 추가 시 가드 누락 동시 검출.
  • AC-T4 신규 매퍼 SQL ${} 0건 — 신규 매퍼 6종(PostsMapper/PostCategoriesMapper/UnityFeedSourcesMapper/UnityFeedItemsMapper + 향후 추가분)에 ${ 매치 0: grep -rc '\${' <매퍼 파일들> == 0 (AC-10).
  • AC-T5 POST_WRITE enforcement 연결 확인 — 작성/수정/삭제 핸들러(쓰기 3종)가 permissionGate.has(...POST_WRITE...) 호출: PostController 의 POST_WRITE 게이트 호출 수 == 작성·수정·삭제 핸들러 수(3). 게이트 누락 핸들러(임시 개방) 동시 검출 — 보안 가드.

잔여 오픈 질문

없음(0). 확정 결정 D1~D9 고정. 두 보안 난제(마크다운 sanitize · SSRF 방어)는 본 설계가 구체 메커니즘(commonmark+jsoup Safelist / SsrfSafeFetcher 9항목 체크리스트)으로 확정. SSRF rebinding 구현 라이브러리 제약·신규 빈 full-test·DB-방언 L2·pom 의존 추가·시그니처 inflate 위험·@EnableScheduling 활성은 오픈 질문이 아니라 구현 단계 점검 항목으로 concerns 에 이관.