KISA 제보 취약점(KVE-2026-1914/1919/2019/2029/2041/2042/2043/2044)과 그 수정 과정에서 드러난 자격증명 전송로 결함을 함께 해소한다. globalHeaders 는 데이터소스와 apiCall 핸들러에만 적용되는데, 코어 ApiClient 를 직접 부르는 경로들이 그 사실을 모른 채 게이트된 엔드포인트를 호출하고 있었다. 서버는 정당한 사용자를 거부하고 화면은 이미 버튼을 내준 뒤라, 예외도 로그도 없이 그 자리만 비는 형태로만 드러났다. 전송로 10축을 전수 열거해 6건을 고치고, 같은 실수가 반복되지 않도록 규정과 coverage 에 등재했다. 아웃바운드 프록시가 사이트 자기 자신으로 가는 내부 요청까지 가로채 저장이 수십 초씩 걸리던 문제도 함께 고쳤다. 실패가 폴백으로 삼켜져 화면에는 지연으로만 나타났다.
16 KiB
그누보드7 게시판 모듈 — 에이전트 가이드
이 문서는 이 모듈을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 README.md 를 보세요.
TL;DR (5초 요약)
1. 유형: 모듈 (sirsoft-board) — 게시판·게시글·댓글·신고·게시판별 알림설정 도메인. 관리자 CRUD + 공개 API 만 소유하고, 방문자 화면(목록/상세/글쓰기)은 템플릿이 그린다
2. 확장 방식: 발행 훅 90개(전량 action/filter) — 새 콘텐츠 타입 연동은 `EcommerceInquiryHookListener` 식 필터 훅 위임 패턴을 참고
3. 건드리면 안 되는 것: 비밀글 게이팅(`SecretContentGate`)을 우회하는 신규 조회 경로, `chunk()`(OFFSET) 로 삭제·갱신 순회, count 컬럼(posts_count 등) 직접 갱신
4. 작업 위치: `modules/_bundled/sirsoft-board` — 활성 디렉토리 직접 수정 금지
5. 반영: `php artisan module:update sirsoft-board --force`
1. 이 확장은 무엇인가
게시판·게시글·댓글·신고·게시판별 알림설정을 소유하는 콘텐츠 도메인 모듈입니다. 운영자가
/admin/boards에서 게시판을 자유롭게 생성(게시판 유형 basic/gallery/card 선택, 비밀글·
답변형·트리거 기반 관리자 알림 등 게시판별 세부 설정)하면, 그 게시판마다 동적 권한
(sirsoft-board.{slug}.*)·역할({slug}.manager/{slug}.step)·메뉴가 자동 생성됩니다 —
게시판 하나하나가 사실상 독립된 작은 확장처럼 자기 권한 체계를 갖습니다.
소유 범위는 관리자 CRUD + 공개 API 까지입니다. 방문자가 실제로 보는 목록/상세/글쓰기
화면은 이 모듈이 그리지 않습니다 — resources/layouts/ 46개가 전부 admin 그룹인 것이
그 증거입니다. 공개 조회·작성은 routes/api.php 의 boards.* 라우트(비회원도 접근하는
optional.sanctum)로만 나가고, 그 API 를 소비해 실제 화면을 그리는 것은 템플릿(sirsoft-basic)
쪽 책임입니다. 그래서 이 모듈에 의존하는 확장은 지금 템플릿 하나뿐입니다 — 새 방문자 화면이
필요하면 이 모듈이 아니라 그 화면을 쓰는 템플릿/모듈 쪽에 레이아웃을 추가합니다.
설계 원칙: 게시판은 이커머스 문의·후기처럼 "게시판을 흉내 낸" 다른 도메인의 콘텐츠 저장소로도
쓰입니다. 그래서 이 모듈을 다른 도메인이 재사용하는 지점은 코드 결합이 아니라 필터 훅
위임(EcommerceInquiryHookListener)입니다 — 이커머스 모듈이 자기 문의 게시판을 만들 때
sirsoft-board 를 직접 use 하지 않고, board 쪽 흐름 중간에서 자기 로직으로 갈아끼웁니다.
의도적으로 하지 않는 것: 게시판 콘텐츠에 대한 실시간 브로드캐스트(WebSocket)는 제공하지
않습니다 — 알림은 전부 코어 GenericNotification(mail/database) 경유이며, 새로고침 없이
갱신되는 목록 같은 기능은 이 모듈의 범위 밖입니다. 또한 비밀글 판정은 이 모듈이 API 계층에서
전량 서버측으로 강제합니다(SecretContentGate, KVE-2026-1914) — 클라이언트가 비밀글 여부를
판단해 화면만 가리는 방식은 쓰지 않습니다.
2. 디렉토리 지도
| 경로 | 역할 | 수정 시 필요한 절차 |
|---|---|---|
module.json |
manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
module.php |
진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 ext:docgen 재실행 + 코어 최소 버전 검토 |
src/Http/Controllers/ |
컨트롤러 | API 표면 변경 시 api:docgen 재실행 |
src/Http/Requests/ |
FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
src/Http/Resources/ |
API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 |
src/Services/ |
비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
src/Repositories/ |
데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
src/Models/ |
Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 |
src/Listeners/ |
훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
src/Enums/ |
상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 |
src/routes/ |
라우트 | 모든 라우트에 name() 필수 |
src/lang/ |
백엔드 다국어 | ko·en 동시 반영 + 번들 ja 팩 동기화 |
database/migrations/ |
마이그레이션 | 한국어 comment + down() 필수, 기설치본은 업그레이드 스텝으로 백필 |
database/seeders/ |
시더 | composer autoload 등록 + extension:update-autoload |
upgrades/ |
업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
resources/layouts/ |
레이아웃 JSON | php artisan module:update sirsoft-board --force (빌드 불필요) |
resources/routes/ |
라우트 → 레이아웃 매핑 (분할) | php artisan module:update sirsoft-board --force |
resources/js/ |
프론트 엔트리·핸들러 | php artisan module:build → php artisan module:update sirsoft-board --force |
resources/extensions/ |
다른 확장 레이아웃에 주입하는 조각 | php artisan module:update sirsoft-board --force |
editor-spec.json |
레이아웃 편집기 스펙 | php artisan module:update sirsoft-board --force |
config/ |
확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
tests/ |
테스트 | 변경 범위만 필터 실행 |
CHANGELOG.md |
변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
docs/ |
개발자 문서 | 표면 변경 시 php artisan ext:docgen 재실행 |
3. 핵심 흐름
게시판 생성: Admin\BoardController → StoreBoardRequest(다국어 이름·slug 유일성·게시판
유형 검증) → BoardService::createBoard() → BoardRepository 로 boards 행 생성 후, 같은
트랜잭션 안에서 BoardPermissionService(동적 권한 3계층 생성) → 역할(manager/step) 생성 →
관리자 메뉴 등록까지 연쇄 실행됩니다. 이 연쇄 때문에 getDynamicPermissionIdentifiers() /
getDynamicRoleIdentifiers() / getDynamicMenuSlugs() 가 boards 테이블을 다시 조회해
전수를 재구성합니다 — 게시판 삭제·정리(clean-up) 시 "stale 판정"의 기준이 이 세 메서드입니다.
게시글 작성 → 비밀글 게이팅: User\PostController → StorePostRequest
(sirsoft-board.post.store_validation_rules 필터로 게시판별 커스텀 규칙 추가) →
PostService::createPost() (before_create→filter_create_data→after_create 훅 순서,
sirsoft-board.post.user_create IDV 정책 게이트 통과 후) → PostRepository. 조회 시에는
PostResource 가 SecretContentGate 로 비밀글 여부·열람 권한을 판정해 content/title/
reply/attachments 를 마스킹합니다 — 이 판정은 댓글 목록·첨부 다운로드에도 개별
재적용됩니다(부모 글에서 한 번 판정하고 끝나지 않음, KVE-2026-1914).
신고 접수 → 처리: User\ReportController → StoreReportRequest → ReportService::create()
(before_create→filter_create_data→after_create) → 신고 접수 관리자 알림 발송. 관리자가
Admin\ReportController 에서 처리(블라인드/삭제/복원)하면 ReportService 가 대상 게시글/댓글
서비스(PostService/CommentService)를 호출해 실제 콘텐츠 상태를 바꾸고, 그 결과가
report_action/post_action 알림으로 원 작성자에게 통지됩니다. 신고 삭제·일괄 처리는
관리자 민감 작업 IDV 정책(report.delete/report.bulk_action)이 걸려 있습니다.
4. 확장점
| 확장점 | 수 | 상세 |
|---|---|---|
| 발행 훅 | 90개 | 발행 훅 |
| 구독 훅 | 77개 | 구독 훅 |
| 훅 리스너 | 16개 | 훅 리스너 |
| 레이아웃 확장 | 5개 | 레이아웃 확장 |
| 미들웨어 | 0개 | 미들웨어 |
| 브로드캐스트 채널 | 0개 | 브로드캐스트 채널 |
| 스케줄 | 2개 | 스케줄 |
| 알림 정의 | 7개 | 알림 정의 |
발행 훅 90개는 전부 action/filter 이며 코어 브로드캐스트 채널은 쓰지 않습니다(구독 훅
목록의 이커머스 8개가 예로 보여주듯, 콘텐츠를 다른 도메인이 재사용하는 자리는 훅이지 상속이
아닙니다). 새 게시판 세부 검증 규칙을 추가하려면 board.store_validation_rules/
update_validation_rules filter 를, 게시판 생성 후 부가 리소스를 함께 만들려면
board.after_create action 을 잡습니다. 게시글/댓글의 before_*→filter_*_data→after_*
3단 패턴은 전 도메인(board/comment/report/attachment)에 동일하게 반복되므로, 하나를 배우면
나머지에 그대로 적용됩니다. 훅 리스너 16개 중 EcommerceInquiryHookListener 는 이 모듈의
CRUD 흐름 자체를 자기 도메인으로 대체하는 가장 무거운 형태의 확장 사례입니다 — 새로운
"게시판을 흉내 낸 도메인"을 만들 때 참고할 선례입니다.
5. 수정 시 동반 의무
_bundled에서만 수정하고php artisan module:update sirsoft-board --force로 반영- manifest version 상향 시
package.json·package-lock.json·composer.json동기화 + CHANGELOG 기재 - 스키마 변경 시 마이그레이션(한국어 comment +
down()) + 기설치본 백필용 업그레이드 스텝 - 발행 훅 추가·이름 변경 시
php artisan ext:docgen재실행 (구독하는 확장의 계약이 바뀝니다) - API 표면 변경 시
php artisan api:docgen --scope=module:sirsoft-board재실행 +docs/api/**갱신 - 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
- 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
- 비밀글이 관여하는 새 조회 경로(댓글·첨부·검색 결과 등)를 추가할 때
SecretContentGate재적용 (KVE-2026-1914 — 부모에서 한 번 판정하고 끝나지 않는다) boards/board_posts/board_comments의 count 컬럼(posts_count/comments_count/replies_count/attachments_count)은 훅 리스너(*CountSyncListener)가 갱신 — Service 에서 직접 증감 금지- 게시판 삭제 시
getDynamicPermissionIdentifiers()/getDynamicRoleIdentifiers()/getDynamicMenuSlugs()가 최신 상태를 반영하도록 동일 트랜잭션에서 정리 (module.phpuninstall()의chunkById패턴 참고 — OFFSET 순회로 삭제 금지) - 레이아웃·컴포넌트·
data_source를 건드렸다면docs/editor-spec.md의 동반 의무 표를 따라editor-spec.json을 함께 갱신 — 샘플이 없는data_source는 편집기 캔버스에서만 빈 화면이 되고 실제 화면은 정상이라 오류도 경고도 남지 않는다. 반영은php artisan module:update sirsoft-board --force
6. 금지 패턴
| 금지 | 올바른 사용 | 이유 |
|---|---|---|
비밀글 상세만 SecretContentGate 로 막고 댓글 목록·첨부 다운로드는 그대로 노출 |
댓글 목록은 부모 글 비밀 여부 확인 후 빈 배열, 첨부 서빙은 403 — 두 경로 모두 게이트 재적용 | 상세 API 하나만 막으면 같은 정보가 형제 엔드포인트로 새어나간다 (KVE-2026-1914) |
게시글/댓글 삭제·복원 시 posts_count/comments_count 를 Service 에서 increment()/decrement() |
훅(after_create/after_delete/after_restore) 리스너의 count 동기화에 맡긴다 |
직접 증감은 훅 기반 동기화와 이중 집계되어 카운트가 어긋난다 |
| 게시판별 동적 권한/역할을 board 삭제와 별도 시점에 정리 | BoardService 삭제 흐름 안에서 즉시 정리(또는 stale cleanup 이 getDynamicPermissionIdentifiers() 로 정확히 판정하게 유지) |
정리가 늦으면 존재하지 않는 게시판의 권한이 역할에 남아 관리 화면에 유령 항목이 뜬다 |
새 콘텐츠 타입을 board 코드에 if ($type === 'inquiry') 로 직접 분기 |
EcommerceInquiryHookListener 처럼 필터 훅으로 CRUD 를 위임 |
board 코드가 알지 못하는 도메인이 늘어날수록 분기가 무한 증식한다 |
레이아웃 표현식에서 누산기에 멤버 대입 (grouped[k] = [] · .push()) 하거나 rest 구조분해 (const { a, ...rest } = obj) 사용 |
map / filter / reduce(Object.assign) / Object.fromEntries 로 누산 없이 구성 |
표현식 평가기가 그 구문을 거부해 식 전체가 평가되지 않는다. iteration.source 면 배열이 아니게 되어 그 목록이 통째로 렌더되지 않고, apiCall body 면 원문 문자열이 그대로 전송된다 — 둘 다 오류도 경고도 남지 않는다 (일괄 적용 확인 창에서 실제 발생) |
일괄 적용 대상 설정 키를 sectionMap 에만 추가 |
같은 키의 bulk_apply.field_labels 라벨을 ko/en(+번들 ja)에 함께 추가 |
라벨이 없으면 확인 창에 원시 다국어 키가 그대로 노출된다 |
7. 테스트 실행
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 157개 | modules/_bundled/sirsoft-board/tests |
| Vitest | 34개 | vitest.config.ts |
| Playwright | 26개 | tests/Playwright |
| 시나리오 매니페스트 | 34개 | tests/scenarios |
기저 TestCase: tests/ModuleTestCase.php — 확장 테스트는 이 클래스를 상속합니다 (Tests\TestCase 직접 상속 금지).
# PHPUnit (변경 범위만) (Bash)
php vendor/bin/phpunit modules/_bundled/sirsoft-board/tests --filter='<대상클래스>'
# Vitest (확장 디렉토리에서) (PowerShell)
cd modules/_bundled/sirsoft-board && powershell -Command "npm run test:run -- <대상>"
# Playwright E2E (확장 디렉토리에서) (Bash)
cd modules/_bundled/sirsoft-board && npm run test:e2e -- specs/<대상>.spec.ts
무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
8. 문서 목차
| 문서 | 내용 | 상태 |
|---|---|---|
| docs/README.md | 문서 통합 목차와 실측 집계 | ✅ |
| docs/architecture.md | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
| docs/extension-points.md | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
| docs/data-model.md | 모델·소유 테이블·마이그레이션·Enum | ✅ |
| docs/settings.md | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
| docs/frontend.md | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
| docs/editor-spec.md | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 | ✅ |
| docs/api/ | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
| CHANGELOG.md | 변경 이력 | ✅ |