fix(core,board,ecommerce,payments,basic): KVE-2026 보안 게이트 6묶음 + 자격증명 전송로 정합

KISA 제보 취약점(KVE-2026-1914/1919/2019/2029/2041/2042/2043/2044)과
그 수정 과정에서 드러난 자격증명 전송로 결함을 함께 해소한다.

globalHeaders 는 데이터소스와 apiCall 핸들러에만 적용되는데, 코어 ApiClient 를
직접 부르는 경로들이 그 사실을 모른 채 게이트된 엔드포인트를 호출하고 있었다.
서버는 정당한 사용자를 거부하고 화면은 이미 버튼을 내준 뒤라, 예외도 로그도 없이
그 자리만 비는 형태로만 드러났다. 전송로 10축을 전수 열거해 6건을 고치고,
같은 실수가 반복되지 않도록 규정과 coverage 에 등재했다.

아웃바운드 프록시가 사이트 자기 자신으로 가는 내부 요청까지 가로채 저장이 수십 초씩
걸리던 문제도 함께 고쳤다. 실패가 폴백으로 삼켜져 화면에는 지연으로만 나타났다.
This commit is contained in:
HeuJung
2026-09-06 00:42:29 +09:00
parent 8f072895ff
commit 9cf9c3ff8c
104 changed files with 4522 additions and 412 deletions
+14
View File
@@ -495,6 +495,20 @@ catch-all shadow 는 보호처럼 보인다는 점이 위험하다. 가려진
> 상세: [storage-driver.md](docs/extension/storage-driver.md) "제3자 라이브러리에 절대 경로를 넘길 때", [service-repository.md](docs/backend/service-repository.md) "서비스가 제3자 라이브러리를 붙일 때"
### 직접 전송로는 자격증명을 스스로 싣는다
레이아웃의 `globalHeaders` 는 **데이터소스(DataSourceManager)와 `apiCall` 핸들러(ActionDispatcher)** 에만 적용된다. 코어 ApiClient(`G7Core.api.*`)를 직접 부르거나 `fetch` 를 쓰는 경로는 그 배선을 타지 않아 `Authorization` 과 `Accept-Language` 만 실린다. 게이트된 엔드포인트를 그렇게 부르면 서버는 정당한 사용자를 거부하는데, 화면은 버튼·썸네일을 이미 내준 뒤라 **예외도 콘솔 오류도 없이 그 자리만 비는 것**이 유일한 증상이다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 게이트된 엔드포인트를 `G7Core.api.*` / `fetch` 로 부르며 자격증명 헤더를 생략 | 호출부가 직접 싣는다 — 비밀글 첨부는 `X-Board-Secret-View-Token`, 비회원 주문은 `X-Guest-Order-Token` |
| 자격증명이 없을 때 조용히 `return null` 로 이탈 | 회원/비회원 두 경로를 모두 구성한다 — 한쪽을 비우면 서버가 지원하는 기능이 도달 불가로만 남는다 |
| 헤더 구성을 호출부마다 복제 | 확장·템플릿 안에 단일 지점(`secretContentHeaders()` / `buildOrderRequestHeaders()`)을 두고 경유 |
| `<img src>` 에 헤더를 실으려 시도 | 이미지 태그는 헤더를 실을 수 없다 — 한시 서명 URL 을 발급하거나 blob 으로 받아 그린다 |
| 자격증명을 GET 쿼리 문자열로 전달 | 헤더로 보낸다 — 쿼리는 웹서버 접근 기록과 `Referer` 에 그대로 남는다 |
같은 기능을 여러 확장이 제공할 때는 **형제 구현의 강도가 갈리지 않는지** 확인한다. 서버가 비회원을 지원하는데 프론트 한쪽만 토큰을 보내면, 나머지 확장에서는 그 서버 기능이 존재하지만 도달 불가인 상태로 남는다.
### 확장·템플릿 구동 에셋은 자체 제공한다
브라우저가 화면을 그리기 위해 제3자 CDN 에 도달해야 하면, 그 도달 실패는 **예외도 로그도 남기지 않고 화면 기능만 조용히 사라진다.** 폐쇄망·방화벽·광고차단기에서 재현되며 자체 서버 로그에 흔적이 없어 운영자가 원인을 특정할 수 없다.
+5
View File
@@ -45,10 +45,15 @@
- 주소에 마침표처럼 보이는 특수문자(전각·표의문자 마침표 등)를 섞으면 서버가 내부 주소로 요청을 보내도록 유도할 수 있던 문제를 수정했습니다. 검사할 때와 실제로 연결할 때 주소를 읽는 방식이 달라 생긴 문제로, 이제 두 시점이 같은 방식으로 주소를 해석합니다. 스케줄의 URL 호출, 주소로 언어팩 설치, 외부 배송비 계산 API 등 서버가 대신 외부로 요청을 보내는 모든 지점이 함께 보호됩니다. 정상적인 국제화 도메인(한글·일본어 도메인 등)은 그대로 사용할 수 있습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2010)
- 2단계 인증을 켠 상태에서 계정 잠금을 우회할 수 있던 문제를 수정했습니다. 잠기기 전에 받아 둔 인증 단계를 잠긴 뒤에 마치면 로그인이 되고 잠금까지 풀렸습니다. 이제 인증번호 확인 단계에서도 잠금 여부를 다시 확인하며, 잠긴 계정은 로그인 화면과 동일한 안내를 받습니다. 잠긴 계정은 기존 로그인 상태로도 인증 기간을 연장할 수 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2011)
- 디버그 도구 엔드포인트 전체에 디버그 모드 확인을 적용했습니다. 종전에는 8개 중 3개에만 확인이 있어, 디버그 모드가 꺼진 운영 사이트에서도 로그인 없이 요청하면 저장된 디버그 데이터를 통째로 삭제할 수 있었습니다. 이제 확인은 개별 엔드포인트가 아니라 디버그 도구 전체에 한 번에 걸리므로, 앞으로 추가되는 엔드포인트도 자동으로 보호됩니다. (#128 @glitter-gim 님께서 건의해주셨습니다.)
- 본인인증을 마친 뒤 받은 확인값이 유효기간을 넘겨도 계속 통했던 문제를 수정했습니다. 그래서 오래전에 받아 둔 값 하나로 회원탈퇴처럼 본인인증이 필요한 작업을 언제든 다시 수행할 수 있었습니다. 이제 유효기간이 지난 확인값은 받아들이지 않으며, 그 경우 종전처럼 본인인증을 다시 요구합니다. 본인인증이 걸린 모든 지점(회원가입·비밀번호 재설정·정책이 지정한 화면 포함)에 함께 적용됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2029)
- 설치 마법사에 입력하는 사이트 이름·사이트 주소·업데이트 저장소 주소에 줄바꿈을 섞어 설치 설정 파일에 임의의 설정 항목을 끼워 넣을 수 있던 문제를 수정했습니다. 이제 설치 설정 파일에 기록되는 모든 입력이 같은 검사를 지나며, 줄바꿈이 섞이면 해당 항목에 오류를 표시하고 저장하지 않습니다. 주소 항목은 형식까지 확인합니다. 이 문제는 설치가 끝나지 않은 사이트에서만 성립합니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2042)
- 설치 마법사의 PHP·Composer 실행 경로 입력에서 네트워크 공유 경로(`\\서버\공유`)와 임의 이름의 `.phar` 파일을 지정할 수 있던 문제를 수정했습니다. 공격자가 지정한 원격 파일이나 미리 올려 둔 아카이브가 설치 과정에서 실행될 수 있었습니다. 이제 로컬 절대경로만 허용하고 Composer 자리는 composer 계열 이름만 받습니다. 이 문제는 설치가 끝나지 않은 사이트에서만 성립합니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2043)
- 웹소켓 채널 인증 주소가 로그인 확인 없는 형태로 하나 더 등록되어 있던 문제를 수정했습니다. 화면에서는 쓰이지 않는 주소였지만 「웹소켓 사용 안 함」 설정을 우회할 수 있었습니다. 이제 로그인과 설정을 함께 확인하는 주소 하나만 남으며, 채널별 권한 확인은 종전과 동일하게 동작합니다. (#128 @glitter-gim 님께서 건의해주셨습니다.)
### Fixed
- 아웃바운드 프록시를 지정한 사이트에서 글·상품 저장이 수십 초씩 걸리던 문제를 수정했습니다. 사이트가 자기 자신에게 보내는 내부 요청까지 프록시로 나가고 있었고, 프록시가 응답하지 않으면 그 요청이 연결 실패 시각까지 매달렸습니다. 실패는 화면에 드러나지 않고 저장만 느려져 원인을 알기 어려웠습니다. 이제 사이트 자기 주소와 로컬 주소는 운영자가 예외 목록에 적지 않아도 항상 프록시를 거치지 않습니다.
- 설치 마법사에서 PHP·Composer 경로가 거부될 때 안내 문구가 실제 허용 범위와 달라, 안내대로 고쳐도 계속 거부되던 문제를 수정했습니다. 이제 파일 이름 조건과 사용할 수 없는 경로 형태(네트워크 경로·scheme:// 등)를 문구에 함께 안내합니다.
- 같은 스크립트를 거의 동시에 두 번 불러오면, 두 번째 요청이 첫 번째 로드가 끝나기 전에 완료된 것으로 처리되어 그 뒤 동작이 아무 반응 없이 끝나던 문제를 수정했습니다. 이제 두 요청 모두 실제 로드가 끝난 뒤에 이어집니다.
- 디버그 모드에서도 디버그 도구의 조회 주소(상태·액션 이력·캐시·변경 감지)가 사용자 화면에 가려져 응답하지 못하던 문제를 수정했습니다. 사이트가 미리 예약해 둔 주소는 사용자 화면 처리에서 제외됩니다. (#128 @glitter-gim 님께서 건의해주셨습니다.)
- 「자산 주소에 확장자 사용 안 함」 설정을 켠 사이트에서 글꼴과 국기 아이콘이 표시되지 않던 문제를 수정했습니다. 스타일시트가 그 안에서 상대 경로로 가리키던 글꼴·이미지 파일을 브라우저가 엉뚱한 주소로 찾아 불러오지 못했고, 화면에는 기본 서체와 빈 아이콘만 보였습니다. 이제 스타일시트를 내보낼 때 그 경로를 올바른 주소로 바꿔 전달합니다.
@@ -89,7 +89,13 @@ class IdentityVerificationLogRepository implements IdentityVerificationLogReposi
}
/**
* 미소비된 검증 토큰으로 로그를 조회합니다.
* 미소비·미만료 검증 토큰으로 로그를 조회합니다.
*
* 만료 술어(expires_at > now)는 이 지점이 단일 관문이다. 정책 미들웨어·정책 서비스·
* 회원가입/비밀번호재설정 리스너·IdvTokenRule 이 모두 이 메서드를 경유하므로
* 호출부마다 만료 검사를 중복해 두지 않는다(한쪽 누락 시 그 경로가 우회로가 된다).
* expires_at 이 비어 있는 로그도 반환하지 않는다 — 모든 provider 가 challenge 생성 시
* expires_at 을 세팅하므로 NULL 은 정상 발급 산물이 아니다.
*
* @param string $token 검증 토큰
* @param string $purpose 본인인증 목적
@@ -102,6 +108,7 @@ class IdentityVerificationLogRepository implements IdentityVerificationLogReposi
->where('purpose', $purpose)
->where('status', IdentityVerificationStatus::Verified->value)
->whereNull('consumed_at')
->where('expires_at', '>', Carbon::now())
->first();
}
+39 -12
View File
@@ -163,29 +163,56 @@ final class OutboundProxy
* 빈 항목과 중복을 걸러내고 순번을 다시 매깁니다 — 비연속 키는 JSON 직렬화 시 객체가 되어
* Guzzle 이 목록으로 읽지 못합니다.
*
* 목록 맨 앞에는 사이트 자기 호스트와 루프백이 항상 들어갑니다 (selfHosts 참조).
*
* @param mixed $value 원본 예외 목록
* @return array<int, string> 정규화된 호스트 목록
*/
private static function normalizeBypass(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$hosts = self::selfHosts();
$hosts = [];
if (is_array($value)) {
foreach ($value as $host) {
if (! is_string($host)) {
continue;
}
foreach ($value as $host) {
if (! is_string($host)) {
continue;
}
$host = trim($host);
$host = trim($host);
if ($host !== '') {
$hosts[] = $host;
if ($host !== '') {
$hosts[] = $host;
}
}
}
return array_values(array_unique($hosts));
}
/**
* 프록시를 거치지 않아야 하는 자기 자신 호스트 목록을 돌려줍니다.
*
* 아웃바운드 프록시는 **바깥으로 나가는** 트래픽의 출발지를 지정하려는 장치입니다. 그런데
* 사이트는 자기 자신에게도 HTTP 를 겁니다 — SEO 렌더러가 데이터소스를 부를 때, API 문서
* 생성기가 엔드포인트를 탐침할 때가 그렇습니다. 그 요청까지 프록시로 내보내면 프록시가
* 응답하지 않을 때 호출마다 연결 실패 시각까지 매달리고, 실패는 폴백으로 삼켜지므로
* 예외도 오류 화면도 없이 저장 요청만 느려집니다. 운영자에게는 원인을 알 단서가 없습니다.
*
* 자기 자신으로 가는 요청을 프록시로 보내는 것은 어떤 구성에서도 의도가 아니므로, 운영자가
* 예외 목록에 적었는지와 무관하게 항상 제외합니다.
*
* @return array<int, string> 항상 우회할 호스트 목록
*/
private static function selfHosts(): array
{
$hosts = ['localhost', '127.0.0.1', '::1'];
$appHost = parse_url((string) config('app.url'), PHP_URL_HOST);
if (is_string($appHost) && $appHost !== '') {
$hosts[] = $appHost;
}
return $hosts;
}
}
@@ -9,6 +9,7 @@
### Added
- 게시판 환경설정 > 일괄 적용 확인 창의 「금지어」 항목 이름 일본어 번역을 추가했습니다 (`admin/settings.bulk_apply.field_labels.blocked_keywords`).
- 열람 권한이 없는 비밀글에 댓글·답글을 작성하려 할 때 표시되는 안내 문구의 일본어 번역을 추가했습니다 (`messages.comment.post_secret`, `validation.comment.post_id.secret`, `validation.post.parent_id.secret`).
## [1.0.3] - 2026-08-19
@@ -104,6 +104,7 @@ return [
'verify_password_failed' => 'パスワードの確認に失敗しました。',
'post_blinded' => 'ブロック処理された投稿にはコメントを作成できません。',
'post_deleted' => '削除された投稿にはコメントを作成できません。',
'post_secret' => '閲覧権限のない秘密投稿にはコメントを作成できません。',
],
'comments' => [
'comments_disabled' => 'この掲示板はコメント機能が無効化されています。',
@@ -215,6 +215,7 @@ return [
'not_found' => '元の投稿が見つかりません。',
'blinded' => '非表示にされた投稿には返信を作成することはできません。',
'deleted' => '削除された投稿には返信を作成することはできません。',
'secret' => '閲覧権限のない秘密投稿には返信を作成することはできません。',
'depth_exceeded' => 'この掲示板は返信を:max段階までのみ許可しています。',
'notice_not_allowed' => 'お知らせには返信を作成することはできません。',
],
@@ -385,6 +386,7 @@ return [
'not_found' => '投稿が見つかりません。',
'blinded' => 'ブラインド処理された投稿にはコメントを作成できません。',
'deleted' => '削除された投稿にはコメントを作成できません。',
'secret' => '閲覧権限のない秘密投稿にはコメントを作成できません。',
],
'parent_id' => [
'exists' => '存在しないコメントです。',
+2 -2
View File
@@ -156,8 +156,8 @@ CRUD 흐름 자체를 **자기 도메인으로 대체**하는 가장 무거운
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 155개 | `modules/_bundled/sirsoft-board/tests` |
| Vitest | 33개 | `vitest.config.ts` |
| PHPUnit | 157개 | `modules/_bundled/sirsoft-board/tests` |
| Vitest | 34개 | `vitest.config.ts` |
| Playwright | 26개 | `tests/Playwright` |
| 시나리오 매니페스트 | 34개 | `tests/scenarios` |
@@ -14,6 +14,12 @@
### Fixed
- 비회원 글을 비밀번호로 확인하고 들어가도 저장할 때 「게시글 수정 권한이 없습니다」로 거부되던 문제를 수정했습니다. 본인 확인에 쓰는 값이 한 번만 쓸 수 있는 것인데 수정 화면을 불러오는 과정에서 먼저 소모되어, 정작 저장할 때는 남아 있지 않았습니다. 비밀번호를 다시 받을 방법도 화면에 없어 그 글은 수정 자체가 불가능했습니다. 이제 화면을 불러오는 단계에서는 확인만 하고, 실제로 저장하거나 삭제할 때 한 번 사용합니다.
- 비밀번호를 입력해 비밀글을 열면 댓글 목록이 보이지 않던 문제를 수정했습니다. 「댓글 N」 표시는 그대로인데 그 아래가 비어 있었고, 댓글을 하나 쓰면 그때서야 기존 댓글이 함께 나타났습니다. 비밀번호 확인 응답이 게시글 상세와 같은 내용을 담지 않아 화면이 목록을 잃어버린 것이며, 이제 두 경로가 같은 내용을 돌려줍니다.
- 삭제된 비밀글이 비밀번호만 알면 열리던 문제를 수정했습니다. 게시글 상세 조회는 삭제된 글에 게시판 관리 권한을 요구하는데 비밀번호 확인 경로에는 그 확인이 없어, 목록에서 사라진 글의 내용을 주소를 아는 사람이 볼 수 있었습니다. 이제 두 경로가 같은 기준을 적용하며, 게시판 관리 권한자는 종전처럼 볼 수 있습니다.
- 비회원이 자기 글을 수정하면 그 글의 비밀번호가 암호화되지 않은 상태로 저장되던 문제를 수정했습니다. 수정 화면에서 본인 확인용으로 입력한 비밀번호가 저장 대상으로 잘못 흘러 기존 암호화 값을 덮었고, 그 뒤로는 본인 확인이 항상 실패해 작성자가 자기 글을 다시 수정하거나 삭제할 수 없었습니다. 이제 본인 확인용 입력은 저장되지 않으며, 이미 이 문제로 평문이 된 게시글의 비밀번호는 업데이트 시 자동으로 암호화되어 복구됩니다(작성자가 알던 비밀번호는 그대로 사용됩니다).
- 비밀글을 볼 수 없는 사용자가 그 비밀글에 댓글·답글을 달거나 신고할 수 있던 문제를 수정했습니다. 종전에는 원문만 가려졌을 뿐 하위 글쓰기에는 열람 권한을 다시 확인하지 않아, 내용을 모르는 제3자가 비밀글에 댓글·대댓글·답글을 남기고 그 글과 댓글을 신고할 수 있었습니다. 이제 이 경로 전부에 원문 열람과 같은 기준을 적용합니다. 작성자 본인, 게시판 관리자, 비밀글 읽기 권한을 가진 사용자는 종전처럼 그대로 작성할 수 있고 일반 게시글은 영향받지 않습니다. 비밀번호를 입력해 원문을 연 사용자도 종전처럼 댓글·답글·신고를 남길 수 있습니다 — 비회원이 쓴 비밀글은 작성자 본인의 유일한 확인 수단이 비밀번호이므로, 그 경로가 열려 있어야 자기 글에 이어서 문의할 수 있습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2044)
- 비회원이 자기 글을 수정하거나 삭제할 때 쓰는 임시 확인값이 인터넷 주소에 실려 오가던 것을 요청 헤더로 옮겼습니다. 종전에는 이 값이 웹서버 접속 기록과 이동 경로 기록에 그대로 남아, 기록을 볼 수 있는 사람이 그 값을 손에 넣을 수 있었습니다. 저장·삭제 요청은 종전 방식도 계속 받으므로 외부 연동은 영향받지 않습니다.
- 게시판 환경설정 > 게시판 설정 > 일괄 적용에서 [일괄 적용] 확인 창의 「적용할 항목」 목록이 아무것도 표시되지 않던 문제를 고쳤습니다. 이제 선택한 항목이 기본/목록/게시글/댓글/첨부파일/알림/권한 설정별로 묶여 표시되므로, 무엇이 적용되는지 확인한 뒤 실행할 수 있습니다.
- 위 목록에서 「금지어」 항목만 이름 대신 내부 식별자가 표시되던 문제를 고쳤습니다.
@@ -21,3 +21,19 @@
<!-- @generated:end -->
## 공통 요청 헤더
### `X-Board-Secret-View-Token`
비밀글에 딸린 하위 콘텐츠를 만드는 요청(댓글 작성, 대댓글 작성, 답글 작성, 게시글·댓글 신고)은
부모 게시글의 원문 열람 권한을 다시 확인합니다. 작성자 본인·게시판 관리자·비밀글 읽기 권한자는
그대로 통과하지만, **비밀번호를 입력해 원문을 연 사용자**는 그 사실이 검증 응답 하나에만
남기 때문에 이 헤더로 넘겨야 합니다.
- 발급: `POST /boards/{slug}/posts/{id}/verify-password` 응답 최상위 `secret_view_token`
- 사용: 이후 그 게시글 관련 요청의 `X-Board-Secret-View-Token` 헤더
- 범위: 발급받은 게시글에만 유효하며 다른 글에는 통하지 않습니다
- 수명: 소비되지 않고 `secret_view_expires_at` 까지 여러 번 쓸 수 있습니다
- 미제시: 위 경로에서 `422` 와 함께 "열람 권한이 없는 비밀글" 문구가 반환됩니다.
비밀글이 아닌 게시글에서는 이 헤더가 평가되지 않습니다.
@@ -3215,6 +3215,8 @@ HTTP/1.1 200
**설명** 게시글 작성/수정 폼의 초기 입력값을 반환합니다. `auth:sanctum` + `sirsoft-board.{slug}.posts.write` 권한이 필요하며, 쿼리 파라미터로 모드가 분기됩니다: `post_id` 가 있으면 기존 글 값(수정 모드), `parent_id` 가 있으면 원글 제목을 기반으로 한 답변글 초기값(답변 모드), 둘 다 없으면 빈 기본값(생성 모드)입니다. 수정/답변 모드에서는 본인·관리자 여부와 비회원 글의 비밀번호/토큰 검증을 확인하며, 답변 대상이 블라인드/삭제 상태면 진입을 차단합니다.
비회원 글의 본인 확인 값(`verification_token`)은 요청 헤더 `X-Board-Post-Verify-Token` 으로 보냅니다 — 자격증명을 쿼리 문자열에 실으면 웹서버 접근 기록과 `Referer` 에 그대로 남기 때문입니다. 호환을 위해 본문/쿼리의 `verification_token` 도 계속 받으며, 헤더가 있으면 헤더를 먼저 사용합니다. 이 값은 조회 단계에서 소비되지 않고 확인만 하며, 저장·삭제 요청에서 한 번 사용되면 폐기됩니다.
### GET /api/modules/sirsoft-board/boards/{slug}/posts/form-meta
<!-- @generated:start:api.modules.sirsoft-board.boards.posts.form-meta -->
@@ -3338,6 +3340,8 @@ HTTP/1.1 200
**설명** 게시글 작성/수정 폼 렌더링에 필요한 게시판 메타 정보를 반환합니다. `auth:sanctum` + `sirsoft-board.{slug}.posts.write` 권한이 필요하며, 게시판 설정과 사용자 권한(`user_abilities`)을 담습니다. `post_id` 가 있으면 수정 모드로 작성자/작성일/첨부 목록을 포함하되 회원 글은 본인 또는 관리자만, 비회원 글은 비밀번호/검증 토큰 확인을 요구합니다. `parent_id` 가 있으면 답변 모드로 원글 정보를 포함하며, 답변 기능이 꺼져 있거나 원글이 블라인드/삭제 상태면 차단합니다.
비회원 글의 본인 확인 값(`verification_token`)은 요청 헤더 `X-Board-Post-Verify-Token` 으로 보냅니다 — 자격증명을 쿼리 문자열에 실으면 웹서버 접근 기록과 `Referer` 에 그대로 남기 때문입니다. 호환을 위해 본문/쿼리의 `verification_token` 도 계속 받으며, 헤더가 있으면 헤더를 먼저 사용합니다. 이 값은 조회 단계에서 소비되지 않고 확인만 하며, 저장·삭제 요청에서 한 번 사용되면 폐기됩니다.
### DELETE /api/modules/sirsoft-board/boards/{slug}/posts/{id}
<!-- @generated:start:api.modules.sirsoft-board.boards.posts.destroy -->
@@ -3853,6 +3857,8 @@ HTTP/1.1 200
**설명** 게시글을 수정합니다. `auth:sanctum` + `sirsoft-board.{slug}.posts.write` 또는 게시판 manager 권한이 필요하며, `UpdatePostRequest` 로 검증된 값으로 갱신합니다. 작성자 본인, 게시판 관리자, 또는 비회원 글의 검증 토큰·비밀번호 확인 중 하나를 만족해야 수정할 수 있고, 조건 미충족 시 403을 반환합니다.
`password` 와 `verification_token` 은 **본인 확인용 자격증명이며 저장되지 않습니다.** 게시글 비밀번호는 작성 시점에 설정된 값이 그대로 유지되며, 이 엔드포인트로 변경할 수 없습니다(수정 요청에 실린 값을 저장하면 저장된 해시가 평문으로 덮여 이후 본인 확인이 불가능해집니다). 같은 규칙이 댓글 수정 엔드포인트에도 적용됩니다.
### GET /api/modules/sirsoft-board/boards/{slug}/posts/{id}/navigation
<!-- @generated:start:api.modules.sirsoft-board.boards.posts.navigation -->
@@ -3946,6 +3952,13 @@ Content-Type: application/json
_단건 응답: `data` 객체의 필드. 검증 성공 시 `password_verified` 플래그가 설정되어 게시글 상세(`PostResource`)를 그대로 반환합니다 — 즉 `GET /boards/{slug}/posts/{id}` 와 동일 스키마이며, 차이는 비밀글이라도 `content` 와 `attachments` 가 채워진다는 점입니다._
_이 응답은 `data` 밖 최상위에 다음 두 필드를 함께 싣습니다._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| secret_view_token | string | `k3Jd…40자` | 열람 확인 토큰. 이후 이 게시글의 댓글·답글·신고 요청에 `X-Board-Secret-View-Token` 헤더로 실어 보내면 비밀번호를 맞힌 사실이 인정됩니다. 이 값을 보내지 않으면 원문을 연 사용자도 하위 글쓰기가 거부됩니다. 토큰은 이 게시글에만 유효하며 소비되지 않아 유효기간 안에서 여러 번 쓸 수 있습니다. |
| secret_view_expires_at | string | `2026-09-04T20:55:42+09:00` | 위 토큰의 만료 시각 (ISO 8601). 기간은 코어 설정 `cache.post_verify_token_ttl` 을 따릅니다. |
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| id | integer | `237` | 게시글 기본 키 |
@@ -47,50 +47,50 @@
| `sirsoft-board.board_type.before_update` | action | — | `src/Services/BoardTypeService.php:62` |
| `sirsoft-board.board_type.filter_create_data` | filter | — | `src/Services/BoardTypeService.php:38` |
| `sirsoft-board.board_type.filter_update_data` | filter | — | `src/Services/BoardTypeService.php:66` |
| `sirsoft-board.comment.after_blind` | action | — | `src/Services/CommentService.php:480` |
| `sirsoft-board.comment.after_create` | action | — | `src/Services/CommentService.php:375` |
| `sirsoft-board.comment.after_delete` | action | — | `src/Services/CommentService.php:441` |
| `sirsoft-board.comment.after_restore` | action | — | `src/Services/CommentService.php:516` |
| `sirsoft-board.comment.after_update` | action | — | `src/Services/CommentService.php:410` |
| `sirsoft-board.comment.before_blind` | action | — | `src/Services/CommentService.php:471` |
| `sirsoft-board.comment.before_create` | action | — | `src/Services/CommentService.php:341` |
| `sirsoft-board.comment.before_delete` | action | — | `src/Services/CommentService.php:431` |
| `sirsoft-board.comment.before_restore` | action | — | `src/Services/CommentService.php:507` |
| `sirsoft-board.comment.before_update` | action | — | `src/Services/CommentService.php:399` |
| `sirsoft-board.comment.filter_create_data` | filter | — | `src/Services/CommentService.php:344` |
| `sirsoft-board.comment.filter_update_data` | filter | — | `src/Services/CommentService.php:404` |
| `sirsoft-board.comment.after_blind` | action | — | `src/Services/CommentService.php:496` |
| `sirsoft-board.comment.after_create` | action | — | `src/Services/CommentService.php:391` |
| `sirsoft-board.comment.after_delete` | action | — | `src/Services/CommentService.php:457` |
| `sirsoft-board.comment.after_restore` | action | — | `src/Services/CommentService.php:532` |
| `sirsoft-board.comment.after_update` | action | — | `src/Services/CommentService.php:426` |
| `sirsoft-board.comment.before_blind` | action | — | `src/Services/CommentService.php:487` |
| `sirsoft-board.comment.before_create` | action | — | `src/Services/CommentService.php:357` |
| `sirsoft-board.comment.before_delete` | action | — | `src/Services/CommentService.php:447` |
| `sirsoft-board.comment.before_restore` | action | — | `src/Services/CommentService.php:523` |
| `sirsoft-board.comment.before_update` | action | — | `src/Services/CommentService.php:415` |
| `sirsoft-board.comment.filter_create_data` | filter | — | `src/Services/CommentService.php:360` |
| `sirsoft-board.comment.filter_update_data` | filter | — | `src/Services/CommentService.php:420` |
| `sirsoft-board.comment.store_validation_rules` | filter | — | `src/Http/Requests/StoreCommentRequest.php:91` |
| `sirsoft-board.comment.update_validation_rules` | filter | — | `src/Http/Requests/UpdateCommentRequest.php:67` |
| `sirsoft-board.permissions.after_create` | action | — | `src/Services/BoardService.php:431` |
| `sirsoft-board.permissions.after_delete` | action | — | `src/Services/BoardService.php:600` |
| `sirsoft-board.permissions.after_update` | action | — | `src/Services/BoardService.php:523` |
| `sirsoft-board.post.after_blind` | action | — | `src/Services/PostService.php:500` |
| `sirsoft-board.post.after_create` | action | — | `src/Services/PostService.php:275` |
| `sirsoft-board.post.after_delete` | action | — | `src/Services/PostService.php:459` |
| `sirsoft-board.post.after_restore` | action | — | `src/Services/PostService.php:564` |
| `sirsoft-board.post.after_update` | action | — | `src/Services/PostService.php:351` |
| `sirsoft-board.post.before_blind` | action | — | `src/Services/PostService.php:491` |
| `sirsoft-board.post.before_create` | action | — | `src/Services/PostService.php:250` |
| `sirsoft-board.post.before_delete` | action | — | `src/Services/PostService.php:426` |
| `sirsoft-board.post.before_restore` | action | — | `src/Services/PostService.php:533` |
| `sirsoft-board.post.before_update` | action | — | `src/Services/PostService.php:324` |
| `sirsoft-board.post.after_blind` | action | — | `src/Services/PostService.php:510` |
| `sirsoft-board.post.after_create` | action | — | `src/Services/PostService.php:285` |
| `sirsoft-board.post.after_delete` | action | — | `src/Services/PostService.php:469` |
| `sirsoft-board.post.after_restore` | action | — | `src/Services/PostService.php:574` |
| `sirsoft-board.post.after_update` | action | — | `src/Services/PostService.php:361` |
| `sirsoft-board.post.before_blind` | action | — | `src/Services/PostService.php:501` |
| `sirsoft-board.post.before_create` | action | — | `src/Services/PostService.php:260` |
| `sirsoft-board.post.before_delete` | action | — | `src/Services/PostService.php:436` |
| `sirsoft-board.post.before_restore` | action | — | `src/Services/PostService.php:543` |
| `sirsoft-board.post.before_update` | action | — | `src/Services/PostService.php:334` |
| `sirsoft-board.post.filter_content_thumbnail` | filter | — | `src/Models/Post.php:138` |
| `sirsoft-board.post.filter_create_data` | filter | — | `src/Services/PostService.php:253` |
| `sirsoft-board.post.filter_update_data` | filter | — | `src/Services/PostService.php:329` |
| `sirsoft-board.post.filter_create_data` | filter | — | `src/Services/PostService.php:263` |
| `sirsoft-board.post.filter_update_data` | filter | — | `src/Services/PostService.php:339` |
| `sirsoft-board.post.store_validation_rules` | filter | — | `src/Http/Requests/StorePostRequest.php:120` |
| `sirsoft-board.post.update_validation_rules` | filter | — | `src/Http/Requests/UpdatePostRequest.php:77` |
| `sirsoft-board.report.after_blind_content` | action | — | `src/Services/ReportService.php:712` |
| `sirsoft-board.report.after_bulk_update_status` | action | — | `src/Services/ReportService.php:492` |
| `sirsoft-board.report.after_create` | action | — | `src/Services/ReportService.php:292` |
| `sirsoft-board.report.after_delete` | action | — | `src/Services/ReportService.php:559` |
| `sirsoft-board.report.after_delete_content` | action | — | `src/Services/ReportService.php:880` |
| `sirsoft-board.report.after_restore_content` | action | — | `src/Services/ReportService.php:678` |
| `sirsoft-board.report.after_update_status` | action | — | `src/Services/ReportService.php:369` |
| `sirsoft-board.report.before_bulk_update_status` | action | — | `src/Services/ReportService.php:385` |
| `sirsoft-board.report.before_create` | action | — | `src/Services/ReportService.php:203` |
| `sirsoft-board.report.before_delete` | action | — | `src/Services/ReportService.php:554` |
| `sirsoft-board.report.before_update_status` | action | — | `src/Services/ReportService.php:324` |
| `sirsoft-board.report.filter_create_data` | filter | — | `src/Services/ReportService.php:227` |
| `sirsoft-board.report.after_blind_content` | action | — | `src/Services/ReportService.php:717` |
| `sirsoft-board.report.after_bulk_update_status` | action | — | `src/Services/ReportService.php:497` |
| `sirsoft-board.report.after_create` | action | — | `src/Services/ReportService.php:297` |
| `sirsoft-board.report.after_delete` | action | — | `src/Services/ReportService.php:564` |
| `sirsoft-board.report.after_delete_content` | action | — | `src/Services/ReportService.php:885` |
| `sirsoft-board.report.after_restore_content` | action | — | `src/Services/ReportService.php:683` |
| `sirsoft-board.report.after_update_status` | action | — | `src/Services/ReportService.php:374` |
| `sirsoft-board.report.before_bulk_update_status` | action | — | `src/Services/ReportService.php:390` |
| `sirsoft-board.report.before_create` | action | — | `src/Services/ReportService.php:208` |
| `sirsoft-board.report.before_delete` | action | — | `src/Services/ReportService.php:559` |
| `sirsoft-board.report.before_update_status` | action | — | `src/Services/ReportService.php:329` |
| `sirsoft-board.report.filter_create_data` | filter | — | `src/Services/ReportService.php:232` |
| `sirsoft-board.roles.after_create` | action | — | `src/Services/BoardService.php:411` |
| `sirsoft-board.roles.after_delete` | action | — | `src/Services/BoardService.php:604` |
| `sirsoft-board.search.post.index_should_update` | filter | — | `src/Models/Post.php:280` |
@@ -0,0 +1,116 @@
/**
* 비밀글 열람 확인 토큰 배선 검증 테스트
*
* @description
* 비밀번호로 비밀글 원문을 연 사용자는 그 사실을 다음 요청으로 넘겨야 댓글·답글·신고를
* 남길 수 있다. 서버는 그 사실을 검증 응답 하나에만 담으므로, 화면이 ①응답의 토큰을
* 보관하고 ②게시판 API 요청에 헤더로 실어야 흐름이 성립한다.
*
* 이 배선은 빠져도 예외도 콘솔 오류도 남기지 않는다 — 원문이 열린 화면에서 댓글 버튼을
* 눌렀을 때 "열람 권한이 없는 비밀글" 로 거부되는 것이 유일한 증상이고, 서버 게이트는
* 정상 동작 중이라 백엔드 테스트로는 드러나지 않는다. 그래서 구조로 잠근다.
*/
import { describe, it, expect } from 'vitest';
import userBase from '../../../../../../../templates/_bundled/sirsoft-basic/layouts/_user_base.json';
import basicShow from '../../../../../../../templates/_bundled/sirsoft-basic/layouts/partials/board/types/basic/show.json';
import boardForm from '../../../../../../../templates/_bundled/sirsoft-basic/layouts/board/form.json';
const HEADER = 'X-Board-Secret-View-Token';
const MODIFY_HEADER = 'X-Board-Post-Verify-Token';
const BOARD_API_PATTERN = '/api/modules/sirsoft-board/*';
/**
* 레이아웃 트리를 평탄화해 모든 노드를 돌려줍니다.
*/
function walk(node: unknown, out: Record<string, unknown>[] = []): Record<string, unknown>[] {
if (Array.isArray(node)) {
node.forEach((child) => walk(child, out));
return out;
}
if (node && typeof node === 'object') {
out.push(node as Record<string, unknown>);
Object.values(node as Record<string, unknown>).forEach((value) => walk(value, out));
}
return out;
}
describe('비밀글 열람 확인 토큰 배선', () => {
it('_user_base 의 globalHeaders 가 게시판 API 에 열람 토큰 헤더를 주입한다', () => {
const rules = (userBase as Record<string, any>).globalHeaders;
expect(Array.isArray(rules)).toBe(true);
const boardRule = rules.find((rule: any) => rule?.pattern === BOARD_API_PATTERN);
expect(
boardRule,
`globalHeaders 에 ${BOARD_API_PATTERN} 규칙이 없습니다 — 열람 토큰이 요청에 실리지 않아 원문을 연 사용자의 댓글·답글·신고가 전부 거부됩니다.`
).toBeDefined();
expect(boardRule.headers?.[HEADER]).toBe('{{_global.secretViewToken}}');
});
it('비밀번호 검증 성공 시 응답의 토큰을 _global.secretViewToken 에 보관한다', () => {
const nodes = walk(basicShow);
const verifyCall = nodes.find(
(node) =>
typeof node.target === 'string' &&
node.target.includes('/verify-password') &&
!node.target.includes('verify-password-for-modify')
);
expect(verifyCall, '비밀글 비밀번호 검증 apiCall 을 찾지 못했습니다.').toBeDefined();
const stored = walk(verifyCall!.onSuccess).some(
(node) =>
node.handler === 'setState' &&
(node.params as Record<string, unknown> | undefined)?.target === 'global' &&
typeof (node.params as Record<string, unknown> | undefined)?.secretViewToken === 'string'
);
expect(
stored,
'verify-password 의 onSuccess 가 secret_view_token 을 _global.secretViewToken 에 보관하지 않습니다 — 토큰이 없으면 globalHeaders 가 빈 값을 보내 헤더 자체가 붙지 않습니다.'
).toBe(true);
});
});
describe('게시글 수정 검증 토큰 전송로', () => {
const sources = (boardForm as Record<string, any>).data_sources as Record<string, any>[];
it.each(['form_data', 'form_meta'])(
'%s 는 검증 토큰을 헤더로 보낸다 (쿼리 파라미터 금지)',
(id) => {
const source = sources.find((entry) => entry?.id === id);
expect(source, `${id} 데이터소스를 찾지 못했습니다.`).toBeDefined();
expect(
source!.params?.verification_token,
`${id} 가 검증 토큰을 쿼리 파라미터로 보냅니다 — 자격증명이 주소에 실려 웹서버 접근 기록과 Referer 에 그대로 남습니다.`
).toBeUndefined();
expect(
source!.headers?.[MODIFY_HEADER],
`${id} 가 검증 토큰 헤더를 보내지 않습니다 — 비회원 작성자가 자기 글을 수정할 수 없게 됩니다.`
).toBe("{{_local.verificationToken ?? ''}}");
}
);
it('검증 토큰을 담는 엔드포인트가 GET 인지 확인한다 (전송로 판단 근거)', () => {
sources
.filter((source) => source?.headers?.[MODIFY_HEADER])
.forEach((source) => {
expect(
String(source.method).toUpperCase(),
'헤더 전송이 필요한 이유는 GET 이기 때문입니다 — 메서드가 바뀌면 이 계약을 다시 판단해야 합니다.'
).toBe('GET');
});
});
});
@@ -47,6 +47,19 @@ class PostNotCommentableException extends Exception
return new self('deleted', 'sirsoft-board::messages.comment.post_deleted');
}
/**
* 열람 권한이 없는 비밀글에 대한 예외를 생성합니다.
*
* 비밀글 원문을 볼 수 없는 사용자는 그 게시글의 하위 콘텐츠도 만들 수 없다
* (KVE-2026-2044). 판정 SSoT 는 SecretContentGate 다.
*
* @return self 비밀글 사유 예외
*/
public static function secret(): self
{
return new self('secret', 'sirsoft-board::messages.comment.post_secret');
}
/**
* 다국어 메시지 키를 반환합니다.
*
@@ -26,6 +26,7 @@ use Modules\Sirsoft\Board\Http\Requests\User\VerifyGuestPasswordRequest;
use Modules\Sirsoft\Board\Http\Resources\BoardResource;
use Modules\Sirsoft\Board\Http\Resources\PostCollection;
use Modules\Sirsoft\Board\Http\Resources\PostResource;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Services\BoardService;
use Modules\Sirsoft\Board\Services\CommentService;
@@ -161,89 +162,7 @@ class PostController extends PublicBaseController
$post->view_count = (int) $post->view_count + 1;
}
// manager 권한 체크 (삭제 게시글/댓글 포함 여부 결정)
$canViewDeleted = $this->checkBoardPermission($slug, 'manager', PermissionType::User);
// 댓글 로드 (게시판 comment_order 설정 적용, manager 권한 + 토글 ON 시 삭제 댓글 포함)
$withTrashedComments = $canViewDeleted && $request->boolean('del_cmt');
// comment_page 가 오면 원댓글 기준 페이지네이션 경로를 쓴다. 댓글이 상한을 넘는
// 글에서도 뒤쪽 댓글에 도달할 수 있어야 하기 때문이다. 파라미터가 없으면
// 종전대로 상한까지 전량을 싣는다(기존 화면 응답 형태 불변).
$commentPage = $this->resolveCommentPage($request);
$commentPagination = null;
if ($commentPage !== null) {
$paginated = $this->commentService->paginateCommentsByPostId(
$slug,
$id,
perPage: $commentPage['per_page'],
page: $commentPage['page'],
context: 'user',
withTrashed: $withTrashedComments,
boardId: $board->id,
board: $board,
);
$comments = $paginated->getCollection();
$commentPagination = [
'current_page' => $paginated->currentPage(),
'per_page' => $paginated->perPage(),
'total' => $paginated->total(),
'last_page' => $paginated->lastPage(),
'has_more_pages' => $paginated->hasMorePages(),
'total_relation' => $paginated->totalRelation()->value,
'total_is_exact' => $paginated->totalRelation()->isExact(),
'result_cap' => $paginated->resultCap(),
];
} else {
$comments = $this->commentService->getCommentsByPostId($slug, $id, context: 'user', withTrashed: $withTrashedComments, boardId: $board->id, board: $board);
}
// 신고 여부 일괄 조회 (N+1 방지: 댓글별 개별 쿼리 → 1회 일괄 쿼리)
$user = $request->user();
if ($user) {
$commentIds = $comments->pluck('id')->all();
$reportedCommentIds = $this->reportService
->getReportedTargetIds($user->id, $board->id, 'comment', $commentIds);
foreach ($comments as $comment) {
$comment->is_already_reported_preloaded = in_array($comment->id, $reportedCommentIds);
$comment->setRelation('post', $post);
}
} else {
// 비로그인: 신고 불가이므로 모두 false
foreach ($comments as $comment) {
$comment->is_already_reported_preloaded = false;
$comment->setRelation('post', $post);
}
}
// 정렬된 댓글을 post에 설정
$post->setRelation('comments', $comments);
// 댓글 목록은 상한에서 끊길 수 있다. 끊겼다면 그 사실을 화면에 알린다 —
// 조용히 잘라내면 사용자에게는 "댓글이 그만큼뿐" 으로 보인다.
// 상한 이하면 이미 전량을 받았으므로 세는 쿼리를 추가하지 않는다.
$commentCap = PaginationLimits::resultCap('board.comments');
if ($commentPagination !== null) {
// 페이지네이션 경로는 잘림 여부를 페이지 메타가 그대로 알린다.
$post->comments_pagination = $commentPagination;
$post->comments_total = $commentPagination['total'];
$post->comments_total_is_exact = $commentPagination['total_is_exact'];
} elseif ($commentCap !== null && $comments->count() >= $commentCap) {
$commentTotal = $this->commentService->countCommentsByPostId(
$slug,
$id,
$withTrashedComments,
$board->id
);
$post->comments_truncated = true;
$post->comments_total = $commentTotal->total;
$post->comments_total_is_exact = $commentTotal->totalRelation()->isExact();
}
$this->attachComments($request, $post, $board, $slug, $id);
// 비밀글 권한 체크 및 content 필터링은 PostResource에서 처리
return $this->successWithResource(
@@ -443,7 +362,11 @@ class PostController extends PublicBaseController
}
// 게시글 수정
$data = $request->validated();
// password / verification_token 은 본인 확인용 자격증명이므로 저장 데이터에서 제거한다.
// 그대로 넘기면 검증에 쓰인 평문이 기존 bcrypt 해시를 덮어써, 비밀번호가 평문으로
// 남고 이후 본인 확인이 "bcrypt 가 아니다" 예외로 끝나 본인이 자기 글을 수정·삭제할
// 수 없게 된다. 댓글 수정 경로(CommentController::update)와 같은 규칙이다.
$data = collect($request->validated())->except(['password', 'verification_token'])->toArray();
// `attachment_ids` 를 Service 로 넘기지 않으면 검증(형식·개수 상한 합산)은 통과하고
// 첨부만 조용히 연결되지 않는다 (200 + 첨부 0 건). 관리자 경로는 넘기고 있으므로
@@ -550,7 +473,15 @@ class PostController extends PublicBaseController
}
// 게시글 조회 (첨부파일 포함). 이미 조회한 Board 를 넘겨 재조회를 막는다.
$post = $this->postService->getPostWithCounts($slug, $id, board: $board);
// context 는 상세 조회와 같은 값을 넘긴다 — 라우트의 {id} 는 Model 로 resolve
// 되지 않아 미들웨어 스코프 검사가 건너뛰므로, 서비스 계층이 유일한 스코프 관문이다.
$post = $this->postService->getPostWithCounts($slug, $id, board: $board, context: 'user');
// 삭제된 게시글은 상세 조회와 동일하게 manager 권한을 요구한다. 이 판정이 없으면
// 목록에서 사라진 글의 원문이 주소를 아는 쪽에만 열려 형제 경로로 새어나간다.
if ($post->trashed() && ! $this->checkBoardPermission($slug, 'manager', PermissionType::User)) {
throw new PostNotFoundException($id);
}
// 비밀번호 검증 (Service 사용)
$password = $request->validated('password');
@@ -563,11 +494,22 @@ class PostController extends PublicBaseController
// 검증 성공 - password_verified 플래그 설정하여 PostResource에서 content 포함
$post->password_verified = true;
// 상세 조회와 같은 스키마를 돌려준다. 화면은 이 응답으로 게시글 데이터소스를
// 통째로 교체하므로, 댓글을 싣지 않으면 목록이 사라진 채 "댓글 N" 헤더만 남는다.
$this->attachComments($request, $post, $board, $slug, $id);
// 이 플래그는 이 응답 안에서만 산다. 화면은 원문이 열린 사람에게 댓글·답글·신고를
// 내주는데 그 요청들은 각각 별개라, 같은 사실을 넘길 토큰을 함께 발급한다.
$viewToken = $this->postService->issueSecretViewToken($slug, $id);
// successWithResource 사용: $this->when() 조건부 필드가 올바르게 직렬화됨
// (toArray 직접 호출 시 MissingValue 객체가 반환되는 문제 방지)
return $this->successWithResource(
'sirsoft-board::messages.posts.password_verified',
new PostResource($post)
(new PostResource($post))->additional([
'secret_view_token' => $viewToken['token'],
'secret_view_expires_at' => $viewToken['expires_at'],
])
);
} catch (ModelNotFoundException $e) {
throw new PostNotFoundException($id);
@@ -681,11 +623,9 @@ class PostController extends PublicBaseController
$requiresPassword = ! $post->user_id && $post->password;
// verification_token이 유효하면 비밀번호 확인 불필요
if ($requiresPassword && $request->filled('verification_token')) {
$token = $request->get('verification_token');
if ($this->isVerificationTokenValid($slug, $postId, $token)) {
$requiresPassword = false;
}
$token = $this->verificationTokenFrom($request);
if ($requiresPassword && $token !== '' && $this->verificationTokenMatches($slug, $postId, $token)) {
$requiresPassword = false;
}
$metaData['requires_password'] = $requiresPassword;
@@ -771,9 +711,9 @@ class PostController extends PublicBaseController
// 비밀글 또는 비회원 글의 검증 처리
// 1. verification_token으로 검증 (권장 - 비밀번호 재전송 불필요)
// 2. password로 검증 (fallback)
if ($request->filled('verification_token')) {
$token = $request->get('verification_token');
if ($this->isVerificationTokenValid($slug, $postId, $token)) {
$token = $this->verificationTokenFrom($request);
if ($token !== '') {
if ($this->verificationTokenMatches($slug, $postId, $token)) {
$post->password_verified = true;
}
} elseif ($request->filled('password') && $post->password) {
@@ -798,7 +738,7 @@ class PostController extends PublicBaseController
'parent_id' => $postData['parent_id'] ?? null,
'attachments' => $postData['attachments'] ?? [],
// 비회원 글 수정 시 verification_token 유지 (PUT 요청에 필요)
'verification_token' => $request->get('verification_token', ''),
'verification_token' => $this->verificationTokenFrom($request),
];
}
// 답변글 모드
@@ -940,8 +880,8 @@ class PostController extends PublicBaseController
// 3. 비회원 게시글인 경우 verification_token 또는 비밀번호로 확인
if (! $post->user_id && $post->password) {
// 3-1. verification_token 확인 (권장)
$token = $request->input('verification_token');
if ($token && $this->isVerificationTokenValid($slug, $post->id, $token)) {
$token = $this->verificationTokenFrom($request);
if ($token !== '' && $this->consumeVerificationToken($slug, $post->id, $token)) {
return true;
}
@@ -960,14 +900,58 @@ class PostController extends PublicBaseController
// =========================================================================
/**
* verification_token이 유효한지 확인하고 소비합니다.
* 요청에서 게시글 검증 토큰을 꺼냅니다.
*
* 헤더를 먼저 본다 — 자격증명이 주소에 실리면 웹서버 접근 기록과 Referer 에 남는다.
* 폼 조회(GET)는 헤더로만 보내도록 바꿨지만, 저장·삭제는 이미 본문으로 보내고 있고
* 외부에서 그 형태로 호출하는 쪽이 있을 수 있어 기존 경로도 계속 받는다.
*
* @param Request $request HTTP 요청
* @return string 검증 토큰 (없으면 빈 문자열)
*/
private function verificationTokenFrom(Request $request): string
{
$header = $request->header(PostService::VERIFY_TOKEN_HEADER);
if (is_string($header) && $header !== '') {
return $header;
}
$value = $request->input('verification_token');
return is_string($value) ? $value : '';
}
/**
* verification_token 이 유효한지 확인만 합니다 (소비하지 않음).
*
* 수정 화면은 「비밀번호 확인 → 폼 조회 → 저장」 순으로 같은 토큰을 여러 번 제시한다.
* 폼 조회가 토큰을 써 버리면 저장 시점에 남지 않아, 비밀번호를 정확히 넣고 본문까지 본
* 사용자가 「수정 권한이 없습니다」로 거부된다 — 토큰을 다시 받을 방법이 화면에 없어
* 그 글은 수정 자체가 불가능해진다. 그래서 읽기는 이 확인만 쓴다.
*
* @param string $slug 게시판 슬러그
* @param int $postId 게시글 ID
* @param string $token 검증 토큰
* @return bool 토큰 유효 여부
*/
private function isVerificationTokenValid(string $slug, int $postId, string $token): bool
private function verificationTokenMatches(string $slug, int $postId, string $token): bool
{
return $this->postService->hasValidDeleteVerifyToken($slug, $postId, $token);
}
/**
* verification_token 을 확인하고 소비합니다.
*
* 소비는 상태를 바꾸는 요청(수정·삭제)에서만 한다 — 그 지점이 토큰의 목적이고,
* 1회용이라야 같은 토큰으로 두 번 쓰는 것을 막을 수 있다.
*
* @param string $slug 게시판 슬러그
* @param int $postId 게시글 ID
* @param string $token 검증 토큰
* @return bool 토큰 유효 여부
*/
private function consumeVerificationToken(string $slug, int $postId, string $token): bool
{
return $this->postService->consumeDeleteVerifyToken($slug, $postId, $token);
}
@@ -1052,4 +1036,106 @@ class PostController extends PublicBaseController
return ['page' => $page, 'per_page' => $perPage];
}
/**
* 게시글에 댓글 목록과 그 메타를 적재합니다.
*
* 상세 조회와 비밀번호 검증이 같은 응답 스키마를 약속하므로(문서: "GET 상세와 동일
* 스키마") 적재 규칙을 한 곳에 둔다. 검증 응답에서 이 적재가 빠지면 화면이 그 응답으로
* 게시글 데이터소스를 통째로 교체하는 순간 댓글 목록이 사라지는데, 댓글 수는 집계
* 컬럼이라 그대로 남아 "댓글 N" 아래가 비어 보인다 — 오류도 경고도 남지 않는다.
*
* @param Request $request HTTP 요청 (del_cmt / comment_page 파라미터 해석)
* @param Post $post 대상 게시글 (comments 관계와 메타가 이 인스턴스에 설정된다)
* @param Board $board 이미 조회한 게시판 (재조회 방지)
* @param string $slug 게시판 슬러그
* @param int $id 게시글 ID
*/
private function attachComments(Request $request, Post $post, Board $board, string $slug, int $id): void
{
// manager 권한 체크 (삭제 게시글/댓글 포함 여부 결정)
$canViewDeleted = $this->checkBoardPermission($slug, 'manager', PermissionType::User);
// 댓글 로드 (게시판 comment_order 설정 적용, manager 권한 + 토글 ON 시 삭제 댓글 포함)
$withTrashedComments = $canViewDeleted && $request->boolean('del_cmt');
// comment_page 가 오면 원댓글 기준 페이지네이션 경로를 쓴다. 댓글이 상한을 넘는
// 글에서도 뒤쪽 댓글에 도달할 수 있어야 하기 때문이다. 파라미터가 없으면
// 종전대로 상한까지 전량을 싣는다(기존 화면 응답 형태 불변).
$commentPage = $this->resolveCommentPage($request);
$commentPagination = null;
if ($commentPage !== null) {
$paginated = $this->commentService->paginateCommentsByPostId(
$slug,
$id,
perPage: $commentPage['per_page'],
page: $commentPage['page'],
context: 'user',
withTrashed: $withTrashedComments,
boardId: $board->id,
board: $board,
);
$comments = $paginated->getCollection();
$commentPagination = [
'current_page' => $paginated->currentPage(),
'per_page' => $paginated->perPage(),
'total' => $paginated->total(),
'last_page' => $paginated->lastPage(),
'has_more_pages' => $paginated->hasMorePages(),
'total_relation' => $paginated->totalRelation()->value,
'total_is_exact' => $paginated->totalRelation()->isExact(),
'result_cap' => $paginated->resultCap(),
];
} else {
$comments = $this->commentService->getCommentsByPostId($slug, $id, context: 'user', withTrashed: $withTrashedComments, boardId: $board->id, board: $board);
}
// 신고 여부 일괄 조회 (N+1 방지: 댓글별 개별 쿼리 → 1회 일괄 쿼리)
$user = $request->user();
if ($user) {
$commentIds = $comments->pluck('id')->all();
$reportedCommentIds = $this->reportService
->getReportedTargetIds($user->id, $board->id, 'comment', $commentIds);
foreach ($comments as $comment) {
$comment->is_already_reported_preloaded = in_array($comment->id, $reportedCommentIds);
$comment->setRelation('post', $post);
}
} else {
// 비로그인: 신고 불가이므로 모두 false
foreach ($comments as $comment) {
$comment->is_already_reported_preloaded = false;
$comment->setRelation('post', $post);
}
}
// 정렬된 댓글을 post에 설정
$post->setRelation('comments', $comments);
// 댓글 목록은 상한에서 끊길 수 있다. 끊겼다면 그 사실을 화면에 알린다 —
// 조용히 잘라내면 사용자에게는 "댓글이 그만큼뿐" 으로 보인다.
// 상한 이하면 이미 전량을 받았으므로 세는 쿼리를 추가하지 않는다.
$commentCap = PaginationLimits::resultCap('board.comments');
if ($commentPagination !== null) {
// 페이지네이션 경로는 잘림 여부를 페이지 메타가 그대로 알린다.
$post->comments_pagination = $commentPagination;
$post->comments_total = $commentPagination['total'];
$post->comments_total_is_exact = $commentPagination['total_is_exact'];
} elseif ($commentCap !== null && $comments->count() >= $commentCap) {
$commentTotal = $this->commentService->countCommentsByPostId(
$slug,
$id,
$withTrashedComments,
$board->id
);
$post->comments_truncated = true;
$post->comments_total = $commentTotal->total;
$post->comments_total_is_exact = $commentTotal->totalRelation()->isExact();
}
}
}
@@ -8,13 +8,17 @@ use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\Comment;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Support\SecretContentGate;
/**
* 댓글/대댓글 작성 시 검증 규칙
*
* 검증 대상에 따라 다음을 검증합니다:
* - post_id: 게시글의 블라인드/삭제 상태
* - parent_id: 부모 댓글의 블라인드/삭제 상태, 게시판 max_comment_depth 초과 여부
* - post_id: 게시글의 블라인드/삭제 상태, 비밀글 열람 권한
* - parent_id: 부모 댓글의 블라인드/삭제 상태, 부모 게시글의 비밀글 열람 권한,
* 게시판 max_comment_depth 초과 여부
*
* 비밀글 게이트는 요청 단계의 조기 차단이며 최종 관문은 CommentService 다(이중 방어).
*/
class CommentValidationRule implements ValidationRule
{
@@ -78,6 +82,12 @@ class CommentValidationRule implements ValidationRule
if ($post->status === PostStatus::Deleted || $post->deleted_at !== null) {
$fail(__('sirsoft-board::validation.comment.post_id.deleted'));
return;
}
if (! $this->canWriteChildOf($post, $board)) {
$fail(__('sirsoft-board::validation.comment.post_id.secret'));
}
}
@@ -115,9 +125,41 @@ class CommentValidationRule implements ValidationRule
return;
}
// 부모 게시글의 비밀글 게이트 — 부모 댓글의 블라인드/삭제 검사와는 별개 축이다.
// 대댓글도 비밀글의 하위 콘텐츠이므로 같은 열람 기준을 적용한다 (KVE-2026-2044).
$parentPost = Post::where('board_id', $board->id)
->withTrashed()
->find($this->postId ?? $parentComment->post_id);
if ($parentPost && ! $this->canWriteChildOf($parentPost, $board)) {
$fail(__('sirsoft-board::validation.comment.post_id.secret'));
return;
}
// 댓글 깊이 제한 검증
if ($parentComment->depth + 1 > $board->max_comment_depth) {
$fail(__('sirsoft-board::validation.comment.depth.exceeded', ['max' => $board->max_comment_depth]));
}
}
/**
* 부모 게시글의 하위 콘텐츠를 작성할 수 있는지 판정합니다.
*
* 판정 규칙은 읽기 게이트와 같은 SecretContentGate(SSoT)를 재사용한다.
* board 관계를 붙여 두는 이유는 게이트의 슬러그 해석이 라우트에 없을 때
* 관계로 폴백하기 때문이다(미로딩이면 fail-closed).
*
* @param Post $post 부모 게시글
* @param Board $board 대상 게시판
* @return bool 작성 가능 여부
*/
private function canWriteChildOf(Post $post, Board $board): bool
{
if (! $post->relationLoaded('board')) {
$post->setRelation('board', $board);
}
return app(SecretContentGate::class)->canWriteChild($post);
}
}
@@ -7,6 +7,7 @@ use Illuminate\Contracts\Validation\ValidationRule;
use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Support\SecretContentGate;
/**
* 답글 작성 시 부모 게시글 검증 규칙
@@ -14,6 +15,7 @@ use Modules\Sirsoft\Board\Models\Post;
* parent_id가 있을 때 다음을 검증합니다:
* 1. 게시판의 답글 기능(use_reply) 활성화 여부
* 2. 부모 게시글의 블라인드/삭제 상태
* 2-1. 부모 게시글이 비밀글이면 원문 열람 권한 (SecretContentGate SSoT)
* 3. 공지 게시글에는 답글 불가
* 4. 게시판 설정의 max_reply_depth 초과 여부
*/
@@ -78,6 +80,19 @@ class ParentPostValidationRule implements ValidationRule
return;
}
// 2-1. 비밀글 하위 쓰기 게이트 (KVE-2026-2044)
// 답글도 비밀글의 하위 콘텐츠이므로 원문 열람과 같은 기준을 적용한다.
// 판정 SSoT 는 읽기 게이트와 동일한 SecretContentGate 다.
if (! $parentPost->relationLoaded('board')) {
$parentPost->setRelation('board', $board);
}
if (! app(SecretContentGate::class)->canWriteChild($parentPost)) {
$fail(__('sirsoft-board::validation.post.parent_id.secret'));
return;
}
// 3. 공지 게시글에는 답글 불가
if ($parentPost->is_notice) {
$fail(__('sirsoft-board::validation.post.parent_id.notice_not_allowed'));
@@ -19,6 +19,7 @@ use Modules\Sirsoft\Board\Models\Comment;
use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\CommentRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface;
use Modules\Sirsoft\Board\Support\SecretContentGate;
use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
/**
@@ -299,7 +300,7 @@ class CommentService
* @return bool 댓글 작성 가능 여부
*
* @throws ModelNotFoundException 게시글을 찾을 수 없는 경우
* @throws PostNotCommentableException 블라인드/삭제된 게시글인 경우
* @throws PostNotCommentableException 블라인드/삭제/비열람 비밀 게시글인 경우
*/
public function validatePostForComment(string $slug, int $postId): bool
{
@@ -313,6 +314,21 @@ class CommentService
throw PostNotCommentableException::deleted();
}
// 비밀글 하위 쓰기 게이트 (KVE-2026-2044) — 요청 단계 규칙을 우회해도 여기서 막힌다.
// 서비스가 최종 관문이므로 판정은 읽기와 같은 SecretContentGate(SSoT)를 쓴다.
// 비밀글일 때만 board 를 붙인다: 게이트의 슬러그 해석이 라우트에 없으면 관계로
// 폴백하는데, 미로딩이면 fail-closed 라 비-HTTP 호출에서 정상 흐름까지 막힌다.
// 비밀글이 아니면 게이트는 언제나 통과하므로 그 조회를 하지 않는다.
if ($post->is_secret) {
if (! $post->relationLoaded('board')) {
$post->load('board');
}
if (! app(SecretContentGate::class)->canWriteChild($post)) {
throw PostNotCommentableException::secret();
}
}
return true;
}
@@ -18,6 +18,7 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Enums\ReplyDeletePolicy;
use Modules\Sirsoft\Board\Exceptions\PostHasRepliesException;
@@ -36,6 +37,15 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
*/
class PostService
{
/**
* 게시글 수정·삭제용 검증 토큰을 싣는 요청 헤더 이름.
*
* 이 토큰은 종전에 GET 쿼리 파라미터로만 다녔다 — 자격증명이 주소에 실리면 웹서버
* 접근 기록과 Referer 에 그대로 남는다. 헤더로 옮기되, 이미 본문으로 보내는 저장·삭제
* 요청과 외부 연동을 깨지 않도록 기존 경로도 계속 받는다.
*/
public const VERIFY_TOKEN_HEADER = 'X-Board-Post-Verify-Token';
/**
* 검색 정렬 이름 → [실제 컬럼, 방향] 선언
*
@@ -1371,6 +1381,28 @@ class PostService
];
}
/**
* 게시글 비밀번호 검증 토큰이 유효한지 확인만 합니다 (소비하지 않음).
*
* 수정 화면은 「비밀번호 확인 → 폼 조회 → 저장」 순으로 같은 토큰을 여러 번 제시합니다.
* 폼 조회 단계가 토큰을 소비하면 저장 시점에는 남아 있지 않아, 비밀번호를 정확히 입력한
* 사용자가 수정 권한 없음으로 거부됩니다. 조회는 이 확인을, 상태를 바꾸는 요청만
* `consumeDeleteVerifyToken()` 을 씁니다.
*
* @param string $slug 게시판 슬러그
* @param int $postId 게시글 ID
* @param string $token 검증 토큰
* @return bool 토큰 유효 여부
*/
public function hasValidDeleteVerifyToken(string $slug, int $postId, string $token): bool
{
if ($token === '') {
return false;
}
return $this->cache->has("board_post_verify_{$slug}_{$postId}_{$token}");
}
/**
* 게시글 비밀번호 검증 토큰의 유효성을 확인하고 소비합니다.
*
@@ -1392,6 +1424,67 @@ class PostService
return true;
}
/**
* 비밀글 열람 확인 토큰을 발급해 캐시에 저장합니다.
*
* 비밀번호를 맞혔다는 사실은 그 응답 하나에만 살아 있고 다음 요청으로 이어지지 않습니다
* (`$post->password_verified` 는 메모리 플래그입니다). 그런데 화면은 원문이 열린 사람에게
* 댓글·답글·신고를 내주므로, 그 후속 요청이 같은 사실을 제시할 통로가 필요합니다.
*
* 수정·삭제용 토큰(storeDeleteVerifyToken)과 달리 **소비하지 않습니다** — 열람자는 한
* 화면에서 댓글을 여러 번 달 수 있고, 1회용이면 두 번째부터 다시 비밀번호를 물어야 합니다.
* 대신 게시글 단위로 묶이고 유효기간이 있어, 권한 범위는 비밀번호를 아는 것과 같습니다.
*
* @param string $slug 게시판 슬러그
* @param int $postId 게시글 ID
* @return array{token: string, expires_at: string} 토큰 및 만료 시각
*/
public function issueSecretViewToken(string $slug, int $postId): array
{
$ttl = (int) g7_core_settings('cache.post_verify_token_ttl', 3600);
$token = Str::random(40);
$expiresAt = now()->addSeconds($ttl);
$this->cache->put(self::secretViewTokenKey($slug, $postId, $token), true, $ttl);
return [
'token' => $token,
'expires_at' => $expiresAt->toIso8601String(),
];
}
/**
* 비밀글 열람 확인 토큰이 그 게시글에 대해 유효한지 확인합니다 (소비하지 않음).
*
* @param string $slug 게시판 슬러그
* @param int $postId 게시글 ID
* @param string|null $token 제시된 토큰
* @return bool 유효 여부
*/
public function hasValidSecretViewToken(string $slug, int $postId, ?string $token): bool
{
if (! is_string($token) || $token === '') {
return false;
}
return $this->cache->has(self::secretViewTokenKey($slug, $postId, $token));
}
/**
* 비밀글 열람 확인 토큰의 캐시 키를 만듭니다.
*
* 게시판 슬러그와 게시글 ID 를 키에 넣어, 한 글에서 받은 토큰이 다른 글에 통하지 않게 합니다.
*
* @param string $slug 게시판 슬러그
* @param int $postId 게시글 ID
* @param string $token 토큰
* @return string 캐시 키
*/
private static function secretViewTokenKey(string $slug, int $postId, string $token): string
{
return "board_post_secret_view_{$slug}_{$postId}_{$token}";
}
/**
* 관리자 작업 이력 배열을 생성합니다.
*
@@ -6,19 +6,24 @@ use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\UserRepositoryInterface;
use App\Extension\HookManager;
use App\Helpers\PermissionHelper;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Services\BoardSettingsService;
use Modules\Sirsoft\Board\Enums\ReportStatus;
use Modules\Sirsoft\Board\Enums\TriggerType;
use Modules\Sirsoft\Board\Exceptions\DeletedReportStatusChangeException;
use Modules\Sirsoft\Board\Exceptions\DuplicateReportException;
use Modules\Sirsoft\Board\Models\Report;
use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\CommentRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\ReportRepositoryInterface;
use Modules\Sirsoft\Board\Support\SecretContentGate;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* 신고 관리 서비스 클래스
@@ -119,7 +124,7 @@ class ReportService
* @param int $id 신고 ID
* @return Report 조회된 신고 모델
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
* @throws ModelNotFoundException
*/
public function getReport(int $id): Report
{
@@ -138,9 +143,9 @@ class ReportService
* 1케이스 구조이므로 케이스(report) + 신고자 목록(logs) + 처리 이력(process_histories)을 반환합니다.
*
* @param int $id 케이스 ID
* @return array{report: Report, reporters: \Illuminate\Database\Eloquent\Collection, cancelled_reports: \Illuminate\Database\Eloquent\Collection, report_count: int, first_reported_at: mixed, last_reported_at: mixed}
* @return array{report: Report, reporters: Collection, cancelled_reports: Collection, report_count: int, first_reported_at: mixed, last_reported_at: mixed}
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
* @throws ModelNotFoundException
*/
public function getGroupedReportDetail(int $id): array
{
@@ -150,7 +155,7 @@ class ReportService
if (! PermissionHelper::checkScopeAccess($report, 'sirsoft-board.reports.view')) {
throw new AccessDeniedHttpException(__('auth.scope_denied'));
}
// 신고자 로그 목록 로드 (reporter 관계 eager load)
$report->load(['logs' => fn ($q) => $q->latest()->with('reporter'), 'board']);
@@ -172,12 +177,12 @@ class ReportService
/**
* 신고 케이스의 신고자 목록을 페이지네이션으로 반환합니다.
*
* @param int $id 신고 케이스 ID
* @param int $id 신고 케이스 ID
* @param int $perPage 페이지당 항목 수
* @param int $page 페이지 번호
* @return \Illuminate\Pagination\LengthAwarePaginator
* @param int $page 페이지 번호
* @return LengthAwarePaginator
*/
public function paginateReporters(int $id, int $perPage = 10, int $page = 1): \Illuminate\Pagination\LengthAwarePaginator
public function paginateReporters(int $id, int $perPage = 10, int $page = 1): LengthAwarePaginator
{
$this->reportRepository->findOrFail($id); // 케이스 존재 확인 (404 처리)
@@ -195,7 +200,7 @@ class ReportService
* @param array $data 신고 생성 데이터
* @return Report 케이스 모델
*
* @throws \Modules\Sirsoft\Board\Exceptions\DuplicateReportException
* @throws DuplicateReportException
*/
public function createReport(array $data): Report
{
@@ -301,8 +306,8 @@ class ReportService
* @param array $data 변경할 데이터 (status, process_note)
* @return Report 상태가 변경된 신고 모델
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
* @throws \Modules\Sirsoft\Board\Exceptions\DeletedReportStatusChangeException 영구삭제 상태에서 변경 시도 시
* @throws ModelNotFoundException
* @throws DeletedReportStatusChangeException 영구삭제 상태에서 변경 시도 시
*/
public function updateReportStatus(int $id, array $data): Report
{
@@ -504,7 +509,7 @@ class ReportService
* @param string $action 처리 액션 (status 값 또는 reported/re_reported 등 특수 타입)
* @param string|null $reason 처리 사유
* @param int|null $processorId 처리자 ID (신고 접수 시 null)
* @param \Carbon\Carbon $processedAt 처리 일시
* @param Carbon $processedAt 처리 일시
* @param int|null $reporterCount 현재 사이클 신고자 수 (reported/re_reported 시 사용)
* @return array 이력 항목
*/
@@ -539,7 +544,7 @@ class ReportService
* @param int $id 신고 ID
* @return bool 삭제 성공 여부
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
* @throws ModelNotFoundException
*/
public function deleteReport(int $id): bool
{
@@ -1254,6 +1259,7 @@ class ReportService
* 신고 대상이 신고 가능한 상태인지 확인합니다.
*
* 블라인드 또는 삭제된 대상은 신고할 수 없습니다.
* 열람 권한이 없는 비밀글과 그 비밀글에 달린 댓글도 신고할 수 없습니다.
*
* @param int $boardId 게시판 ID
* @param string $targetType 신고 대상 타입 (post/comment)
@@ -1280,6 +1286,26 @@ class ReportService
return false;
}
// 비밀글 하위 쓰기 게이트 (KVE-2026-2044)
// 신고도 대상 게시글에 딸린 생성형 쓰기이므로, 원문을 볼 수 없는 사용자는
// 그 게시글(또는 그 게시글의 댓글)을 신고할 수 없다. 판정은 읽기 게이트와
// 같은 SecretContentGate(SSoT)를 쓴다.
$parentPost = $targetType === 'post'
? $target
: $this->postRepository->findByBoardId($boardId, (int) $target->post_id);
// 비밀글일 때만 board 를 붙인다 — 게이트의 슬러그 해석이 라우트에 없으면 관계로
// 폴백하고, 미로딩이면 fail-closed 다. 비밀글이 아니면 게이트는 언제나 통과한다.
if ($parentPost && $parentPost->is_secret) {
if (! $parentPost->relationLoaded('board')) {
$parentPost->load('board');
}
if (! app(SecretContentGate::class)->canWriteChild($parentPost)) {
return false;
}
}
return true;
}
@@ -6,6 +6,7 @@ use App\Enums\PermissionType;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Services\PostService;
use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
/**
@@ -19,13 +20,23 @@ use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
* 열람 가능 조건 (우선순위 순):
* 1. 작성자 본인 (회원 게시글)
* 2. 비밀번호 검증 완료 (`password_verified` 플래그 — 상세 컨텍스트에서만 설정, 리스트 미적용)
* 2-b. 비밀번호 검증 응답으로 받은 열람 확인 토큰 (`X-Board-Secret-View-Token` 헤더)
* 3. 게시판별 비밀글 읽기 권한 (posts.read-secret)
* 4. 게시판 관리자 권한 (Admin: admin.manage / User: manager)
*
* 2 와 2-b 는 같은 사실("비밀번호를 맞혔다")의 두 표현입니다. 2 는 검증한 그 응답 안에서만
* 살고, 2-b 는 그 사실을 다음 요청으로 넘깁니다 — 비회원 작성자가 자기 비밀글에 댓글을 다는
* 흐름은 별도 요청이라 2-b 가 없으면 성립하지 않습니다.
*/
class SecretContentGate
{
use ChecksBoardPermission;
/**
* 열람 확인 토큰을 싣는 요청 헤더 이름.
*/
public const VIEW_TOKEN_HEADER = 'X-Board-Secret-View-Token';
/**
* 주어진 게시글의 비밀 원문을 현재 요청자가 열람할 수 있는지 판정합니다.
*
@@ -57,6 +68,16 @@ class SecretContentGate
return false;
}
// 2-b. 직전 요청에서 비밀번호를 맞히고 받은 열람 확인 토큰.
//
// 위 2번 플래그는 비밀번호를 검증한 그 응답 안에서만 삽니다. 그런데 댓글·답글·신고는
// 각각 별도 요청이라, 토큰이 없으면 원문을 연 사람도 그 사실을 증명할 방법이 없어
// 화면이 내준 버튼이 전부 거부됩니다. 토큰은 게시글 단위로 묶이고 유효기간이 있어
// 권한 범위는 비밀번호를 아는 것과 같습니다.
if ($this->hasSecretViewToken($post, $request, $slug)) {
return true;
}
if ($this->isAdminRequest($request)) {
return $this->checkBoardPermission($slug, 'admin.posts.read-secret')
|| $this->checkBoardPermission($slug, 'admin.manage');
@@ -66,6 +87,52 @@ class SecretContentGate
|| $this->checkBoardPermission($slug, 'manager', PermissionType::User);
}
/**
* 비밀글의 하위 콘텐츠를 생성할 수 있는지 판정합니다.
*
* 읽기 게이트(KVE-2026-1914)는 원문 노출만 막았고, 하위 생성 경로(댓글·대댓글·답글·
* 신고)는 부모 게시글의 열람 권한을 재적용하지 않아 무권한 사용자가 비밀글에 댓글을
* 달거나 신고를 남길 수 있었다(KVE-2026-2044). 판정은 읽기와 같은 canView() 를
* 재사용해 강도 분기를 만들지 않는다 — 작성자·비밀글 읽기 권한자·게시판 관리자는
* 그대로 통과한다.
*
* 비밀글이 아니면 항상 통과한다.
*
* @param Post $post 부모 게시글
* @param Request|null $request HTTP 요청 (미지정 시 현재 요청)
* @return bool 하위 콘텐츠 생성 가능 여부
*/
public function canWriteChild(Post $post, ?Request $request = null): bool
{
if (! $post->is_secret) {
return true;
}
return $this->canView($post, $request);
}
/**
* 요청이 제시한 열람 확인 토큰이 이 게시글에 대해 유효한지 판정합니다.
*
* 토큰은 헤더로만 받습니다 — 본문으로 받으면 댓글·답글·신고 각각의 FormRequest 에
* 필드를 더해야 하고, 그 중 한 곳만 빠져도 그 경로에서만 조용히 거부됩니다.
*
* @param Post $post 대상 게시글
* @param Request $request HTTP 요청
* @param string $slug 게시판 슬러그
* @return bool 유효한 토큰이 제시되었으면 true
*/
private function hasSecretViewToken(Post $post, Request $request, string $slug): bool
{
$token = $request->header(self::VIEW_TOKEN_HEADER);
if (! is_string($token) || $token === '' || ! $post->id) {
return false;
}
return app(PostService::class)->hasValidSecretViewToken($slug, (int) $post->id, $token);
}
/**
* 게시판 슬러그를 해석합니다.
*
@@ -123,6 +123,7 @@ return [
// Comment availability
'post_blinded' => 'You cannot comment on a blinded post.',
'post_deleted' => 'You cannot comment on a deleted post.',
'post_secret' => 'You cannot comment on a secret post you are not allowed to view.',
],
// Additional comment messages
@@ -259,6 +259,7 @@ return [
'not_found' => 'Parent post not found.',
'blinded' => 'Cannot create reply on blinded post.',
'deleted' => 'Cannot create reply on deleted post.',
'secret' => 'Cannot create reply on a secret post you are not allowed to view.',
'depth_exceeded' => 'This board allows replies up to :max level(s) only.',
'notice_not_allowed' => 'Replies cannot be created on notice posts.',
],
@@ -445,6 +446,7 @@ return [
'not_found' => 'Post not found.',
'blinded' => 'Cannot create comment on blinded post.',
'deleted' => 'Cannot create comment on deleted post.',
'secret' => 'Cannot create comment on a secret post you are not allowed to view.',
],
'parent_id' => [
'exists' => 'Parent comment does not exist.',
@@ -123,6 +123,7 @@ return [
// 댓글 작성 가능 여부
'post_blinded' => '블라인드 처리된 게시글에는 댓글을 작성할 수 없습니다.',
'post_deleted' => '삭제된 게시글에는 댓글을 작성할 수 없습니다.',
'post_secret' => '열람 권한이 없는 비밀글에는 댓글을 작성할 수 없습니다.',
],
// 댓글 관련 추가 메시지
@@ -259,6 +259,7 @@ return [
'not_found' => '원글을 찾을 수 없습니다.',
'blinded' => '블라인드 처리된 게시글에는 답글을 작성할 수 없습니다.',
'deleted' => '삭제된 게시글에는 답글을 작성할 수 없습니다.',
'secret' => '열람 권한이 없는 비밀글에는 답글을 작성할 수 없습니다.',
'depth_exceeded' => '이 게시판은 답글을 :max단계까지만 허용합니다.',
'notice_not_allowed' => '공지사항에는 답글을 작성할 수 없습니다.',
],
@@ -445,6 +446,7 @@ return [
'not_found' => '게시글을 찾을 수 없습니다.',
'blinded' => '블라인드 처리된 게시글에는 댓글을 작성할 수 없습니다.',
'deleted' => '삭제된 게시글에는 댓글을 작성할 수 없습니다.',
'secret' => '열람 권한이 없는 비밀글에는 댓글을 작성할 수 없습니다.',
],
'parent_id' => [
'exists' => '존재하지 않는 댓글입니다.',
@@ -0,0 +1,710 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature;
// 테스트 베이스 클래스 수동 require (autoload 전에 로드 필요)
require_once __DIR__.'/../ModuleTestCase.php';
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Exceptions\PostNotCommentableException;
use Modules\Sirsoft\Board\Services\CommentService;
use Modules\Sirsoft\Board\Support\SecretContentGate;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* 비밀글 하위 쓰기 게이트 테스트 (KVE-2026-2044 + 동형 전수).
*
* 읽기 경로는 이미 게이트되어 있었지만(비밀글 원문 마스킹), 하위 생성 경로는 부모
* 게시글의 열람 권한을 재적용하지 않았다. 그래서 원문을 볼 수 없는 사용자가
* 비밀글에 댓글·대댓글·답글을 달고 신고까지 남길 수 있었다.
*
* 대상 경로 6종:
* - C1 댓글 생성 (CommentService 최종 관문)
* - C2 댓글 생성 (요청 단계 규칙)
* - C3 대댓글 생성 (부모 게시글 비밀 여부)
* - C4 답글 생성 (부모 게시글 비밀 여부)
* - C5 게시글 신고
* - C6 댓글 신고
*
* 통과해야 하는 주체: 작성자 본인 · 게시판 관리자(manager) · 비밀글 읽기 권한자.
*
* @scenario secret-post-child-write-gate
*
* @effects secret_post_comment_blocked_for_outsider,
* secret_post_reply_comment_blocked_for_outsider,
* secret_post_reply_post_blocked_for_outsider,
* secret_post_report_blocked_for_outsider,
* secret_comment_report_blocked_for_outsider,
* secret_post_comment_allowed_for_author,
* secret_post_comment_allowed_for_manager,
* normal_post_comment_unaffected
*/
class SecretPostChildWriteGateTest extends BoardTestCase
{
private User $outsider;
private User $author;
private User $manager;
/**
* 테스트 게시판 slug
*/
protected function getTestBoardSlug(): string
{
return 'secret-child-write';
}
/**
* 기본 게시판 속성 (비밀글 허용 + 답글/댓글/신고 활성)
*
* @param string $slug 게시판 슬러그
* @return array<string, mixed> 게시판 속성
*/
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '비밀글 하위 쓰기', 'en' => 'Secret Child Write'],
'is_active' => true,
'secret_mode' => 'enabled',
'use_comment' => true,
'use_reply' => true,
'use_report' => true,
'max_comment_depth' => 3,
'max_reply_depth' => 3,
'blocked_keywords' => [],
];
}
/**
* 테스트 사전 준비를 수행합니다.
*/
protected function setUp(): void
{
parent::setUp();
$this->outsider = User::factory()->create();
$this->author = User::factory()->create();
$this->manager = User::factory()->create();
$this->grantDefaultGuestPermissions();
$this->grantUserRolePermissions([
'posts.read', 'posts.write', 'comments.read', 'comments.write',
]);
$userRole = Role::where('identifier', 'user')->first();
if ($userRole) {
foreach ([$this->outsider, $this->author, $this->manager] as $user) {
$user->roles()->syncWithoutDetaching([$userRole->id]);
}
}
$this->setupManagerRole();
$this->resetPermissionMiddlewareCache();
}
/**
* 게시판 관리자(manager) 역할을 만들어 manager 사용자에게 부여합니다.
*/
private function setupManagerRole(): void
{
$slug = $this->board->slug;
$permIds = [];
foreach (['manager', 'posts.read', 'posts.write', 'comments.read', 'comments.write'] as $action) {
$perm = Permission::firstOrCreate(
['identifier' => "sirsoft-board.{$slug}.{$action}"],
[
'name' => ['ko' => $action, 'en' => $action],
'slug' => "sirsoft-board.{$slug}.{$action}",
'type' => 'user',
]
);
$permIds[] = $perm->id;
}
$managerRole = Role::firstOrCreate(
['identifier' => "{$slug}-manager"],
['name' => ['ko' => '게시판 관리(사용자)', 'en' => 'Board Manager']]
);
$managerRole->permissions()->syncWithoutDetaching($permIds);
$this->manager->roles()->attach($managerRole->id);
}
/**
* 비밀 게시글을 만듭니다.
*
* @param array<string, mixed> $attributes 덮어쓸 속성
* @return int 생성된 게시글 ID
*/
private function createSecretPost(array $attributes = []): int
{
return $this->createTestPost(array_merge([
'user_id' => $this->author->id,
'author_name' => '작성자',
'title' => '비밀 게시글',
'content' => '비밀 원문입니다.',
'is_secret' => true,
'status' => 'published',
], $attributes));
}
private function commentsUrl(int $postId): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments";
}
private function postsUrl(): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts";
}
private function postReportUrl(int $postId): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/reports";
}
private function commentReportUrl(int $commentId): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/comments/{$commentId}/reports";
}
/**
* 지정 사용자로 요청 컨텍스트를 준비합니다.
*/
private function asUser(User $user): self
{
$this->resetPermissionMiddlewareCache();
$this->actingAs($user, 'sanctum');
return $this;
}
/**
* board_comments 총 건수를 반환합니다.
*/
private function commentCount(): int
{
return (int) DB::table('board_comments')->where('board_id', $this->board->id)->count();
}
/**
* board_posts 총 건수를 반환합니다.
*/
private function postCount(): int
{
return (int) DB::table('board_posts')->where('board_id', $this->board->id)->count();
}
/**
* board_reports 총 건수를 반환합니다.
*/
private function reportCount(): int
{
return (int) DB::table('boards_reports')->where('board_id', $this->board->id)->count();
}
// =========================================================================
// 차단 매트릭스 (무권한 사용자)
// =========================================================================
/**
* C1/C2 — 무권한 사용자는 비밀글에 댓글을 달 수 없다.
*
* @scenario actor=outsider, target=secret_post
*
* @effects secret_post_comment_blocked_for_outsider
*/
#[Test]
public function outsider_cannot_comment_on_secret_post(): void
{
$postId = $this->createSecretPost();
$before = $this->commentCount();
$response = $this->asUser($this->outsider)->postJson($this->commentsUrl($postId), [
'content' => '무권한 댓글',
]);
$this->assertContains($response->status(), [403, 422], '비밀글 댓글 작성은 차단되어야 한다');
$this->assertSame($before, $this->commentCount(), '차단된 요청은 댓글을 삽입하지 않아야 한다');
}
/**
* C3 — 무권한 사용자는 비밀글의 댓글에 대댓글을 달 수 없다.
*
* @scenario actor=outsider, target=secret_post_comment
*
* @effects secret_post_reply_comment_blocked_for_outsider
*/
#[Test]
public function outsider_cannot_reply_to_comment_on_secret_post(): void
{
$postId = $this->createSecretPost();
$parentCommentId = $this->createTestComment($postId, [
'user_id' => $this->author->id,
'content' => '작성자 댓글',
]);
$before = $this->commentCount();
$response = $this->asUser($this->outsider)->postJson($this->commentsUrl($postId), [
'content' => '무권한 대댓글',
'parent_id' => $parentCommentId,
]);
$this->assertContains($response->status(), [403, 422], '비밀글 대댓글 작성은 차단되어야 한다');
$this->assertSame($before, $this->commentCount());
}
/**
* C4 — 무권한 사용자는 비밀글에 답글(답변 게시글)을 달 수 없다.
*
* @scenario actor=outsider, target=secret_post
*
* @effects secret_post_reply_post_blocked_for_outsider
*/
#[Test]
public function outsider_cannot_write_reply_post_under_secret_post(): void
{
$postId = $this->createSecretPost();
$before = $this->postCount();
$response = $this->asUser($this->outsider)->postJson($this->postsUrl(), [
'title' => '무권한 답글',
'content' => '무권한 답글 내용입니다. 본문 최소 길이 검증을 넘기기 위한 충분한 길이입니다.',
'parent_id' => $postId,
]);
$this->assertContains($response->status(), [403, 422], '비밀글 답글 작성은 차단되어야 한다');
$this->assertSame($before, $this->postCount(), '차단된 요청은 게시글을 삽입하지 않아야 한다');
}
/**
* C5 — 무권한 사용자는 비밀글을 신고할 수 없다.
*
* @scenario actor=outsider, target=secret_post
*
* @effects secret_post_report_blocked_for_outsider
*/
#[Test]
public function outsider_cannot_report_secret_post(): void
{
$postId = $this->createSecretPost();
$before = $this->reportCount();
$response = $this->asUser($this->outsider)->postJson($this->postReportUrl($postId), [
'reason_type' => 'spam',
'reason_detail' => '무권한 신고',
]);
$this->assertContains($response->status(), [403, 422], '비밀글 신고는 차단되어야 한다');
$this->assertSame($before, $this->reportCount(), '차단된 요청은 신고를 남기지 않아야 한다');
}
/**
* C6 — 무권한 사용자는 비밀글에 달린 댓글을 신고할 수 없다.
*
* @scenario actor=outsider, target=secret_post_comment
*
* @effects secret_comment_report_blocked_for_outsider
*/
#[Test]
public function outsider_cannot_report_comment_on_secret_post(): void
{
$postId = $this->createSecretPost();
$commentId = $this->createTestComment($postId, [
'user_id' => $this->author->id,
'content' => '작성자 댓글',
]);
$before = $this->reportCount();
$response = $this->asUser($this->outsider)->postJson($this->commentReportUrl($commentId), [
'reason_type' => 'spam',
'reason_detail' => '무권한 신고',
]);
$this->assertContains($response->status(), [403, 422], '비밀글 댓글 신고는 차단되어야 한다');
$this->assertSame($before, $this->reportCount());
}
/**
* 요청 계층을 우회해도 서비스 최종 관문이 막는다.
*
* HTTP 경로에서는 요청 단계 규칙이 먼저 걸리므로 서비스 게이트는 **한 번도 실행되지
* 않는다**. 그래서 그 게이트가 살아 있는지는 HTTP 테스트로 증명되지 않는다.
* 훅·확장·콘솔처럼 FormRequest 를 지나지 않는 호출자가 실재하므로, 서비스를 직접
* 불러 최종 관문을 따로 고정한다 (이중 방어의 두 층을 각각 잠근다).
*
* @scenario actor=outsider, target=secret_post, layer=service
*
* @effects secret_post_comment_blocked_for_outsider
*/
#[Test]
public function the_service_layer_gate_blocks_even_when_the_request_layer_is_bypassed(): void
{
$postId = $this->createSecretPost();
$before = $this->commentCount();
$this->resetPermissionMiddlewareCache();
$this->actingAs($this->outsider, 'sanctum');
$service = app(CommentService::class);
$threw = false;
try {
$service->createComment($this->board->slug, [
'post_id' => $postId,
'content' => '요청 계층 우회 댓글',
'user_id' => $this->outsider->id,
]);
} catch (PostNotCommentableException $e) {
$threw = true;
$this->assertSame('sirsoft-board::messages.comment.post_secret', $e->getMessageKey());
}
$this->assertTrue($threw, '서비스 최종 관문이 비밀글 하위 쓰기를 막지 않았다');
$this->assertSame($before, $this->commentCount());
}
/**
* 서비스 최종 관문은 작성자에게는 열려 있다 (정상 흐름 불변).
*
* @scenario actor=author, target=secret_post, layer=service
*
* @effects secret_post_comment_allowed_for_author
*/
#[Test]
public function the_service_layer_gate_allows_the_author(): void
{
$postId = $this->createSecretPost();
$before = $this->commentCount();
$this->resetPermissionMiddlewareCache();
$this->actingAs($this->author, 'sanctum');
app(CommentService::class)->createComment($this->board->slug, [
'post_id' => $postId,
'content' => '작성자 댓글 (서비스 직접 호출)',
'user_id' => $this->author->id,
]);
$this->assertSame($before + 1, $this->commentCount());
}
// =========================================================================
// 통과 매트릭스 (정상 흐름 불변)
// =========================================================================
/**
* 작성자 본인은 자기 비밀글에 댓글을 달 수 있다.
*
* @scenario actor=author, target=secret_post
*
* @effects secret_post_comment_allowed_for_author
*/
#[Test]
public function author_can_comment_on_own_secret_post(): void
{
$postId = $this->createSecretPost();
$before = $this->commentCount();
$response = $this->asUser($this->author)->postJson($this->commentsUrl($postId), [
'content' => '작성자 댓글',
]);
$response->assertStatus(201);
$this->assertSame($before + 1, $this->commentCount());
}
/**
* 게시판 관리자는 비밀글에 댓글을 달 수 있다 (답변 시나리오).
*
* @scenario actor=manager, target=secret_post
*
* @effects secret_post_comment_allowed_for_manager
*/
#[Test]
public function manager_can_comment_on_secret_post(): void
{
$postId = $this->createSecretPost();
$before = $this->commentCount();
$response = $this->asUser($this->manager)->postJson($this->commentsUrl($postId), [
'content' => '관리자 답변',
]);
$response->assertStatus(201);
$this->assertSame($before + 1, $this->commentCount());
}
/**
* 비밀글이 아니면 종전과 같이 누구나 댓글을 달 수 있다 (회귀 방지).
*
* @scenario actor=outsider, target=public_post
*
* @effects normal_post_comment_unaffected
*/
#[Test]
public function outsider_can_still_comment_on_public_post(): void
{
$postId = $this->createTestPost([
'user_id' => $this->author->id,
'title' => '공개 게시글',
'content' => '공개 내용',
'is_secret' => false,
'status' => 'published',
]);
$before = $this->commentCount();
$response = $this->asUser($this->outsider)->postJson($this->commentsUrl($postId), [
'content' => '일반 댓글',
]);
$response->assertStatus(201);
$this->assertSame($before + 1, $this->commentCount());
}
/**
* 작성자 본인은 자기 비밀글에 답글을 달 수 있다 (정상 흐름 불변).
*
* @scenario actor=author, target=secret_post
*
* @effects secret_post_comment_allowed_for_author
*/
#[Test]
public function author_can_write_reply_post_under_own_secret_post(): void
{
$postId = $this->createSecretPost();
$before = $this->postCount();
$response = $this->asUser($this->author)->postJson($this->postsUrl(), [
'title' => '작성자 답글',
'content' => '작성자 답글 내용입니다. 본문 최소 길이 검증을 넘기기 위한 충분한 길이입니다.',
'parent_id' => $postId,
]);
$response->assertStatus(201);
$this->assertSame($before + 1, $this->postCount());
}
/**
* 비밀글이 아니면 종전과 같이 답글을 달 수 있다 (회귀 방지).
*
* @scenario actor=outsider, target=public_post
*
* @effects normal_post_comment_unaffected
*/
#[Test]
public function outsider_can_still_write_reply_post_under_public_post(): void
{
$postId = $this->createTestPost([
'user_id' => $this->author->id,
'title' => '공개 게시글',
'content' => '공개 내용',
'is_secret' => false,
'status' => 'published',
]);
$before = $this->postCount();
$response = $this->asUser($this->outsider)->postJson($this->postsUrl(), [
'title' => '일반 답글',
'content' => '일반 답글 내용입니다. 본문 최소 길이 검증을 넘기기 위한 충분한 길이입니다.',
'parent_id' => $postId,
]);
$response->assertStatus(201);
$this->assertSame($before + 1, $this->postCount());
}
/**
* 비밀글이 아니면 종전과 같이 신고할 수 있다 (회귀 방지).
*
* @scenario actor=outsider, target=public_post
*
* @effects normal_post_comment_unaffected
*/
#[Test]
public function outsider_can_still_report_public_post(): void
{
$postId = $this->createTestPost([
'user_id' => $this->author->id,
'title' => '공개 게시글',
'content' => '공개 내용',
'is_secret' => false,
'status' => 'published',
]);
$before = $this->reportCount();
$response = $this->asUser($this->outsider)->postJson($this->postReportUrl($postId), [
'reason_type' => 'spam',
'reason_detail' => '정상 신고',
]);
$response->assertStatus(201);
$this->assertSame($before + 1, $this->reportCount());
}
/**
* 비밀번호 검증 응답은 후속 요청에 쓸 열람 확인 토큰을 함께 돌려준다.
*
* @scenario actor=password_holder, target=secret_post
*
* @effects secret_view_token_issued_on_password_verify
*/
#[Test]
public function password_verification_issues_a_view_token(): void
{
$postId = $this->createSecretPost([
'user_id' => null,
'password' => bcrypt('guestPw123'),
]);
$response = $this->postJson($this->verifyPasswordUrl($postId), ['password' => 'guestPw123']);
$response->assertStatus(200);
$this->assertIsString(
$response->json('secret_view_token'),
'비밀번호 검증 응답에 열람 확인 토큰이 없습니다 — 후속 요청이 열람 사실을 증명할 수단이 사라집니다.'
);
}
/**
* 비밀번호로 원문을 연 사람은 댓글을 달 수 있다.
*
* 비회원이 쓴 비밀글의 작성자 본인은 user_id 가 없어 작성자 판정에 걸리지 않는다.
* 그에게 유일한 신원 증명이 비밀번호이므로, 이 경로가 막히면 자기 글에 댓글을
* 달 수 없다 — 화면은 원문이 열린 뒤 댓글창을 내주므로 사용자에게는 원인이 보이지 않는다.
*
* @scenario actor=password_holder, target=secret_post
*
* @effects secret_post_comment_allowed_for_password_holder
*/
#[Test]
public function password_holder_can_comment_on_secret_post(): void
{
$postId = $this->createSecretPost([
'user_id' => null,
'password' => bcrypt('guestPw123'),
]);
$token = $this->issueViewToken($postId, 'guestPw123');
$before = $this->commentCount();
$response = $this->asUser($this->outsider)
->withHeader(SecretContentGate::VIEW_TOKEN_HEADER, $token)
->postJson($this->commentsUrl($postId), [
'content' => '비밀번호로 열고 남기는 댓글입니다.',
]);
$response->assertStatus(201);
$this->assertSame($before + 1, $this->commentCount());
}
/**
* 비밀번호로 연 사람은 답글도 쓸 수 있다.
*
* @scenario actor=password_holder, target=secret_post
*
* @effects secret_post_reply_allowed_for_password_holder
*/
#[Test]
public function password_holder_can_write_reply_post_under_secret_post(): void
{
$postId = $this->createSecretPost([
'user_id' => null,
'password' => bcrypt('guestPw123'),
]);
$token = $this->issueViewToken($postId, 'guestPw123');
$before = $this->postCount();
$response = $this->asUser($this->outsider)
->withHeader(SecretContentGate::VIEW_TOKEN_HEADER, $token)
->postJson($this->postsUrl(), [
'title' => '답글 제목',
'content' => '비밀번호로 열고 남기는 답글 본문입니다.',
'parent_id' => $postId,
]);
$response->assertStatus(201);
$this->assertSame($before + 1, $this->postCount());
}
/**
* 토큰은 발급받은 게시글에만 통한다 — 다른 비밀글에는 쓰이지 않는다.
*
* 이 결속이 없으면 비밀번호를 아는 글 하나로 같은 게시판의 모든 비밀글이 열려,
* KVE-2026-2044 가 토큰이라는 다른 이름으로 되살아난다.
*
* @scenario actor=password_holder, target=other_secret_post
*
* @effects secret_view_token_is_bound_to_its_post
*/
#[Test]
public function view_token_does_not_work_on_another_post(): void
{
$openedPostId = $this->createSecretPost([
'user_id' => null,
'password' => bcrypt('guestPw123'),
]);
$otherPostId = $this->createSecretPost([
'user_id' => $this->author->id,
'title' => '남의 비밀글',
]);
$token = $this->issueViewToken($openedPostId, 'guestPw123');
$before = $this->commentCount();
$response = $this->asUser($this->outsider)
->withHeader(SecretContentGate::VIEW_TOKEN_HEADER, $token)
->postJson($this->commentsUrl($otherPostId), [
'content' => '남의 비밀글에 다는 댓글입니다.',
]);
$response->assertStatus(422);
$this->assertSame($before, $this->commentCount(), '다른 글의 토큰으로 댓글이 저장되었습니다.');
}
/**
* 위조·만료 토큰은 통하지 않는다.
*
* @scenario actor=outsider, target=secret_post
*
* @effects secret_view_token_rejects_forged_value
*/
#[Test]
public function forged_view_token_is_rejected(): void
{
$postId = $this->createSecretPost([
'user_id' => null,
'password' => bcrypt('guestPw123'),
]);
$before = $this->commentCount();
$response = $this->asUser($this->outsider)
->withHeader(SecretContentGate::VIEW_TOKEN_HEADER, str_repeat('a', 40))
->postJson($this->commentsUrl($postId), [
'content' => '위조 토큰으로 다는 댓글입니다.',
]);
$response->assertStatus(422);
$this->assertSame($before, $this->commentCount());
}
private function verifyPasswordUrl(int $postId): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/verify-password";
}
/**
* 비밀번호를 검증해 열람 확인 토큰을 발급받습니다 (실제 엔드포인트 경유).
*/
private function issueViewToken(int $postId, string $password): string
{
$response = $this->postJson($this->verifyPasswordUrl($postId), ['password' => $password]);
$response->assertStatus(200);
return (string) $response->json('secret_view_token');
}
}
@@ -0,0 +1,128 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\Upgrade;
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
use Modules\Sirsoft\Board\Upgrades\Upgrade_1_1_1;
use PHPUnit\Framework\Attributes\Test;
/**
* 1.1.1 평문 게시글 비밀번호 복구 업그레이드 스텝 테스트.
*
* 배경: 1.1.1 이전 게시글 수정 경로가 본인 확인용 `password` 를 저장 데이터로 흘려
* 기존 bcrypt 해시를 평문으로 덮었다. 소스 교정은 새로 덮이는 것만 막으므로,
* 이미 평문이 된 행은 이 백필이 없으면 작성자가 영구히 자기 글을 다룰 수 없다.
*
* 검증 목적:
* - 평문으로 남은 행이 bcrypt 해시로 교체된다
* - 교체 후에도 작성자가 알던 원래 비밀번호로 검증된다
* - 이미 해시인 행은 값이 그대로다 (재해싱하지 않는다)
* - 재실행해도 결과가 동일하다 (멱등)
*
* @group board
* @group upgrade
*/
class PlaintextPostPasswordRehashTest extends BoardTestCase
{
protected function getTestBoardSlug(): string
{
return 'plaintext-password-rehash';
}
/**
* 1.1.1 복구 스텝을 실행합니다.
*/
private function runRehash(): void
{
(new Upgrade_1_1_1)->run(new UpgradeContext('1.1.0', '1.1.1', '1.1.1', 'extension-upgrade'));
}
private function storedPassword(int $postId): string
{
return (string) DB::table('board_posts')->where('id', $postId)->value('password');
}
/**
* 평문으로 남은 행이 해시로 복구되고, 원래 비밀번호로 검증된다.
*
* @scenario stored=plaintext
*
* @effects plaintext_password_rehashed_to_bcrypt
*/
#[Test]
public function plaintext_rows_are_rehashed_and_still_verify(): void
{
$plain = 'legacyPlain123';
$postId = $this->createTestPost(['password' => $plain]);
$this->runRehash();
$stored = $this->storedPassword($postId);
$this->assertNotSame($plain, $stored, '평문이 그대로 남았다');
$this->assertSame('bcrypt', password_get_info($stored)['algoName'] ?? 'unknown');
$this->assertTrue(Hash::check($plain, $stored), '복구 후 원래 비밀번호로 검증되지 않는다');
}
/**
* 이미 해시인 행은 건드리지 않는다 (재해싱 금지).
*
* @scenario stored=bcrypt
*
* @effects already_hashed_rows_untouched
*/
#[Test]
public function already_hashed_rows_are_left_untouched(): void
{
$hashed = Hash::make('alreadyHashed123');
$postId = $this->createTestPost(['password' => $hashed]);
$this->runRehash();
$this->assertSame($hashed, $this->storedPassword($postId), '이미 해시인 행이 재해싱되었다');
}
/**
* 비밀번호가 없는 회원 게시글은 대상이 아니다.
*
* @scenario stored=null
*
* @effects rows_without_password_untouched
*/
#[Test]
public function rows_without_password_are_untouched(): void
{
$postId = $this->createTestPost(['password' => null]);
$this->runRehash();
$this->assertNull(DB::table('board_posts')->where('id', $postId)->value('password'));
}
/**
* 재실행해도 결과가 변하지 않는다 (멱등).
*
* @scenario run=twice
*
* @effects rehash_is_idempotent
*/
#[Test]
public function rerunning_does_not_change_the_result(): void
{
$plain = 'idempotent123';
$postId = $this->createTestPost(['password' => $plain]);
$this->runRehash();
$afterFirst = $this->storedPassword($postId);
$this->runRehash();
$this->assertSame($afterFirst, $this->storedPassword($postId), '재실행이 값을 바꿨다');
$this->assertTrue(Hash::check($plain, $this->storedPassword($postId)));
}
}
@@ -8,6 +8,7 @@ require_once __DIR__.'/../../ModuleTestCase.php';
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
@@ -517,7 +518,7 @@ class PostGuestTest extends BoardTestCase
$this->setGuestPermissions(['posts.read', 'posts.write']);
// 테스트용 파일 생성
$file = \Illuminate\Http\UploadedFile::fake()->create('test.pdf', 100);
$file = UploadedFile::fake()->create('test.pdf', 100);
// When: 비회원이 파일과 함께 게시글 생성
$response = $this->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
@@ -541,7 +542,7 @@ class PostGuestTest extends BoardTestCase
// Given: 비회원 파일 업로드 권한 있음 (기본 설정)
// 테스트용 파일 생성
$file = \Illuminate\Http\UploadedFile::fake()->create('test.pdf', 100);
$file = UploadedFile::fake()->create('test.pdf', 100);
// When: 비회원이 파일과 함께 게시글 생성
$response = $this->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
@@ -609,4 +610,45 @@ class PostGuestTest extends BoardTestCase
$postIds = array_column($posts, 'id');
$this->assertNotContains($postId, $postIds);
}
}
/**
* 회귀 — 비회원 게시글을 수정해도 저장된 비밀번호가 해시로 유지되어야 한다.
*
* 결함: 수정 요청의 `password` 는 본인 확인용 자격증명인데, 컨트롤러가 그것을 그대로
* 저장 데이터로 넘겨 기존 bcrypt 해시를 **평문으로 덮었다**. 그 결과 ① 비회원 게시글
* 비밀번호가 평문으로 DB 에 남고 ② 이후 비밀번호 검증이 "bcrypt 가 아니다" 예외로
* 끝나 본인이 자기 글을 수정·삭제할 수 없게 됐다.
*
* 같은 자리에서 댓글 수정 경로는 이미 password/verification_token 을 저장 데이터에서
* 제거하고 있었다 — 게시글 경로만 빠져 있던 비대칭이다.
*/
public function test_guest_post_password_stays_hashed_after_update(): void
{
$password = 'staysHashed123';
$createResponse = $this->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
'title' => '비밀번호 보존 확인',
'content' => '초기 내용입니다. 최소 10자 이상.',
'author_name' => '테스트비회원',
'password' => $password,
]);
$createResponse->assertStatus(201);
$postId = $createResponse->json('data.id');
$this->putJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}", [
'title' => '수정 후 비밀번호 보존 확인',
'content' => '수정된 내용입니다.',
'password' => $password,
])->assertStatus(200);
$stored = (string) DB::table('board_posts')->where('id', $postId)->value('password');
$this->assertNotSame($password, $stored, '수정 후 비밀번호가 평문으로 저장되었다');
$this->assertSame(
'bcrypt',
password_get_info($stored)['algoName'] ?? 'unknown',
'수정 후 저장값이 해시 형식이 아니다 — 이후 본인 확인이 예외로 끝난다'
);
$this->assertTrue(Hash::check($password, $stored), '수정 후 원래 비밀번호로 검증되지 않는다');
}
}
@@ -10,6 +10,7 @@ use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Modules\Sirsoft\Board\Services\PostService;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
@@ -504,4 +505,158 @@ class PostSecretVerifyPasswordTest extends BoardTestCase
// Then: 404 에러
$response->assertStatus(404);
}
/**
* 비밀번호 검증 응답은 상세 조회와 같은 스키마다 — 댓글 목록을 포함한다.
*
* 화면은 이 응답으로 게시글 데이터소스를 통째로 교체한다. 그래서 응답에 `comments` 가
* 없으면 목록이 비어 보이는데, 댓글 수(`comment_count`)는 집계 컬럼이라 그대로 남아
* "댓글 2" 헤더 밑에 아무것도 없는 화면이 된다. 오류도 콘솔 경고도 남지 않고, 댓글을
* 하나 쓰면 그때 상세를 다시 불러 목록이 나타나므로 원인을 짚기 어렵다.
*/
public function test_verify_password_response_includes_comments(): void
{
// Given: 댓글 2개가 달린 비회원 비밀글
$postId = $this->createGuestSecretPost('test1234');
$this->createTestComment($postId, ['content' => '첫 번째 댓글']);
$this->createTestComment($postId, ['content' => '두 번째 댓글']);
// When: 올바른 비밀번호로 검증
$response = $this->postJson(
"/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/verify-password",
['password' => 'test1234']
);
// Then: 상세 조회와 동일하게 댓글이 실려 온다
$response->assertStatus(200);
$comments = $response->json('data.comments');
$this->assertIsArray(
$comments,
'비밀번호 검증 응답에 comments 가 없습니다 — 화면이 이 응답으로 데이터소스를 교체하므로 댓글 목록이 통째로 사라집니다.'
);
$this->assertCount(2, $comments);
// comment_count 는 게시글 행의 집계 컬럼이라 이 픽스처(DB 직접 삽입)에서는 오르지
// 않는다 — 실제 화면에서 "댓글 2" 헤더 아래가 비어 보였던 것이 이 두 값의 어긋남이다.
// 여기서 잠그는 계약은 "목록이 응답에 실린다" 쪽이다.
}
/**
* 수정 검증 토큰은 폼을 불러온 뒤에도 저장에 쓸 수 있어야 한다.
*
* 실제 화면 흐름은 「비밀번호 확인 → 폼 조회(form-meta / form-data) → 저장(PUT)」이다.
* 폼 조회가 토큰을 소비해 버리면 저장 시점에는 남아 있지 않아, 사용자는 비밀번호를
* 정확히 넣고 본문까지 본 상태에서 「게시글 수정 권한이 없습니다」로 거부된다.
* 토큰을 새로 받을 방법도 화면에 없어 그 글은 수정 자체가 불가능해진다.
*/
public function test_modify_token_survives_form_load_and_authorizes_update(): void
{
$postId = $this->createGuestSecretPost('test1234');
// 1) 비밀번호 확인 → 토큰 발급
$verify = $this->postJson(
"/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/verify-password-for-modify",
['password' => 'test1234']
);
$verify->assertStatus(200);
$token = $verify->json('data.verification_token');
$this->assertNotEmpty($token);
// 2) 화면이 하는 대로 폼 메타·데이터를 그 토큰으로 조회
$this->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/form-meta?post_id={$postId}&verification_token={$token}")
->assertStatus(200);
$this->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/form-data?post_id={$postId}&verification_token={$token}")
->assertStatus(200);
// 3) 같은 토큰으로 저장
$update = $this->putJson(
"/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}",
[
'title' => '수정된 제목',
'content' => '수정된 본문입니다.',
'verification_token' => $token,
]
);
$this->assertSame(
200,
$update->status(),
'폼을 불러온 뒤 같은 토큰으로 저장할 수 없습니다 — 폼 조회가 토큰을 소비해 버린 것입니다.'
);
}
/**
* 수정 검증 토큰을 헤더로 제시해도 폼 조회와 저장이 모두 성립한다.
*
* 이 토큰은 종전에 GET 쿼리 파라미터로만 다녔다 — 그러면 웹서버 접근 기록에 자격증명이
* 그대로 남는다. 헤더로 옮기되 기존 본문/쿼리 경로도 계속 받아야 하므로, 헤더 단독으로도
* 「폼 메타 → 폼 데이터 → 저장」 전 구간이 성립하는지를 이 테스트가 잠근다.
*
* @scenario actor=guest_author, transport=header
*
* @effects modify_token_accepted_via_header
*/
public function test_modify_token_is_accepted_via_header_without_query_param(): void
{
$postId = $this->createGuestSecretPost('test1234');
$base = "/api/modules/sirsoft-board/boards/{$this->board->slug}";
$verify = $this->postJson("{$base}/posts/{$postId}/verify-password-for-modify", ['password' => 'test1234']);
$verify->assertStatus(200);
$token = (string) $verify->json('data.verification_token');
$this->assertNotEmpty($token);
$header = [PostService::VERIFY_TOKEN_HEADER => $token];
$meta = $this->withHeaders($header)->getJson("{$base}/posts/form-meta?post_id={$postId}");
$meta->assertStatus(200);
$this->assertFalse(
(bool) $meta->json('data.requires_password'),
'헤더로 제시한 검증 토큰이 폼 메타에서 인정되지 않았습니다 — 화면이 비밀번호를 다시 묻습니다.'
);
$data = $this->withHeaders($header)->getJson("{$base}/posts/form-data?post_id={$postId}");
$data->assertStatus(200);
$this->assertNotEmpty(
$data->json('data.content'),
'헤더로 제시한 검증 토큰이 폼 데이터에서 인정되지 않아 본문이 비어 있습니다.'
);
$update = $this->withHeaders($header)->putJson("{$base}/posts/{$postId}", [
'title' => '헤더 경로 수정',
'content' => '헤더로만 토큰을 제시해 저장합니다.',
]);
$this->assertSame(
200,
$update->status(),
'헤더로만 제시한 검증 토큰으로는 저장할 수 없습니다 — 주소에서 자격증명을 뺄 수 없습니다.'
);
}
/**
* 삭제된 비밀글은 비밀번호를 알아도 열리지 않는다 (manager 권한자 제외).
*
* 상세 조회(show)는 삭제된 글에 manager 권한을 요구한다. 검증 경로가 그 판정을 하지
* 않으면 같은 원문이 형제 엔드포인트로 새어나간다 — 화면에서는 목록에 안 보이니
* 드러나지 않고, 주소를 아는 쪽만 열 수 있다.
*/
public function test_verify_password_does_not_open_deleted_post(): void
{
$postId = $this->createGuestSecretPost('test1234');
DB::table('board_posts')->where('id', $postId)->update(['deleted_at' => now()]);
$response = $this->postJson(
"/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/verify-password",
['password' => 'test1234']
);
$this->assertContains(
$response->status(),
[403, 404],
'삭제된 비밀글이 비밀번호만으로 열렸습니다 — 상세 조회는 manager 권한을 요구합니다.'
);
}
}
@@ -0,0 +1,18 @@
<?php
namespace Modules\Sirsoft\Board\Upgrades;
use App\Extension\AbstractUpgradeStep;
/**
* Board 모듈 1.1.1 업그레이드 스텝
*
* 비회원 게시글 수정 시 본인 확인용 비밀번호가 저장 데이터로 흘러 기존 해시를
* 평문으로 덮던 결함의 잔존 데이터를 복구한다. 소스 교정만으로는 이미 평문이 된
* 행이 낫지 않으며, 그 글의 작성자는 계속 수정·삭제를 할 수 없다.
*
* 모든 비즈니스 로직은 data/1.1.1/migrations/ 로 격리(AbstractUpgradeStep 규약).
*
* @upgrade-path A
*/
class Upgrade_1_1_1 extends AbstractUpgradeStep {}
@@ -0,0 +1,82 @@
<?php
namespace App\Upgrades\Data\Ext\Modules\SirsoftBoard\V1_1_1\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 평문으로 남은 비회원 게시글 비밀번호를 해시로 복구합니다.
*
* 1.1.1 이전에는 게시글 수정 요청의 `password`(본인 확인용 자격증명)가 저장 데이터로
* 그대로 흘러 기존 bcrypt 해시를 평문으로 덮었다. 그래서 한 번이라도 수정된 비회원
* 게시글은 ① 비밀번호가 평문으로 DB 에 남고 ② 이후 본인 확인이 "bcrypt 가 아니다"
* 예외로 끝나 작성자가 자기 글을 수정·삭제할 수 없다.
*
* 소스 교정은 새로 덮이는 것만 막는다 — 이미 평문이 된 행은 이 백필이 없으면 영구히
* 그 상태로 남는다. 평문을 그대로 해싱하므로 작성자가 알던 비밀번호는 그대로 동작한다.
*
* 멱등: 이미 bcrypt 인 행은 건너뛴다. 재실행해도 결과가 변하지 않는다.
*
* V-1 안전: 판정은 PHP 내장 password_get_info, 해싱은 password_hash(PASSWORD_BCRYPT)
* 로 수행한다 — 코어 Hash 파사드를 거치지 않으므로 업그레이드 시점의 hashing 드라이버
* 설정이나 코어 클래스 변경에 영향을 받지 않는다(버전 스냅샷 규약).
*/
class RehashPlaintextPostPasswords implements DataMigration
{
private const POSTS_TABLE = 'board_posts';
/**
* 마이그레이션 이름을 반환합니다.
*/
public function name(): string
{
return 'RehashPlaintextPostPasswords';
}
/**
* 평문 비밀번호를 bcrypt 해시로 교체합니다.
*
* @param UpgradeContext $context 업그레이드 컨텍스트
*/
public function run(UpgradeContext $context): void
{
if (! Schema::hasTable(self::POSTS_TABLE) || ! Schema::hasColumn(self::POSTS_TABLE, 'password')) {
$context->logger->warning('[board:1.1.1] board_posts.password 미존재 — 스킵');
return;
}
$rehashed = 0;
$alreadyHashed = 0;
DB::table(self::POSTS_TABLE)
->whereNotNull('password')
->where('password', '!=', '')
->orderBy('id')
->select('id', 'password')
->chunkById(200, function ($posts) use (&$rehashed, &$alreadyHashed) {
foreach ($posts as $post) {
$stored = (string) $post->password;
if ((password_get_info($stored)['algoName'] ?? 'unknown') === 'bcrypt') {
$alreadyHashed++;
continue;
}
DB::table(self::POSTS_TABLE)
->where('id', $post->id)
->update(['password' => password_hash($stored, PASSWORD_BCRYPT)]);
$rehashed++;
}
});
$context->logger->info(
"[board:1.1.1] 게시글 비밀번호 복구: 재해싱 {$rehashed} / 이미 해시 {$alreadyHashed}"
);
}
}
+1 -1
View File
@@ -195,7 +195,7 @@ CRUD 를 바꾸고 싶으면 이 4종 중 하나를 잡으면 되고, 이 모듈
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 400개 | `modules/_bundled/sirsoft-ecommerce/tests` |
| PHPUnit | 401개 | `modules/_bundled/sirsoft-ecommerce/tests` |
| Vitest | 140개 | `vitest.config.ts` |
| Playwright | 42개 | `tests/Playwright` |
| 시나리오 매니페스트 | 92개 | `tests/scenarios` |
@@ -20,6 +20,8 @@
### Fixed
- 장바구니에서 수량을 바꾸면 배송 불가 안내와 주문 차단이 풀리던 문제를 수정했습니다. 선택한 배송 국가로 보낼 수 없는 상품이 담겨 있으면 주문이 막혀야 하는데, 수량을 한 번만 조정해도 그 잠금이 사라져 그대로 주문할 수 있었습니다. 수량 변경 응답이 장바구니 조회 응답보다 적은 정보를 담고 있었던 것이며, 이제 두 응답이 같은 내용을 돌려줍니다.
- 비회원 주문의 결제 취소 기록이 주문번호만으로 실행되던 문제를 수정했습니다. 종전에는 로그인하지 않은 누구든 주문번호만 알면 다른 사람의 비회원 주문을 결제 취소 상태로 바꿀 수 있었습니다. 이제 비회원 주문 조회에 쓰는 것과 같은 본인 확인 절차를 거친 경우에만 처리되며, 그 밖의 요청은 주문을 찾을 수 없다는 응답으로 차단됩니다. 회원 주문은 종전과 같이 본인만 가능합니다. 비회원이 결제창에서 결제를 취소한 경우에는 주문 직후 발급된 본인 확인 정보로 종전처럼 취소 이력이 남습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2041)
- 상세설명을 편집기(HTML)로 작성한 상품을 등록하거나 수정할 때 저장이 실패하던 문제를 수정했습니다. 상품 설명의 보안 정화에 쓰는 구성요소가 모듈 설치 폴더 안에 자기 캐시 파일을 만들려 했기 때문에, 보안상 모듈 폴더에 쓰기를 막아 둔 서버에서는 저장이 항상 오류로 끝났고 다시 시도해도 같은 결과였습니다. 이제 이 캐시는 `storage` 폴더 아래에 만들어지며, 그 위치마저 쓸 수 없는 경우에는 캐시 없이 정화만 수행해 저장이 실패하지 않습니다(설명은 종전과 똑같이 정화됩니다). (#125 @lyg-kaban 님께서 제보해주셨습니다.)
### Changed
@@ -902,6 +902,11 @@ _단건 응답: `data` 객체의 필드._
| items | array | `[{"id":962,"quantity":2, …}]` | 수량 반영 후의 장바구니 전체 아이템 목록 (CartItemResource — 필드 구성은 POST `/cart` 의 "장바구니 아이템 필드" 표와 동일. 프론트가 refetch 없이 화면을 갱신하도록 전체를 함께 반환) |
| item_count | integer | `1` | 장바구니 아이템 개수 |
| calculation | object | `{"items":[…],"summary":{…},"promotions":{…},"validation_errors":[]}` | 선택 아이템 기준 금액 계산 결과 (GET `/cart` 의 `calculation` 과 동일 구조 — 소계·할인·배송비·적립·최종 결제금액 등) |
| item_ids | array | `[962]` | 장바구니 아이템 ID 목록 |
| has_unshippable_items | boolean | `false` | 선택된 배송 국가로 보낼 수 없는 상품이 1건이라도 있으면 `true` (주문 전체 차단 플래그) |
| selected_shipping_country | string | `KR` | 현재 선택된 배송 국가 코드 |
> 이 응답은 GET `/cart` 와 **같은 키 집합**입니다. 화면이 이 응답으로 장바구니 데이터를 통째로 교체하므로(refetch 제거), 한쪽에만 있는 키가 생기면 수량을 바꾸는 순간 그 값이 사라집니다 — 특히 `has_unshippable_items` 가 빠지면 주문 차단이 조용히 풀립니다.
**응답 예시**
@@ -1850,7 +1850,11 @@ HTTP/1.1 200
<!-- @generated:end -->
**설명** 회원/비회원이 PG 결제창을 닫았을 때 결제 취소 이력만 기록합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `Public\OrderController@cancelPayment`가 `OrderProcessingService::recordPaymentCancellation()`으로 주문 상태는 변경하지 않고 `order_payments`에 취소창 닫힘 이력(`cancel_code`·`cancel_message`)만 남깁니다. 결제 SDK가 사용자 취소 콜백을 받았을 때 프론트가 호출해 결제 시도 이력을 추적하는 용도입니다.
**설명** 회원/비회원이 PG 결제창을 닫았을 때 결제 취소 이력만 기록합니다.
**비회원 요청은 `X-Guest-Order-Token` 헤더가 필요합니다.** 이 경로는 `optional.sanctum` 으로 회원/비회원이 공유하므로 소유권 판정이 분기됩니다 — 회원 주문은 로그인 본인만, 비회원 주문(`user_id` 없음)은 비회원 주문 조회에서 발급받은 토큰이 그 주문번호와 일치할 때만 통과합니다. 토큰이 없거나 만료·위조·다른 주문의 것이면 정보 노출을 막기 위해 존재하지 않는 주문과 동일하게 `404` 를 반환합니다. 토큰 발급은 비회원 주문 조회 인증(`POST /api/modules/sirsoft-ecommerce/guest/orders/authenticate`)이 담당합니다.
`optional.sanctum`으로 회원/비회원 모두 접근하며, `Public\OrderController@cancelPayment`가 `OrderProcessingService::recordPaymentCancellation()`으로 주문 상태는 변경하지 않고 `order_payments`에 취소창 닫힘 이력(`cancel_code`·`cancel_message`)만 남깁니다. 결제 SDK가 사용자 취소 콜백을 받았을 때 프론트가 호출해 결제 시도 이력을 추적하는 용도입니다.
### GET /api/modules/sirsoft-ecommerce/user/orders
@@ -99,15 +99,11 @@ class CartController extends PublicBaseController
selectedCartIds: $selectedIds
);
return ResponseHelper::moduleSuccess('sirsoft-ecommerce', 'messages.cart.fetched', [
'items' => CartItemResource::collection($result->items),
'item_ids' => $result->items->pluck('id')->values()->toArray(),
'item_count' => $result->count(),
'calculation' => $result->calculation->toArray(),
// 선택된 배송국가로 배송 불가한 상품이 1개라도 있으면 주문 전체 차단 플래그 (D1 — layer 1)
'has_unshippable_items' => $this->hasUnshippableItems($result->items),
'selected_shipping_country' => ResolveShippingCountry::getCountry(),
]);
return ResponseHelper::moduleSuccess(
'sirsoft-ecommerce',
'messages.cart.fetched',
$this->buildCartPayload($result)
);
} catch (Exception $e) {
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
@@ -216,11 +212,11 @@ class CartController extends PublicBaseController
selectedCartIds: $selectedIds
);
return ResponseHelper::moduleSuccess('sirsoft-ecommerce', 'messages.cart.quantity_updated', [
'items' => CartItemResource::collection($result->items),
'item_count' => $result->count(),
'calculation' => $result->calculation->toArray(),
]);
return ResponseHelper::moduleSuccess(
'sirsoft-ecommerce',
'messages.cart.quantity_updated',
$this->buildCartPayload($result)
);
} catch (CartUnavailableException $e) {
// 판매불가/재고/구매수량 한도 위반 — generic 500 이 아닌 사유별 422 매핑
return $this->cartUnavailableResponse($e);
@@ -523,6 +519,31 @@ class CartController extends PublicBaseController
);
}
/**
* 장바구니 응답 페이로드를 조립합니다.
*
* 조회와 수량 변경이 같은 조립기를 쓴다. 화면은 수량 변경 응답으로 장바구니 데이터소스를
* **통째로 교체**하므로(refetch 제거), 두 응답의 키 집합이 갈라지면 조회에만 있던 값이
* 수량을 한 번 바꾸는 순간 사라진다. 특히 `has_unshippable_items` 는 배송 불가 상품이
* 담겼을 때 주문 버튼을 잠그는 값이라, 사라지면 그 잠금이 조용히 풀린다 — 요청은 정상
* 성공하고 오류도 경고도 남지 않는다.
*
* @param mixed $result getCartWithCalculation 결과 (items / count / calculation)
* @return array<string, mixed> 응답 페이로드
*/
protected function buildCartPayload($result): array
{
return [
'items' => CartItemResource::collection($result->items),
'item_ids' => $result->items->pluck('id')->values()->toArray(),
'item_count' => $result->count(),
'calculation' => $result->calculation->toArray(),
// 선택된 배송국가로 배송 불가한 상품이 1개라도 있으면 주문 전체 차단 플래그 (D1 — layer 1)
'has_unshippable_items' => $this->hasUnshippableItems($result->items),
'selected_shipping_country' => ResolveShippingCountry::getCountry(),
];
}
/**
* 선택된 배송국가로 배송 불가한 상품이 카트에 1개라도 있는지 판정합니다. (D1 — layer 1)
*
@@ -9,6 +9,8 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Services\GuestOrderAuthService;
/**
* 결제 취소 기록 요청
@@ -45,14 +47,14 @@ class CancelPaymentRequest extends FormRequest
/**
* 추가 검증 로직
*
* @param Validator $validator
* @param Validator $validator
* @return void
*/
protected function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator) {
$orderNumber = $this->route('orderNumber');
$this->order = Order::where('order_number', $orderNumber)->first();
$this->order = app(OrderRepositoryInterface::class)->findByOrderNumber((string) $orderNumber);
if (! $this->order) {
abort(ResponseHelper::moduleError(
@@ -62,14 +64,33 @@ class CancelPaymentRequest extends FormRequest
));
}
// 소유권 검증: 회원 주문은 본인만, 비회원 주문은 주문번호로 접근 허용
$userId = Auth::id();
if ($this->order->user_id !== null && $this->order->user_id !== $userId) {
abort(ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.order_not_found',
404
));
// 소유권 검증
// - 회원 주문: 본인만 통과.
// - 비회원 주문: 보호된 게스트 경로(guest/orders/*)와 동일하게 X-Guest-Order-Token 을 요구한다.
// 주문번호만으로 통과시키면 익명 요청이 결제를 cancelled 로 영속 변경할 수 있다.
// 미들웨어(VerifyGuestOrderToken)를 라우트에 붙이지 않는 이유는 그 미들웨어에
// 회원 pass-through 가 없어 공유 라우트에서 로그인 회원이 404 가 되기 때문이다.
if ($this->order->user_id !== null) {
if ($this->order->user_id !== Auth::id()) {
abort(ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.order_not_found',
404
));
}
} else {
$verified = app(GuestOrderAuthService::class)->verifyToken(
$this->header('X-Guest-Order-Token'),
(string) $orderNumber
);
if (! $verified) {
abort(ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.order_not_found',
404
));
}
}
if ($this->order->order_status !== OrderStatusEnum::PENDING_ORDER) {
@@ -84,7 +105,7 @@ class CancelPaymentRequest extends FormRequest
/**
* 검증 실패 시 응답 커스터마이징
*
* @param Validator $validator
* @param Validator $validator
* @return void
*
* @throws ValidationException
@@ -266,6 +266,51 @@ class CartControllerTest extends ModuleTestCase
$this->assertEquals(1, $response->json('data.item_count'));
}
/**
* 수량 변경 응답은 장바구니 조회 응답과 같은 키 집합이어야 합니다.
*
* 화면은 수량 변경 응답으로 장바구니 데이터소스를 **통째로 교체**한다(refetch 제거).
* 그래서 조회 응답에만 있는 키는 수량을 한 번 바꾸는 순간 사라진다. 특히
* `has_unshippable_items` 는 배송 불가 상품이 담겼을 때 주문 버튼을 잠그는 값이라,
* 사라지면 그 잠금이 조용히 풀린다 — 오류도 경고도 남지 않는다.
*/
public function test_update_quantity_response_matches_cart_query_shape(): void
{
// Given: 장바구니에 아이템이 존재
$data = $this->createProductWithOption();
$cartKey = 'ck_'.str_repeat('e', 32);
$addResponse = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [
'product_id' => $data['product']->id,
'items' => [
['product_option_id' => $data['option']->id, 'quantity' => 2],
],
], ['X-Cart-Key' => $cartKey]);
$addResponse->assertStatus(201);
$cartId = $addResponse->json('data.items.0.id');
// When: 조회와 수량 변경을 각각 호출
$queryResponse = $this->getJson('/api/modules/sirsoft-ecommerce/cart', ['X-Cart-Key' => $cartKey]);
$quantityResponse = $this->patchJson(
"/api/modules/sirsoft-ecommerce/cart/{$cartId}/quantity",
['quantity' => 3],
['X-Cart-Key' => $cartKey]
);
$queryResponse->assertStatus(200);
$quantityResponse->assertStatus(200);
// Then: 조회 응답의 키가 하나도 빠지지 않는다
$queryKeys = array_keys($queryResponse->json('data'));
$quantityKeys = array_keys($quantityResponse->json('data'));
$this->assertSame(
[],
array_values(array_diff($queryKeys, $quantityKeys)),
'수량 변경 응답에 장바구니 조회 응답의 키가 빠져 있습니다 — 화면이 데이터소스를 통째로 교체하므로 그 값들이 사라집니다.'
);
}
/**
* #90 수량을 0으로 변경 시도 시 422 에러를 반환합니다.
*/
@@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\User;
use App\Extension\HookManager;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
@@ -12,6 +13,7 @@ use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus;
use Modules\Sirsoft\Ecommerce\Enums\ProductSalesStatus;
use Modules\Sirsoft\Ecommerce\Models\Cart;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderAddress;
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Models\Product;
@@ -19,6 +21,7 @@ use Modules\Sirsoft\Ecommerce\Models\ProductOption;
use Modules\Sirsoft\Ecommerce\Models\TempOrder;
use Modules\Sirsoft\Ecommerce\Models\UserAddress;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Services\GuestOrderAuthService;
use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
@@ -822,14 +825,24 @@ class UserOrderControllerTest extends ModuleTestCase
}
/**
* 비로그인 사용자가 비회원 주문 결제 취소 기록 성공
* 비로그인 사용자가 유효한 게스트 조회 토큰으로 비회원 주문 결제 취소 기록 성공
*
* 주문번호만으로는 통과하지 않는다 — 보호된 게스트 경로와 동일하게
* X-Guest-Order-Token 이 필요하다 (토큰 부재 차단은 GuestCancelPaymentAuthTest).
*/
public function test_비로그인_사용자_비회원_주문_결제_취소_기록_성공(): void
{
$guestPassword = 'guest12';
$guestPhone = '010-1234-5678';
// 비회원 주문 (user_id = null)
$order = Order::factory()->create([
'user_id' => null,
'order_status' => OrderStatusEnum::PENDING_ORDER,
'guest_lookup_password_hash' => Hash::make($guestPassword),
]);
OrderAddress::factory()->shipping()->forOrder($order)->create([
'orderer_phone' => $guestPhone,
]);
OrderPayment::factory()->create([
'order_id' => $order->id,
@@ -837,8 +850,13 @@ class UserOrderControllerTest extends ModuleTestCase
'payment_method' => PaymentMethodEnum::CARD,
]);
$token = app(GuestOrderAuthService::class)
->authenticate($order->order_number, $guestPhone, $guestPassword, '10.0.0.1')['token'];
$response = $this->postJson(
"/api/modules/sirsoft-ecommerce/orders/{$order->order_number}/cancel-payment"
"/api/modules/sirsoft-ecommerce/orders/{$order->order_number}/cancel-payment",
[],
['X-Guest-Order-Token' => $token]
);
$response->assertStatus(200)
@@ -0,0 +1,218 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http;
use Illuminate\Support\Facades\Hash;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderAddress;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Services\GuestOrderAuthService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 비회원 결제취소 기록 인증 회귀 테스트 (KVE-2026-2041).
*
* 회귀 배경: `orders/{orderNumber}/cancel-payment` 은 optional.sanctum 로 회원/비회원을
* 공유하는데, 소유권 검증이 `user_id !== null` 일 때만 수행됐다. 비회원 주문(user_id=null)은
* 그 분기를 통과해 **주문번호만 아는 익명 요청**이 결제를 cancelled 로 영속 변경할 수 있었다.
* 보호된 게스트 경로(`guest/orders/*`)와 동일하게 X-Guest-Order-Token 을 요구해야 한다.
*
* @group ecommerce
* @group security
*/
class GuestCancelPaymentAuthTest extends ModuleTestCase
{
private const PASSWORD = 'guest12';
private const PHONE = '010-5555-6666';
/**
* 결제취소 기록 엔드포인트 URL 을 만듭니다.
*/
private function cancelPaymentUrl(Order $order): string
{
return "/api/modules/sirsoft-ecommerce/orders/{$order->order_number}/cancel-payment";
}
/**
* 결제 대기 상태의 비회원 주문 + 유효 토큰을 만듭니다.
*
* @return array{0: Order, 1: string}
*/
private function makeGuestOrderWithToken(string $orderNumber): array
{
$order = Order::factory()->forGuest()->create([
'order_number' => $orderNumber,
'order_status' => OrderStatusEnum::PENDING_ORDER,
'guest_lookup_password_hash' => Hash::make(self::PASSWORD),
]);
OrderAddress::factory()->shipping()->forOrder($order)->create([
'orderer_phone' => self::PHONE,
]);
OrderPayment::factory()->create([
'order_id' => $order->id,
'payment_status' => PaymentStatusEnum::READY,
'payment_method' => PaymentMethodEnum::CARD,
]);
/** @var GuestOrderAuthService $service */
$service = app(GuestOrderAuthService::class);
$token = $service->authenticate($order->order_number, self::PHONE, self::PASSWORD, '10.0.0.9')['token'];
return [$order, $token];
}
/**
* 비회원 주문에 토큰 없이 익명 요청하면 404 이고 결제 상태가 변하지 않는다.
*/
public function test_guest_order_without_token_is_rejected(): void
{
[$order] = $this->makeGuestOrderWithToken('ORD-CP-NOTOKEN');
$response = $this->postJson($this->cancelPaymentUrl($order), [
'cancel_code' => 'USER_CANCEL',
]);
$response->assertStatus(404);
$this->assertSame(
PaymentStatusEnum::READY->value,
$order->fresh()->payment->payment_status->value,
'차단된 요청은 결제 상태를 바꾸지 않아야 한다'
);
}
/**
* 위조/무효 토큰도 차단된다.
*/
public function test_guest_order_with_invalid_token_is_rejected(): void
{
[$order] = $this->makeGuestOrderWithToken('ORD-CP-BADTOKEN');
$response = $this->postJson(
$this->cancelPaymentUrl($order),
['cancel_code' => 'USER_CANCEL'],
['X-Guest-Order-Token' => '9999999999|'.str_repeat('a', 64)]
);
$response->assertStatus(404);
$this->assertSame(
PaymentStatusEnum::READY->value,
$order->fresh()->payment->payment_status->value
);
}
/**
* 다른 비회원 주문으로 발급된 토큰은 재사용할 수 없다.
*/
public function test_guest_token_of_other_order_is_rejected(): void
{
[$order] = $this->makeGuestOrderWithToken('ORD-CP-TARGET');
[, $otherToken] = $this->makeGuestOrderWithToken('ORD-CP-OTHER');
$response = $this->postJson(
$this->cancelPaymentUrl($order),
['cancel_code' => 'USER_CANCEL'],
['X-Guest-Order-Token' => $otherToken]
);
$response->assertStatus(404);
$this->assertSame(
PaymentStatusEnum::READY->value,
$order->fresh()->payment->payment_status->value
);
}
/**
* 유효한 게스트 토큰이면 정상 취소된다 (정상 흐름 불변).
*/
public function test_guest_order_with_valid_token_succeeds(): void
{
[$order, $token] = $this->makeGuestOrderWithToken('ORD-CP-VALID');
$response = $this->postJson(
$this->cancelPaymentUrl($order),
['cancel_code' => 'USER_CANCEL', 'cancel_message' => '사용자가 결제를 취소했습니다.'],
['X-Guest-Order-Token' => $token]
);
$response->assertStatus(200)->assertJsonPath('success', true);
$this->assertSame(
PaymentStatusEnum::CANCELLED->value,
$order->fresh()->payment->payment_status->value
);
}
/**
* 회원 주문의 소유자는 게스트 토큰 없이 그대로 통과한다 (회귀 방지).
*/
public function test_member_owner_still_succeeds_without_guest_token(): void
{
$user = $this->createUser();
$this->actingAs($user);
$order = Order::factory()->forUser($user)->create([
'order_status' => OrderStatusEnum::PENDING_ORDER,
]);
OrderPayment::factory()->create([
'order_id' => $order->id,
'payment_status' => PaymentStatusEnum::READY,
'payment_method' => PaymentMethodEnum::CARD,
]);
$response = $this->postJson($this->cancelPaymentUrl($order), [
'cancel_code' => 'USER_CANCEL',
]);
$response->assertStatus(200)->assertJsonPath('success', true);
$this->assertSame(
PaymentStatusEnum::CANCELLED->value,
$order->fresh()->payment->payment_status->value
);
}
/**
* 회원 주문에 타인이 접근하면 404 (기존 동작 보존).
*/
public function test_member_order_of_another_user_is_rejected(): void
{
$user = $this->createUser();
$other = $this->createUser();
$this->actingAs($user);
$order = Order::factory()->forUser($other)->create([
'order_status' => OrderStatusEnum::PENDING_ORDER,
]);
OrderPayment::factory()->create([
'order_id' => $order->id,
'payment_status' => PaymentStatusEnum::READY,
'payment_method' => PaymentMethodEnum::CARD,
]);
$response = $this->postJson($this->cancelPaymentUrl($order));
$response->assertStatus(404);
$this->assertSame(
PaymentStatusEnum::READY->value,
$order->fresh()->payment->payment_status->value
);
}
/**
* 존재하지 않는 주문번호는 404 (정보 노출 차단 — 동일 문구).
*/
public function test_unknown_order_number_is_rejected(): void
{
$response = $this->postJson(
'/api/modules/sirsoft-ecommerce/orders/ORD-CP-NOPE/cancel-payment'
);
$response->assertStatus(404);
}
}
@@ -149,8 +149,8 @@ OS 판별 후 `executeCliWindows()`/`executeCliLinux()` 로 분기 → CLI 인
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 28개 | `plugins/_bundled/sirsoft-pay_nhnkcp/tests` |
| Vitest | 8개 | `vitest.config.ts` |
| PHPUnit | 29개 | `plugins/_bundled/sirsoft-pay_nhnkcp/tests` |
| Vitest | 9개 | `vitest.config.ts` |
| Playwright | 0개 | — |
| 시나리오 매니페스트 | 1개 | `tests/scenarios` |
@@ -6,8 +6,13 @@
## [1.0.4] - 2026-09-04
### Fixed
- 비회원으로 결제하신 손님이 주문완료 화면과 비회원 주문 상세 화면에서 영수증 버튼과 결제수단 표시를 볼 수 없던 문제를 고쳤습니다. 서버는 비회원 주문을 확인해 줄 준비가 되어 있었는데 화면이 확인값을 함께 보내지 않아 조회 자체가 되지 않았고, 오류 메시지도 남지 않아 원인을 알기 어려웠습니다.
### Security
- 모바일 가상계좌 결제에서 제3자가 입금 계좌를 자기 계좌로 바꿔치기할 수 있던 문제를 수정했습니다. 모바일은 결제사가 계좌 정보를 브라우저를 거쳐 그대로 전달하는 방식이라, 주문번호만 아는 사람이 위조한 계좌 정보를 보내면 그 주문의 입금 계좌가 바뀌어 구매자가 엉뚱한 곳으로 입금할 수 있었습니다. 이제 결제창을 열 때 본인 확인을 거쳐 발급한 일회용 확인값을 결제사를 통해 되돌려 받아 대조하며, 확인값이 없거나 맞지 않는 요청은 주문을 전혀 건드리지 않고 결제 화면으로 되돌립니다. 확인값은 한 번 쓰면 소멸하므로 같은 값을 다시 보내도 통하지 않습니다. PC 결제는 종전처럼 결제사 서버 확인을 거칩니다. 업데이트를 적용하는 순간 이미 결제창이 열려 있던 모바일 가상계좌 결제 건은 확인값이 없어 실패로 처리되므로, 결제가 몰리지 않는 시간에 적용하시고 적용 직후 결제 실패 건이 있으면 확인해 주세요. (KISA 측에서 제보해주셨습니다 — KVE-2026-2019)
- 결제창 프로그램을 불러오는 주소가 NHN KCP 의 주소인지 불러오기 직전에 확인합니다. 확인되지 않는 주소면 결제를 진행하지 않고 안내를 표시합니다.
- 제3자가 남의 주문번호만 알면 결제창을 거치지 않고도 그 주문을 취소시킬 수 있던 문제를 수정했습니다. 결제 결과 콜백은 로그인도 서명 확인도 거치지 않는 경로여서, 위조한 결제 정보를 보내 승인을 일부러 실패시키면 그 주문이 결제 실패로 처리되었습니다. 이제 실제 결제 승인이 이루어진 뒤의 실패만 주문에 반영하며, 승인 전 단계의 실패는 결제 화면으로 되돌려 보내기만 합니다. 구매자가 결제창을 닫아 생기는 정상적인 결제 실패는 종전처럼 기록됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2018)
- 같은 결제 결과 콜백에서 가상계좌 경로를 통해서도 남의 주문을 건드릴 수 있던 문제를 수정했습니다. 결제수단을 가상계좌라고 주장하는 값을 요청에 섞으면, 카드로 주문한 건도 결제사 확인을 거치지 않는 경로로 흘러 그 주문이 취소되거나 위조된 입금 계좌가 그 주문에 기록될 수 있었습니다. 이제 결제수단은 요청에 실려 온 값이 아니라 주문에 저장된 값으로만 판단하며, 가상계좌 발급이 확인되지 않은 경우에는 주문을 그대로 두고 결제 화면으로 되돌려 보냅니다.
File diff suppressed because one or more lines are too long
@@ -179,13 +179,14 @@ PC 표준결제창에서 사용자가 결제를 완료하지 않고 창을 닫
| good_mny | numeric | 아니오 | 결제 금액 (min 1) |
| use_pay_method | string | 아니오 | 사용된 결제수단 |
| nhnkcp_easy_pay_method | string | 아니오 | 간편결제 수단 식별값 (max 50) |
| param_opt_2 | string | 아니오 | 모바일 가상계좌 세션 확인값 (max 50). 승인키 발급 시점에 실어 보낸 일회성 값을 KCP 가 그대로 되돌려준다 — 모바일 가상계좌 분기는 이 값이 주문에 저장된 값과 일치할 때만 계좌를 저장한다 |
| bankname · bank_name · account · depositor · account_holder · va_date | string | 아니오 | 모바일 가상계좌 콜백이 평문으로 전달하는 계좌 정보 변종 키 |
**응답**
JSON 이 아니라 **리다이렉트**다. 성공 시 상점 성공 페이지로, 실패 시 실패 페이지로 이동하며 실패 사유를 쿼리스트링(`error` · `message` · `orderId`)으로 전달한다. 쿼리 규약과 예외 원문 비노출 원칙은 [vbank.md "결제 실패 리다이렉트 규약"](vbank.md) 에 있다.
고정 `error` 값: `amount_mismatch` · `callback_locked` · `cli_exception` · `confirm_failed` · `currency_not_supported` · `invalid_payment_currency` · `order_not_found` · `order_not_retryable` · `vbank_save_failed`.
고정 `error` 값: `amount_mismatch` · `callback_locked` · `cli_exception` · `confirm_failed` · `currency_not_supported` · `invalid_payment_currency` · `order_not_found` · `order_not_retryable` · `vbank_save_failed` · `vbank_session_mismatch`.
이 밖에 KCP 가 돌려준 결과코드(`res_cd`)가 그대로 실리는 분기가 있다. 화면이 `error` 로 분기할 때는 위 고정값만 신뢰하고, 그 밖의 값은 미확정 실패로 다룬다.
@@ -196,3 +197,5 @@ KCP 표준결제창이 인증을 마치고 브라우저를 통해 가맹점으
이 문서 상단 **"주문 상태를 바꾸는 경로는 하나뿐이다"** 가 이 엔드포인트의 핵심 계약이다 — 승인 전에 판정되는 실패(금액 불일치·인증 결과코드 비정상)에서는 주문 상태를 바꾸지 않는다. 승인이 이미 일어난 뒤(`tno` 존재)의 실패만 주문에 반영하고, 그 경우 KCP 측에 잔존한 승인을 자동 취소한다.
이 엔드포인트를 수정할 때는 실패 분기마다 **"이 판정의 근거가 브라우저가 보낸 값인가"** 를 먼저 확인한다. 근거가 브라우저 입력뿐이면 주문 상태를 바꾸지 않는다.
모바일 가상계좌 분기는 서버-서버 승인이 없어 계좌 정보의 유일한 출처가 브라우저 평문이다. 그래서 평문을 읽기 **전에** `param_opt_2` 가 주문에 저장된 세션 확인값과 일치하는지 대조하고, 일치하지 않으면 `vbank_session_mismatch` 로 되돌린다. 확인값은 저장 시 소멸하므로 같은 값으로 다시 올 수 없다. 발급 지점은 [vbank.md](vbank.md) 참조.
@@ -33,6 +33,23 @@
---
## 모바일 가상계좌 세션 확인값
모바일(SmartPhone Pay) 가상계좌는 PC 와 달리 **서버-서버 승인이 없다**. KCP 가 계좌번호를 브라우저 평문 POST 로만 전달하므로, 콜백만으로는 그 값이 KCP 에서 온 것인지 확인할 수 없다.
그래서 승인키 발급(`POST /api/plugins/sirsoft-pay_nhnkcp/mobile/approval-key`, 구매자 인증 필요)이 가상계좌 요청일 때 일회성 확인값을 만들어 주문에 저장하고, 결제창 필드 `param_opt_2` 로 실어 보낸다. KCP 는 이 값을 콜백에 그대로 되돌려주므로 콜백은 저장된 값과 대조한 뒤에만 계좌를 저장한다.
| 항목 | 값 |
| --- | --- |
| 결제창 필드 | `param_opt_2` (가상계좌 요청에만 부여) |
| 콜백 수신 필드 | `param_opt_2` (string, max 50) |
| 불일치·부재 시 | 주문 상태 **무변경** + 실패 리다이렉트 `error=vbank_session_mismatch` |
| 재사용 | 불가 — 계좌 저장 시 확인값이 소멸한다 |
`param_opt_1` 은 간편결제 수단 식별자가 이미 점유하고 있으므로 `param_opt_2` 를 쓴다. PC 분기(`enc_data` 존재)는 서버 승인으로 검증되므로 이 대조 대상이 아니다.
---
## 결제 실패 리다이렉트 규약 (브라우저 콜백)
결제창에서 돌아오는 브라우저 콜백은 JSON 응답이 아니라 상점 실패 페이지로 **리다이렉트**하며, 실패 사유를 쿼리스트링으로 전달합니다.
@@ -14,9 +14,9 @@
| `sirsoft-pay_nhnkcp.escrow.purchase_cancelled` | action | — | `src/Controllers/EscrowCommonNotifyController.php:97` |
| `sirsoft-pay_nhnkcp.escrow.purchase_confirmed` | action | — | `src/Controllers/EscrowCommonNotifyController.php:96` |
| `sirsoft-pay_nhnkcp.payment.after_cancel` | action | KCP 결제 취소 완료 후 | `src/Services/NhnKcpApiService.php:250` |
| `sirsoft-pay_nhnkcp.payment.after_confirm` | action | KCP 결제 승인 확인 완료 후 | `src/Controllers/PaymentCallbackController.php:278` |
| `sirsoft-pay_nhnkcp.payment.after_confirm` | action | KCP 결제 승인 확인 완료 후 | `src/Controllers/PaymentCallbackController.php:280` |
| `sirsoft-pay_nhnkcp.payment.before_cancel` | action | KCP 결제 취소 API 호출 전 (본인인증 등 확장 지점) | `src/Services/NhnKcpApiService.php:229` |
| `sirsoft-pay_nhnkcp.payment.before_confirm` | action | KCP 결제 승인 확인 전 | `src/Controllers/PaymentCallbackController.php:273` |
| `sirsoft-pay_nhnkcp.payment.before_confirm` | action | KCP 결제 승인 확인 전 | `src/Controllers/PaymentCallbackController.php:275` |
<!-- @generated:hooks-published END -->
<!-- @intent START -->
@@ -83,3 +83,22 @@ KCP 가 제공하는 `payplus_web.jsp` SDK 는 이 목록에 없습니다 — `r
그리고, 이 플러그인은 간편결제 버튼·복사 버튼 같은 최소한의 UI만 코어 컴포넌트로
구성하기 때문입니다.
<!-- @intent END -->
## 비회원 주문의 자격 증명
<!-- @intent START -->
영수증·결제수단 표시는 주문 정보를 서버에서 다시 조회해 만든다. 이 요청은 레이아웃의
`globalHeaders` 배선을 타지 않는 직접 호출(`fetch`)이므로, 자격 증명을 호출부가 직접 실어야 한다.
- 회원: `Authorization: Bearer …`
- 비회원: `X-Guest-Order-Token: …` (코어 storageHandlers 가 sessionStorage 에 저장, 전역 상태 폴백)
두 경로 모두 `resources/js/guestOrderToken.ts` 의 `buildOrderRequestHeaders()` 한 곳을 거친다.
비회원 분기를 비우면 서버는 그 주문을 찾을 수 없다고 응답하고, 화면에는 영수증 버튼이 아예
나타나지 않는다 — 예외도 콘솔 오류도 남지 않아 운영자가 원인을 알 수 없다. 서버(`UserReceiptController`)는
비회원을 이미 지원하므로, 이 배선이 빠지면 서버 기능이 도달 불가 상태로만 남는다.
주문 상세 화면의 경로 판정(`ORDER_SHOW_RE`)도 회원(`/mypage/orders/{N}`)과
비회원(`/shop/guest/orders/{N}`) 두 주소를 함께 매칭해야 한다. 한쪽을 빼면 그 화면에서는
조회가 시작조차 되지 않는다.
<!-- @intent END -->
@@ -0,0 +1,94 @@
/**
* 비회원 주문 조회 토큰 배선 검증 (NHN KCP)
*
* 서버는 `X-Guest-Order-Token` 으로 비회원 주문 소유자를 확인한다. 화면이 그 값을
* 보내지 않으면 서버는 주문을 찾지 못하고, 비회원 손님에게는 영수증 버튼이 아예
* 나타나지 않는다 — 예외도 콘솔 오류도 남지 않아 원인을 알 수 없는 결함이라 구조로 잠근다.
*
* @scenario actor=guest, surface=receipt_injector
*
* @effects guest_order_token_header_built,guest_order_detail_route_matched
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { buildOrderRequestHeaders, getGuestOrderToken } from '../guestOrderToken';
const JS = resolve(__dirname, '..');
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
});
afterEach(() => {
delete (window as any).G7Core;
});
describe('buildOrderRequestHeaders', () => {
it('회원 토큰이 있으면 Authorization 을 쓴다', () => {
localStorage.setItem('auth_token', 'member-token');
expect(buildOrderRequestHeaders()).toEqual({
Accept: 'application/json',
Authorization: 'Bearer member-token',
});
});
it('회원 토큰이 없고 비회원 토큰이 있으면 X-Guest-Order-Token 을 쓴다', () => {
sessionStorage.setItem('g7_guest_order_token', 'guest-token');
expect(buildOrderRequestHeaders()).toEqual({
Accept: 'application/json',
'X-Guest-Order-Token': 'guest-token',
});
});
it('둘 다 없으면 null 을 돌려준다 (호출 자체를 막는다)', () => {
expect(buildOrderRequestHeaders()).toBeNull();
});
it('sessionStorage 가 비어도 전역 상태 폴백을 본다', () => {
(window as any).G7Core = {
state: { get: (k: string) => (k === '_global' ? { guestOrderToken: 'global-token' } : undefined) },
};
expect(getGuestOrderToken()).toBe('global-token');
});
});
describe('영수증 화면 배선', () => {
it.each(['orderCompleteReceiptInjector.ts', 'mypageOrderShowInjector.ts'])(
'%s 가 비회원 토큰 헤더를 경유한다',
(file) => {
const source = readFileSync(resolve(JS, file), 'utf-8');
expect(
source.includes('buildOrderRequestHeaders'),
`${file} 가 비회원 토큰을 싣지 않습니다 — 비회원 손님에게 영수증 버튼이 나타나지 않습니다.`
).toBe(true);
expect(
/localStorage\.getItem\('auth_token'\)/.test(source),
`${file} 에 회원 전용 토큰 조회가 남아 있습니다 — 비회원 분기가 다시 막힙니다.`
).toBe(false);
}
);
it('주문 상세 경로 판정이 비회원 주소도 매칭한다', () => {
const source = readFileSync(resolve(JS, 'mypageOrderShowInjector.ts'), 'utf-8');
const match = source.match(/const ORDER_SHOW_RE = (\/.+\/);/);
expect(match, '주문 상세 경로 정규식을 찾지 못했습니다.').not.toBeNull();
const re = new RegExp(match![1].slice(1, -1));
expect(re.test('/mypage/orders/12'), '회원 주문 상세 경로가 매칭되지 않습니다.').toBe(true);
expect(
re.test('/shop/guest/orders/20260904-0001'),
'비회원 주문 상세 경로가 매칭되지 않습니다 — 그 화면에서는 영수증이 조회조차 되지 않습니다.'
).toBe(true);
});
});
@@ -0,0 +1,62 @@
/**
* 비회원 주문 조회 토큰 확보 (플러그인 공용)
*
* 비회원 주문의 영수증·결제수단 정보는 서버가 `X-Guest-Order-Token` 으로 소유자를
* 확인한다. 이 토큰을 싣지 않으면 서버는 그 주문을 찾을 수 없다고 응답하므로,
* 비회원 손님에게는 영수증 버튼이 아예 나타나지 않는다 — 예외도 콘솔 오류도 남지
* 않아 운영자가 원인을 알 수 없다.
*
* 코어 storageHandlers 가 sessionStorage 에 저장하며, 그 접근이 막힌 환경
* (프라이빗 창·iframe)을 위해 전역 상태 폴백을 함께 본다.
*/
/**
* 회원 인증 토큰을 돌려줍니다.
*
* @return 토큰 또는 null
*/
export function getAuthToken(): string | null {
return localStorage.getItem('auth_token');
}
/**
* 비회원 주문 조회 토큰을 돌려줍니다.
*
* @return 토큰 또는 null
*/
export function getGuestOrderToken(): string | null {
try {
const sessionToken = sessionStorage.getItem('g7_guest_order_token');
if (sessionToken) return sessionToken;
} catch {
// sessionStorage 접근 불가 환경 — 전역 상태 폴백으로 진행
}
const globalToken = (window as any).G7Core?.state?.get?.('_global')?.guestOrderToken;
return typeof globalToken === 'string' && globalToken !== '' ? globalToken : null;
}
/**
* 주문 조회 요청 헤더를 만듭니다. 회원 토큰을 우선하고, 없으면 비회원 토큰을 씁니다.
*
* @return 헤더 객체. 둘 다 없으면 null (호출 자체를 하지 않아야 함)
*/
export function buildOrderRequestHeaders(): Record<string, string> | null {
const authToken = getAuthToken();
const guestToken = getGuestOrderToken();
if (!authToken && !guestToken) {
return null;
}
const headers: Record<string, string> = { Accept: 'application/json' };
if (authToken) {
headers.Authorization = `Bearer ${authToken}`;
} else if (guestToken) {
headers['X-Guest-Order-Token'] = guestToken;
}
return headers;
}
@@ -1,10 +1,14 @@
import { buildOrderRequestHeaders } from './guestOrderToken';
const PLUGIN_ID = 'sirsoft-pay_nhnkcp';
const FLAG = '__kcpMpShowInjectorInstalled';
const VBANK_ID = 'kcp-mp-vbank-row';
const ROW_ID = 'kcp-mp-receipt-row';
const MOCK_DEPOSIT_ID = 'kcp-mp-mock-deposit';
const ORDER_SHOW_RE = /^\/mypage\/orders\/([^/]+)$/;
// 회원 마이페이지(/mypage/orders/{N}) 와 비회원 주문 상세(/shop/guest/orders/{N}) 를 함께 매칭한다.
// 비회원 경로를 빼면 그 화면에서는 영수증 정보가 조회조차 되지 않는다 (서버는 지원한다).
const ORDER_SHOW_RE = /^(?:\/mypage\/orders\/([^/]+)|\/shop\/guest\/orders\/([^/]+))$/;
interface Payment {
pg_provider?: string;
@@ -38,9 +42,6 @@ function getOrderFromState(orderNumber: string): OrderData | null {
}
}
function getToken(): string | null {
return localStorage.getItem('auth_token');
}
interface ReceiptInfo {
receipt_url?: string;
@@ -49,11 +50,12 @@ interface ReceiptInfo {
}
async function fetchReceiptUrls(orderNumber: string): Promise<ReceiptInfo | null> {
const token = getToken();
if (!token) return null;
const headers = buildOrderRequestHeaders();
if (!headers) return null;
try {
const res = await fetch(`/api/plugins/${PLUGIN_ID}/user/orders/${orderNumber}/receipt`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
headers,
credentials: 'same-origin',
});
if (!res.ok) return null;
return (await res.json()) as ReceiptInfo;
@@ -72,11 +74,12 @@ interface MockDepositInfo {
}
async function fetchMockDepositInfo(orderNumber: string): Promise<MockDepositInfo | null> {
const token = getToken();
if (!token) return null;
const headers = buildOrderRequestHeaders();
if (!headers) return null;
try {
const res = await fetch(`/api/plugins/${PLUGIN_ID}/user/orders/${orderNumber}/vbank-mock-deposit-info`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
headers,
credentials: 'same-origin',
});
if (!res.ok) return null;
return (await res.json()) as MockDepositInfo;
@@ -381,7 +384,9 @@ function startPolling(orderNumber: string): void {
function onRouteChange(): void {
const match = location.pathname.match(ORDER_SHOW_RE);
if (match) startPolling(match[1]);
// 회원 그룹(match[1]) 또는 비회원 그룹(match[2]) 중 한쪽이 채워진다.
const segment = match?.[1] ?? match?.[2];
if (segment) startPolling(segment);
}
export function installMypageOrderShowInjector(): void {
@@ -1,3 +1,5 @@
import { buildOrderRequestHeaders } from './guestOrderToken';
const PLUGIN_ID = 'sirsoft-pay_nhnkcp';
const FLAG = '__kcpOcReceiptInjectorInstalled';
const BTN_ID = 'kcp-oc-receipt-btn';
@@ -11,17 +13,16 @@ type Payment = {
[key: string]: unknown;
};
function getToken(): string | null {
return localStorage.getItem('auth_token');
}
async function fetchPayment(orderNumber: string): Promise<Payment | null> {
const token = getToken();
if (!token) return null;
// 회원 토큰 또는 비회원 주문 조회 토큰 중 하나는 있어야 한다. 비회원 분기를 비우면
// 비회원 손님에게는 영수증 버튼이 아예 나타나지 않는다 (서버는 지원한다).
const headers = buildOrderRequestHeaders();
if (!headers) return null;
try {
const res = await fetch(`/api/modules/sirsoft-ecommerce/user/orders/${orderNumber}`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
headers,
credentials: 'same-origin',
});
if (!res.ok) return null;
const data = (await res.json()) as { data?: { payment?: Payment } };
@@ -38,12 +39,13 @@ interface ReceiptInfo {
}
async function fetchReceiptUrl(orderNumber: string): Promise<ReceiptInfo | null> {
const token = getToken();
if (!token) return null;
const headers = buildOrderRequestHeaders();
if (!headers) return null;
try {
const res = await fetch(`/api/plugins/${PLUGIN_ID}/user/orders/${orderNumber}/receipt`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
headers,
credentials: 'same-origin',
});
if (!res.ok) return null;
return (await res.json()) as ReceiptInfo;
@@ -0,0 +1,101 @@
<?php
namespace Plugins\Sirsoft\PayNhnkcp\Concerns;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Models\Order;
/**
* 모바일 가상계좌 콜백을 인증된 결제 세션에 묶는 일회성 state.
*
* KCP 모바일(SmartPhone Pay)은 가상계좌 계좌번호를 서버-서버 승인이 아니라 브라우저
* 평문 POST 로만 전달한다. 그래서 콜백만으로는 그 값이 KCP 에서 온 것인지 확인할 수
* 없고, 주문번호만 아는 제3자가 위조 계좌를 영속시킬 수 있었다 (KVE-2026-2019).
*
* 방어는 KCP 가 passthrough 파라미터를 그대로 되돌려주는 성질을 이용한다 —
* 소유자 인증을 거친 승인키 발급 시점에 nonce 를 만들어 주문에 저장하고 `param_opt_2`
* 로 실어 보낸 뒤, 콜백에서 되돌아온 값과 대조한다. `param_opt_1` 은 간편결제 수단
* 식별자가 이미 점유하고 있으므로 `param_opt_2` 를 쓴다.
*
* 발급 지점은 `auth:sanctum` 이라 nonce 는 주문 소유자의 세션에 완전히 묶인다.
* 공격자는 피해자의 nonce 를 알 수 없다.
*/
trait BindsMobileVbankSession
{
/**
* payment_meta 안의 state 키
*/
private const MOBILE_VBANK_STATE_KEY = 'mobile_vbank_state';
/**
* 모바일 가상계좌 세션 nonce 를 발급해 주문에 저장합니다.
*
* 저장에 실패하면 null 을 반환한다 — 호출부는 nonce 없이 결제창을 열지 않는다
* (검증 불가능한 콜백을 만들지 않기 위해서다).
*
* @param Order $order 대상 주문
* @return string|null 발급된 nonce, 저장 실패 시 null
*/
protected function issueMobileVbankState(Order $order): ?string
{
$payment = $order->payment;
if (! $payment || ! $payment->exists) {
return null;
}
$nonce = bin2hex(random_bytes(16));
try {
$meta = $payment->payment_meta ?? [];
$meta = is_array($meta) ? $meta : [];
$meta[self::MOBILE_VBANK_STATE_KEY] = [
'nonce' => $nonce,
'issued_at' => now()->toIso8601String(),
];
$payment->payment_meta = $meta;
$payment->save();
} catch (\Exception $e) {
Log::error('KCP: failed to persist mobile vbank session state', [
'order_number' => $order->order_number,
'error' => $e->getMessage(),
]);
return null;
}
return $nonce;
}
/**
* 콜백으로 되돌아온 `param_opt_2` 가 저장된 nonce 와 일치하는지 확인합니다.
*
* 저장된 state 가 없거나 값이 다르면 false 다. 일치 여부는 timing-safe 비교로
* 판정한다.
*
* @param Order $order 대상 주문
* @param string|null $echoedState 콜백이 되돌려준 param_opt_2
* @return bool 일치 여부
*/
protected function mobileVbankStateMatches(Order $order, ?string $echoedState): bool
{
if (! is_string($echoedState) || $echoedState === '') {
return false;
}
$payment = $order->payment;
if (! $payment || ! $payment->exists) {
return false;
}
$meta = $payment->payment_meta ?? [];
$meta = is_array($meta) ? $meta : [];
$stored = $meta[self::MOBILE_VBANK_STATE_KEY]['nonce'] ?? null;
if (! is_string($stored) || $stored === '') {
return false;
}
return hash_equals($stored, $echoedState);
}
}
@@ -10,6 +10,7 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
use Plugins\Sirsoft\PayNhnkcp\Concerns\BindsMobileVbankSession;
use Plugins\Sirsoft\PayNhnkcp\Concerns\RecordsPaymentWindowClosure;
use Plugins\Sirsoft\PayNhnkcp\Exceptions\NhnKcpApiException;
use Plugins\Sirsoft\PayNhnkcp\Services\KcpSoapService;
@@ -22,6 +23,7 @@ use Plugins\Sirsoft\PayNhnkcp\Services\KcpSoapService;
*/
class MobileApprovalController
{
use BindsMobileVbankSession;
use RecordsPaymentWindowClosure;
/** 결제수단 → KCP 모바일 pay_method 코드 */
@@ -197,6 +199,21 @@ class MobileApprovalController
$settings = plugin_settings(self::PLUGIN_IDENTIFIER) ?? [];
$fields['vcnt_expire_term'] = (string) ((int) ($settings['vbank_expire_days'] ?? 3));
$fields['disp_tax_yn'] = 'N';
// 모바일 가상계좌 콜백은 계좌번호를 브라우저 평문으로만 전달한다.
// 이 지점은 소유자 인증(auth:sanctum)을 통과했으므로 여기서 만든 일회성
// nonce 를 passthrough 파라미터로 실어 보내고, 콜백에서 되돌아온 값과
// 대조해 그 콜백이 이 결제 세션에서 비롯됐음을 확인한다 (KVE-2026-2019).
// param_opt_1 은 간편결제 수단 식별자가 점유하므로 param_opt_2 를 쓴다.
$mobileVbankState = $this->issueMobileVbankState($order);
if ($mobileVbankState === null) {
return response()->json([
'success' => false,
'error' => 'Failed to prepare the virtual account payment session.',
], 500);
}
$fields['param_opt_2'] = $mobileVbankState;
}
if ($isEasyPay) {
@@ -17,6 +17,7 @@ use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException;
use Modules\Sirsoft\Ecommerce\Helpers\DeviceDetector;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
use Plugins\Sirsoft\PayNhnkcp\Concerns\BindsMobileVbankSession;
use Plugins\Sirsoft\PayNhnkcp\Concerns\IssuesReceiptCookie;
use Plugins\Sirsoft\PayNhnkcp\Concerns\PreventsReplayCallback;
use Plugins\Sirsoft\PayNhnkcp\Concerns\RecordsPaymentWindowClosure;
@@ -38,6 +39,7 @@ use Plugins\Sirsoft\PayNhnkcp\Support\ShopRedirectUrl;
*/
class PaymentCallbackController
{
use BindsMobileVbankSession;
use IssuesReceiptCookie;
use PreventsReplayCallback;
use RecordsPaymentWindowClosure;
@@ -607,6 +609,24 @@ class PaymentCallbackController
$isMobile = $encData === '' || $encInfo === '';
if ($isMobile) {
// 모바일 분기는 PG 서버 호출이 없어 계좌 정보의 유일한 출처가 브라우저 평문이다.
// 그래서 이 콜백이 소유자 인증을 거친 결제 세션에서 비롯됐는지를 먼저 확인한다 —
// 승인키 발급 시점에 실어 보낸 일회성 nonce 가 param_opt_2 로 되돌아와야 한다
// (KVE-2026-2019). 불일치/부재면 상태를 전혀 바꾸지 않고 실패 URL 로 돌린다
// ("브라우저 입력만으로 상태 변경 금지" — KVE-2026-2018/2046 과 같은 기준).
if (! $this->mobileVbankStateMatches($order, $validated['param_opt_2'] ?? null)) {
Log::warning('KCP: mobile vbank callback rejected — session state mismatch', [
'ordr_idxx' => $ordrIdxx,
'has_echoed_state' => ($validated['param_opt_2'] ?? '') !== '',
]);
return redirect($this->resolveFailUrl([
'error' => 'vbank_session_mismatch',
'message' => __('sirsoft-pay_nhnkcp::messages.errors.payment_failed'),
'orderId' => $ordrIdxx,
]));
}
// 모바일: 콜백 POST 의 평문 필드를 그대로 응답으로 취급
// KCP 변종 키 모두 대응 (bankname|bank_name, depositor|account_holder, va_date|vnbank_expire_date)
$pgResponse = [
@@ -48,6 +48,8 @@ class AuthCallbackRequest extends FormRequest
'good_mny' => ['nullable', 'numeric', 'min:1'],
'use_pay_method' => ['nullable', 'string'],
'param_opt_1' => ['nullable', 'string', 'max:50'],
// 모바일 가상계좌 세션 nonce (승인키 발급 시점에 실어 보낸 값을 KCP 가 되돌려준다)
'param_opt_2' => ['nullable', 'string', 'max:50'],
'nhnkcp_easy_pay_method' => ['nullable', 'string', 'max:50'],
// 모바일 SmartPhone Pay 가상계좌 콜백 (enc_data 없이 평문 전달)
// KCP가 보내는 변종 키 모두 허용 — handleVbankIssued 에서 우선순위로 처리
@@ -0,0 +1,400 @@
<?php
namespace Plugins\Sirsoft\PayNhnkcp\Tests\Feature\Controllers;
use App\Models\User;
use App\Services\PluginSettingsService;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Plugins\Sirsoft\PayNhnkcp\Services\KcpSoapService;
use Plugins\Sirsoft\PayNhnkcp\Tests\PluginTestCase;
/**
* 모바일 가상계좌 콜백 세션 바인딩 회귀 테스트 (KVE-2026-2019).
*
* 회귀 배경: KCP 모바일(SmartPhone Pay) 가상계좌 콜백은 enc_data/enc_info 없이
* 계좌 정보를 평문 POST 로만 전달한다. 그런데 컨트롤러는 그 평문을 PG 서버 검증 없이
* 그대로 영속시켰다. 그래서 주문번호만 아는 익명 요청 1회로 피해자 주문의 입금 계좌를
* 공격자 계좌로 바꿀 수 있었다.
*
* 방어: 소유자 인증을 거친 승인키 발급 시점에 일회성 nonce 를 만들어 주문에 저장하고
* KCP passthrough 파라미터(param_opt_2)로 실어 보낸 뒤, 콜백에서 되돌아온 값과
* 대조한다. 불일치/부재면 상태를 전혀 바꾸지 않는다.
*
* @group nhnkcp
* @group security
*/
class MobileVbankCallbackBindingTest extends PluginTestCase
{
private const TEST_SITE_CD = 'T0000';
private const TEST_SITE_KEY = 'TEST_SITE_KEY_0000';
private const CALLBACK_URL = '/plugins/sirsoft-pay_nhnkcp/payment/callback';
private const APPROVAL_KEY_ENDPOINT = '/api/plugins/sirsoft-pay_nhnkcp/mobile/approval-key';
private const TEST_PAY_URL = 'https://testpay.kcp.co.kr/php/mobile/mc_pay_form.php';
protected function setUp(): void
{
parent::setUp();
$this->mockPluginSettings();
}
/**
* 플러그인 설정을 테스트 모드로 고정합니다.
*/
private function mockPluginSettings(): void
{
$mock = $this->createMock(PluginSettingsService::class);
$mock->method('get')->willReturn([
'is_test_mode' => true,
'test_site_cd' => self::TEST_SITE_CD,
'test_site_key' => self::TEST_SITE_KEY,
'live_site_cd' => '',
'live_site_key' => '',
'redirect_success_url' => '/shop/orders/{orderId}/complete',
'redirect_fail_url' => '/shop/checkout',
]);
$this->app->instance(PluginSettingsService::class, $mock);
}
/**
* KcpSoapService 를 승인키 발급 성공으로 고정합니다.
*/
private function mockSoapService(): void
{
$mock = $this->createMock(KcpSoapService::class);
$mock->method('getApprovalKey')->willReturn([
'approval_key' => 'TEST_APPROVAL_KEY',
'pay_url' => self::TEST_PAY_URL,
]);
$mock->method('getSiteCd')->willReturn(self::TEST_SITE_CD);
$mock->method('getEscrowSiteCd')->willReturn(self::TEST_SITE_CD);
$this->app->instance(KcpSoapService::class, $mock);
}
/**
* KRW 통화 스냅샷을 반환합니다.
*
* @return array<string, mixed>
*/
private static function krwCurrencySnapshot(): array
{
return [
'base_currency' => 'KRW',
'order_currency' => 'KRW',
'base_unit' => 1,
'exchange_rates' => [
'KRW' => [
'rate' => 1,
'rounding_unit' => '1',
'rounding_method' => 'round',
'decimal_places' => 0,
'base_unit' => 1,
],
],
];
}
/**
* 가상계좌 결제대기 주문을 만듭니다.
*
* @param array<string, mixed> $paymentMeta payment_meta 초기값
*/
private function createVbankOrder(array $paymentMeta = [], int $totalAmount = 50000): Order
{
$user = User::factory()->create();
$order = OrderFactory::new()->create([
'user_id' => $user->id,
'order_number' => 'ORD-VB-'.random_int(10000, 99999),
'order_status' => OrderStatusEnum::PENDING_ORDER,
'subtotal_amount' => $totalAmount,
'total_discount_amount' => 0,
'total_coupon_discount_amount' => 0,
'total_product_coupon_discount_amount' => 0,
'total_order_coupon_discount_amount' => 0,
'total_code_discount_amount' => 0,
'base_shipping_amount' => 0,
'extra_shipping_amount' => 0,
'shipping_discount_amount' => 0,
'total_shipping_amount' => 0,
'total_amount' => $totalAmount,
'total_due_amount' => $totalAmount,
'total_points_used_amount' => 0,
'total_deposit_used_amount' => 0,
'total_paid_amount' => 0,
'total_tax_amount' => $totalAmount,
'total_vat_amount' => (int) round($totalAmount * 10 / 110),
'total_tax_free_amount' => 0,
'currency' => 'KRW',
'currency_snapshot' => self::krwCurrencySnapshot(),
]);
OrderPaymentFactory::new()->create([
'order_id' => $order->id,
'payment_status' => PaymentStatusEnum::READY,
'payment_method' => PaymentMethodEnum::VBANK,
'pg_provider' => 'nhnkcp',
'paid_amount_local' => 0,
'paid_at' => null,
'transaction_id' => null,
'vbank_name' => null,
'vbank_number' => null,
'vbank_holder' => null,
'payment_meta' => $paymentMeta,
]);
return $order->fresh('payment');
}
/**
* 저장된 세션 nonce 를 가진 주문을 만듭니다.
*
* @return array{0: Order, 1: string}
*/
private function createVbankOrderWithState(): array
{
$nonce = bin2hex(random_bytes(16));
$order = $this->createVbankOrder([
'mobile_vbank_state' => [
'nonce' => $nonce,
'issued_at' => now()->toIso8601String(),
],
]);
return [$order, $nonce];
}
/**
* 모바일 가상계좌 콜백 페이로드를 만듭니다 (enc_data/enc_info 없음).
*
* @param array<string, mixed> $overrides 덮어쓸 값
* @return array<string, mixed>
*/
private function mobileVbankPayload(Order $order, array $overrides = []): array
{
return array_merge([
'res_cd' => '0000',
'res_msg' => '정상처리',
'tno' => 'KCP_TNO_'.uniqid(),
'ordr_idxx' => $order->order_number,
'good_mny' => (int) $order->total_due_amount,
'use_pay_method' => 'VCNT',
'bankname' => '공격자은행',
'account' => '9999999999',
'depositor' => '공격자',
'va_date' => now()->addDays(3)->format('YmdHis'),
], $overrides);
}
// =========================================================================
// 차단 매트릭스
// =========================================================================
/**
* 세션 nonce 가 저장되지 않은 주문에 온 모바일 콜백은 계좌를 쓰지 못한다.
*/
public function test_mobile_vbank_callback_without_stored_state_cannot_persist_account(): void
{
$order = $this->createVbankOrder();
$this->post(self::CALLBACK_URL, $this->mobileVbankPayload($order));
$payment = $order->fresh()->payment;
$this->assertNull($payment->vbank_number, '검증되지 않은 콜백이 계좌를 저장했습니다');
$this->assertNull($payment->vbank_name);
$this->assertSame(PaymentStatusEnum::READY->value, $payment->payment_status->value);
}
/**
* 저장된 nonce 와 다른 param_opt_2 는 차단된다.
*/
public function test_mobile_vbank_callback_with_mismatched_state_is_rejected(): void
{
[$order] = $this->createVbankOrderWithState();
$this->post(self::CALLBACK_URL, $this->mobileVbankPayload($order, [
'param_opt_2' => bin2hex(random_bytes(16)),
]));
$payment = $order->fresh()->payment;
$this->assertNull($payment->vbank_number, '불일치 nonce 로 계좌가 저장되었습니다');
$this->assertNull($payment->vbank_name);
}
/**
* 이미 발급된 계좌는 위조 콜백으로 덮어써지지 않는다.
*/
public function test_forged_callback_cannot_overwrite_an_issued_account(): void
{
$order = $this->createVbankOrder();
$order->payment->update([
'vbank_name' => '정상은행',
'vbank_number' => 'T1234567890',
'vbank_holder' => 'NHN KCP',
'vbank_issued_at' => now(),
]);
$this->post(self::CALLBACK_URL, $this->mobileVbankPayload($order));
$payment = $order->fresh()->payment;
$this->assertSame('T1234567890', $payment->vbank_number, '위조 콜백이 발급된 계좌를 변조했습니다');
$this->assertSame('정상은행', $payment->vbank_name);
}
/**
* nonce 는 1회성이다 — 정상 발급 후 같은 nonce 로 다시 오면 차단된다.
*/
public function test_state_is_single_use_and_replay_is_rejected(): void
{
[$order, $nonce] = $this->createVbankOrderWithState();
$this->post(self::CALLBACK_URL, $this->mobileVbankPayload($order, [
'param_opt_2' => $nonce,
'bankname' => '정상은행',
'account' => 'T1111111111',
'depositor' => 'NHN KCP',
]));
$this->assertSame('T1111111111', $order->fresh()->payment->vbank_number);
// 같은 nonce 로 재사용 시도 (계좌 변조)
$this->post(self::CALLBACK_URL, $this->mobileVbankPayload($order, [
'param_opt_2' => $nonce,
'bankname' => '공격자은행',
'account' => 'T9999999999',
]));
$this->assertSame(
'T1111111111',
$order->fresh()->payment->vbank_number,
'nonce 재사용으로 계좌가 변조되었습니다'
);
}
// =========================================================================
// 발급 지점 (승인키 요청) — 정상 흐름 불변
// =========================================================================
/**
* 가상계좌 모바일 승인키 요청은 nonce 를 만들어 주문에 저장하고 param_opt_2 로 실어 보낸다.
*
* 이 경로는 종전 테스트가 카드 결제만 다뤄 비어 있었다. 발급이 조용히 실패하면
* 콜백은 언제나 차단되어 **가상계좌 결제 자체가 불능**이 되는데, 그 실패는 차단
* 테스트만으로는 드러나지 않는다.
*/
public function test_mobile_vbank_approval_key_issues_and_persists_the_session_state(): void
{
$order = $this->createVbankOrder();
$this->mockSoapService();
$response = $this->actingAs($order->user)->postJson(self::APPROVAL_KEY_ENDPOINT, [
'order_number' => $order->order_number,
'amount' => (int) $order->total_due_amount,
'good_name' => '테스트 상품',
'pay_method' => 'vbank',
'ret_url' => 'https://example.com/callback',
]);
$response->assertOk()->assertJsonPath('success', true);
$echoed = $response->json('data.fields.param_opt_2');
$this->assertIsString($echoed);
$this->assertNotSame('', $echoed, 'param_opt_2 가 결제창 필드에 실리지 않았다');
$stored = $order->fresh()->payment->payment_meta['mobile_vbank_state']['nonce'] ?? null;
$this->assertSame($echoed, $stored, '결제창에 실은 값과 저장된 값이 다르다');
}
/**
* 카드 결제에는 nonce 를 만들지 않는다 (범위 최소화 — 불필요한 부작용 방지).
*/
public function test_card_approval_key_does_not_issue_a_vbank_state(): void
{
$order = $this->createVbankOrder();
$this->mockSoapService();
$response = $this->actingAs($order->user)->postJson(self::APPROVAL_KEY_ENDPOINT, [
'order_number' => $order->order_number,
'amount' => (int) $order->total_due_amount,
'good_name' => '테스트 상품',
'pay_method' => 'card',
'ret_url' => 'https://example.com/callback',
]);
$response->assertOk();
$this->assertNull($response->json('data.fields.param_opt_2'));
$this->assertArrayNotHasKey(
'mobile_vbank_state',
$order->fresh()->payment->payment_meta ?? []
);
}
/**
* 발급 → 콜백 왕복이 실제로 성립한다 (발급 지점과 검증 지점의 계약 일치).
*
* 두 지점이 서로 다른 키·형식을 쓰면 차단 테스트는 전부 통과하면서 정상 결제만
* 막힌다. 과거 KVE 재수정 사례가 "검증 지점과 실행 지점의 해석 불일치" 였으므로
* 두 절반을 한 테스트로 묶어 고정한다.
*/
public function test_issued_state_round_trips_through_the_callback(): void
{
$order = $this->createVbankOrder();
$this->mockSoapService();
$issued = $this->actingAs($order->user)
->postJson(self::APPROVAL_KEY_ENDPOINT, [
'order_number' => $order->order_number,
'amount' => (int) $order->total_due_amount,
'good_name' => '테스트 상품',
'pay_method' => 'vbank',
'ret_url' => 'https://example.com/callback',
])
->json('data.fields.param_opt_2');
$this->post(self::CALLBACK_URL, $this->mobileVbankPayload($order, [
'param_opt_2' => $issued,
'bankname' => '정상은행',
'account' => 'T3333333333',
'depositor' => 'NHN KCP',
]));
$this->assertSame(
'T3333333333',
$order->fresh()->payment->vbank_number,
'발급된 nonce 가 콜백에서 인정되지 않았다 — 정상 가상계좌 결제가 불능이다'
);
}
// =========================================================================
// 통과 매트릭스 (정상 흐름 불변)
// =========================================================================
/**
* 일치하는 nonce 를 실은 콜백은 종전대로 계좌를 저장한다.
*/
public function test_mobile_vbank_callback_with_matching_state_persists_account(): void
{
[$order, $nonce] = $this->createVbankOrderWithState();
$this->post(self::CALLBACK_URL, $this->mobileVbankPayload($order, [
'param_opt_2' => $nonce,
'bankname' => '정상은행',
'account' => 'T2222222222',
'depositor' => 'NHN KCP',
]));
$payment = $order->fresh()->payment;
$this->assertSame('T2222222222', $payment->vbank_number);
$this->assertSame('정상은행', $payment->vbank_name);
$this->assertSame('NHN KCP', $payment->vbank_holder);
$this->assertSame('mobile', $payment->payment_device);
}
}
@@ -139,7 +139,7 @@ API 와 동기화하는 역할만 합니다. 등록은 훅 기반입니다
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 23개 | `plugins/_bundled/sirsoft-pay_nicepayments/tests` |
| Vitest | 7개 | `vitest.config.ts` |
| Vitest | 8개 | `vitest.config.ts` |
| Playwright | 0개 | — |
| 시나리오 매니페스트 | 1개 | `tests/scenarios` |
@@ -6,6 +6,11 @@
## [1.0.3] - 2026-09-04
### Fixed
- 비회원으로 결제하신 손님이 주문완료 화면과 비회원 주문 상세 화면에서 영수증 버튼과 결제수단 표시를 볼 수 없던 문제를 고쳤습니다. 서버는 비회원 주문을 확인해 줄 준비가 되어 있었는데 화면이 확인값을 함께 보내지 않아 조회 자체가 되지 않았고, 오류 메시지도 남지 않아 원인을 알기 어려웠습니다.
- 비회원으로 결제할 때 결제사로 과세·비과세 금액 구분이 전달되지 않던 문제를 고쳤습니다. 주문 정보를 불러오지 못해 세금 항목이 빠진 채 결제가 진행되었고, 면세 상품을 함께 파는 상점에서 금액 구성이 실제와 달라질 수 있었습니다.
### Security
- 결제창 프로그램을 불러오는 주소가 나이스페이먼츠의 주소인지 불러오기 직전에 확인합니다. 확인되지 않는 주소면 결제를 진행하지 않고 안내를 표시합니다. 또한 결제창 프로그램이 실제로 준비되었는지를 기준으로 다음 단계를 진행하도록 바꿔, 프로그램이 아직 준비되지 않았는데 결제창이 열리지 않고 멈추던 상황을 없앴습니다.
File diff suppressed because one or more lines are too long
@@ -77,3 +77,22 @@
`dist/` 산출물과는 다른 층입니다. CSS 산출물이 없는 것은 결제창 자체는 PG 가 그리고, 이
플러그인은 간편결제 버튼 같은 최소한의 UI만 코어 컴포넌트로 구성하기 때문입니다.
<!-- @intent END -->
## 비회원 주문의 자격 증명
<!-- @intent START -->
영수증·결제수단 표시는 주문 정보를 서버에서 다시 조회해 만든다. 이 요청은 레이아웃의
`globalHeaders` 배선을 타지 않는 직접 호출(`fetch`)이므로, 자격 증명을 호출부가 직접 실어야 한다.
- 회원: `Authorization: Bearer …`
- 비회원: `X-Guest-Order-Token: …` (코어 storageHandlers 가 sessionStorage 에 저장, 전역 상태 폴백)
두 경로 모두 `resources/js/guestOrderToken.ts` 의 `buildOrderRequestHeaders()` 한 곳을 거친다.
비회원 분기를 비우면 서버는 그 주문을 찾을 수 없다고 응답하고, 화면에는 영수증 버튼이 아예
나타나지 않는다 — 예외도 콘솔 오류도 남지 않아 운영자가 원인을 알 수 없다. 서버(`UserReceiptController`)는
비회원을 이미 지원하므로, 이 배선이 빠지면 서버 기능이 도달 불가 상태로만 남는다.
주문 상세 화면의 경로 판정(`ORDER_SHOW_RE`)도 회원(`/mypage/orders/{N}`)과
비회원(`/shop/guest/orders/{N}`) 두 주소를 함께 매칭해야 한다. 한쪽을 빼면 그 화면에서는
조회가 시작조차 되지 않는다.
<!-- @intent END -->
@@ -0,0 +1,94 @@
/**
* 비회원 주문 조회 토큰 배선 검증 (나이스페이먼츠)
*
* 서버는 `X-Guest-Order-Token` 으로 비회원 주문 소유자를 확인한다. 화면이 그 값을
* 보내지 않으면 서버는 주문을 찾지 못하고, 비회원 손님에게는 영수증 버튼이 아예
* 나타나지 않는다 — 예외도 콘솔 오류도 남지 않아 원인을 알 수 없는 결함이라 구조로 잠근다.
*
* @scenario actor=guest, surface=receipt_injector
*
* @effects guest_order_token_header_built,guest_order_detail_route_matched
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { buildOrderRequestHeaders, getGuestOrderToken } from '../guestOrderToken';
const JS = resolve(__dirname, '..');
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
});
afterEach(() => {
delete (window as any).G7Core;
});
describe('buildOrderRequestHeaders', () => {
it('회원 토큰이 있으면 Authorization 을 쓴다', () => {
localStorage.setItem('auth_token', 'member-token');
expect(buildOrderRequestHeaders()).toEqual({
Accept: 'application/json',
Authorization: 'Bearer member-token',
});
});
it('회원 토큰이 없고 비회원 토큰이 있으면 X-Guest-Order-Token 을 쓴다', () => {
sessionStorage.setItem('g7_guest_order_token', 'guest-token');
expect(buildOrderRequestHeaders()).toEqual({
Accept: 'application/json',
'X-Guest-Order-Token': 'guest-token',
});
});
it('둘 다 없으면 null 을 돌려준다 (호출 자체를 막는다)', () => {
expect(buildOrderRequestHeaders()).toBeNull();
});
it('sessionStorage 가 비어도 전역 상태 폴백을 본다', () => {
(window as any).G7Core = {
state: { get: (k: string) => (k === '_global' ? { guestOrderToken: 'global-token' } : undefined) },
};
expect(getGuestOrderToken()).toBe('global-token');
});
});
describe('영수증 화면 배선', () => {
it.each(['orderCompleteReceiptInjector.ts', 'mypageOrderShowInjector.ts'])(
'%s 가 비회원 토큰 헤더를 경유한다',
(file) => {
const source = readFileSync(resolve(JS, file), 'utf-8');
expect(
source.includes('buildOrderRequestHeaders'),
`${file} 가 비회원 토큰을 싣지 않습니다 — 비회원 손님에게 영수증 버튼이 나타나지 않습니다.`
).toBe(true);
expect(
/localStorage\.getItem\('auth_token'\)/.test(source),
`${file} 에 회원 전용 토큰 조회가 남아 있습니다 — 비회원 분기가 다시 막힙니다.`
).toBe(false);
}
);
it('주문 상세 경로 판정이 비회원 주소도 매칭한다', () => {
const source = readFileSync(resolve(JS, 'mypageOrderShowInjector.ts'), 'utf-8');
const match = source.match(/const ORDER_SHOW_RE = (\/.+\/);/);
expect(match, '주문 상세 경로 정규식을 찾지 못했습니다.').not.toBeNull();
const re = new RegExp(match![1].slice(1, -1));
expect(re.test('/mypage/orders/12'), '회원 주문 상세 경로가 매칭되지 않습니다.').toBe(true);
expect(
re.test('/shop/guest/orders/20260904-0001'),
'비회원 주문 상세 경로가 매칭되지 않습니다 — 그 화면에서는 영수증이 조회조차 되지 않습니다.'
).toBe(true);
});
});
@@ -0,0 +1,62 @@
/**
* 비회원 주문 조회 토큰 확보 (플러그인 공용)
*
* 비회원 주문의 영수증·결제수단 정보는 서버가 `X-Guest-Order-Token` 으로 소유자를
* 확인한다. 이 토큰을 싣지 않으면 서버는 그 주문을 찾을 수 없다고 응답하므로,
* 비회원 손님에게는 영수증 버튼이 아예 나타나지 않는다 — 예외도 콘솔 오류도 남지
* 않아 운영자가 원인을 알 수 없다.
*
* 코어 storageHandlers 가 sessionStorage 에 저장하며, 그 접근이 막힌 환경
* (프라이빗 창·iframe)을 위해 전역 상태 폴백을 함께 본다.
*/
/**
* 회원 인증 토큰을 돌려줍니다.
*
* @return 토큰 또는 null
*/
export function getAuthToken(): string | null {
return localStorage.getItem('auth_token');
}
/**
* 비회원 주문 조회 토큰을 돌려줍니다.
*
* @return 토큰 또는 null
*/
export function getGuestOrderToken(): string | null {
try {
const sessionToken = sessionStorage.getItem('g7_guest_order_token');
if (sessionToken) return sessionToken;
} catch {
// sessionStorage 접근 불가 환경 — 전역 상태 폴백으로 진행
}
const globalToken = (window as any).G7Core?.state?.get?.('_global')?.guestOrderToken;
return typeof globalToken === 'string' && globalToken !== '' ? globalToken : null;
}
/**
* 주문 조회 요청 헤더를 만듭니다. 회원 토큰을 우선하고, 없으면 비회원 토큰을 씁니다.
*
* @return 헤더 객체. 둘 다 없으면 null (호출 자체를 하지 않아야 함)
*/
export function buildOrderRequestHeaders(): Record<string, string> | null {
const authToken = getAuthToken();
const guestToken = getGuestOrderToken();
if (!authToken && !guestToken) {
return null;
}
const headers: Record<string, string> = { Accept: 'application/json' };
if (authToken) {
headers.Authorization = `Bearer ${authToken}`;
} else if (guestToken) {
headers['X-Guest-Order-Token'] = guestToken;
}
return headers;
}
@@ -407,7 +407,13 @@ export async function requestPaymentHandler(action: PaymentAction, _context?: un
// 4-2. 과세/비과세 금액 조회 (optional — 실패해도 결제 진행)
try {
const orderRes = await G7Core.api.get(`/modules/sirsoft-ecommerce/user/orders/${pgPaymentData.order_number}`);
// 비회원 주문은 X-Guest-Order-Token 이 없으면 서버가 주문을 찾지 못한다.
// 헤더를 빼면 과세/비과세 금액이 조회되지 않아 결제사에 세금 구분이 빠진 채 전달된다.
const guestToken = G7Core?.state?.get?.('_global')?.guestOrderToken;
const orderRes = await G7Core.api.get(
`/modules/sirsoft-ecommerce/user/orders/${pgPaymentData.order_number}`,
guestToken ? { headers: { 'X-Guest-Order-Token': guestToken } } : undefined,
);
const od = orderRes?.data as Record<string, unknown> | null | undefined;
if (od) {
const taxAmt = Number(od['total_tax_amount'] ?? 0);
@@ -1,9 +1,13 @@
import { buildOrderRequestHeaders } from './guestOrderToken';
const PLUGIN_ID = 'sirsoft-pay_nicepayments';
const FLAG = '__nicepayOrderShowInjectorInstalled';
const ROW_ID = 'nicepay-mp-receipt-row';
const VBANK_BLOCK_ID = 'nicepay-mp-vbank-info';
const ORDER_SHOW_RE = /^\/mypage\/orders\/([^/]+)$/;
// 회원 마이페이지(/mypage/orders/{N}) 와 비회원 주문 상세(/shop/guest/orders/{N}) 를 함께 매칭한다.
// 비회원 경로를 빼면 그 화면에서는 영수증 정보가 조회조차 되지 않는다 (서버는 지원한다).
const ORDER_SHOW_RE = /^(?:\/mypage\/orders\/([^/]+)|\/shop\/guest\/orders\/([^/]+))$/;
interface Payment {
pg_provider?: string;
@@ -42,16 +46,14 @@ function getOrderFromState(orderNumber: string): OrderData | null {
}
}
function getToken(): string | null {
return localStorage.getItem('auth_token');
}
async function fetchReceiptInfo(orderNumber: string): Promise<ReceiptInfo | null> {
const token = getToken();
if (!token) return null;
const headers = buildOrderRequestHeaders();
if (!headers) return null;
try {
const res = await fetch(`/api/plugins/${PLUGIN_ID}/user/orders/${orderNumber}/receipt`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
headers,
credentials: 'same-origin',
});
if (!res.ok) return null;
return (await res.json()) as ReceiptInfo;
@@ -250,7 +252,9 @@ function startPolling(orderNumber: string): void {
function onRouteChange(): void {
const match = location.pathname.match(ORDER_SHOW_RE);
if (match) startPolling(match[1]);
// 회원 그룹(match[1]) 또는 비회원 그룹(match[2]) 중 한쪽이 채워진다.
const segment = match?.[1] ?? match?.[2];
if (segment) startPolling(segment);
}
export function installMypageOrderShowInjector(): void {
@@ -1,3 +1,5 @@
import { buildOrderRequestHeaders } from './guestOrderToken';
const PLUGIN_ID = 'sirsoft-pay_nicepayments';
const FLAG = '__nicepayOcReceiptInjectorInstalled';
const BTN_ID = 'nicepay-oc-receipt-btn';
@@ -16,17 +18,16 @@ interface ReceiptInfo {
payment_method_display_label?: string | null;
}
function getToken(): string | null {
return localStorage.getItem('auth_token');
}
async function fetchPayment(orderNumber: string): Promise<Payment | null> {
const token = getToken();
if (!token) return null;
// 회원 토큰 또는 비회원 주문 조회 토큰 중 하나는 있어야 한다. 비회원 분기를 비우면
// 비회원 손님에게는 영수증 버튼이 아예 나타나지 않는다 (서버는 지원한다).
const headers = buildOrderRequestHeaders();
if (!headers) return null;
try {
const res = await fetch(`/api/modules/sirsoft-ecommerce/user/orders/${orderNumber}`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
headers,
credentials: 'same-origin',
});
if (!res.ok) return null;
const data = (await res.json()) as { data?: { payment?: Payment } };
@@ -37,12 +38,13 @@ async function fetchPayment(orderNumber: string): Promise<Payment | null> {
}
async function fetchReceiptInfo(orderNumber: string): Promise<ReceiptInfo | null> {
const token = getToken();
if (!token) return null;
const headers = buildOrderRequestHeaders();
if (!headers) return null;
try {
const res = await fetch(`/api/plugins/${PLUGIN_ID}/user/orders/${orderNumber}/receipt`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
headers,
credentials: 'same-origin',
});
if (!res.ok) return null;
return (await res.json()) as ReceiptInfo;
@@ -6,6 +6,10 @@
## [1.0.3] - 2026-09-04
### Fixed
- 비회원으로 결제하다 결제창을 닫았을 때 취소 이력이 남지 않던 문제를 고쳤습니다. 이력을 남기는 요청에 비회원 주문 확인값이 함께 가지 않아 서버가 그 주문을 찾지 못했고, 화면에는 평소와 같은 안내가 떠서 기록이 빠졌다는 사실이 드러나지 않았습니다.
### Security
- 결제창 프로그램을 불러오는 주소가 토스페이먼츠의 주소인지 불러오기 직전에 확인합니다. 확인되지 않는 주소면 결제를 진행하지 않고 안내를 표시합니다. 또한 결제창 프로그램이 실제로 준비되었는지를 기준으로 다음 단계를 진행하도록 바꿔, 프로그램이 아직 준비되지 않았는데 결제창이 열리지 않고 멈추던 상황을 없앴습니다.
File diff suppressed because one or more lines are too long
@@ -39,6 +39,14 @@
하나(카드 한 장)를 열지, 사용자가 고른 개별 토스 결제수단(`params.paymentMethodId`)을
지정해 열지가 갈리지만(`params.paymentMethod`, 미지정 시 `_local.paymentMethod` 참조) 그
분기도 이 핸들러 하나 안에서 처리합니다.
구매자가 결제창을 닫으면(`USER_CANCEL`) 이 핸들러가 이커머스 모듈의 결제 취소 기록
엔드포인트(`orders/{orderNumber}/cancel-payment`)를 부릅니다. 이 엔드포인트는 회원과
비회원이 공유하고 서버가 소유권을 대조하므로, 비회원 주문이면 `_global.guestOrderToken`
을 `X-Guest-Order-Token` 헤더로 함께 보내야 합니다 — 그 토큰은 주문 생성 직후 체크아웃이
발급합니다. 헤더가 빠지면 서버가 404 로 거부하는데, 여기서는 `console.warn` 만 남기고
취소 안내 모달이 평소대로 뜨기 때문에 이력이 유실된 사실이 화면에 드러나지 않습니다.
`G7Core.api` 는 레이아웃의 `globalHeaders` 를 타지 않으므로 헤더는 호출부가 직접 붙입니다.
<!-- @intent END -->
## 전역 진입점
@@ -27,7 +27,7 @@ const CLIENT_CONFIG_DATA = {
describe('requestPaymentHandler', () => {
let mockG7Core: {
api: { get: ReturnType<typeof vi.fn>; post: ReturnType<typeof vi.fn> };
state: { setLocal: ReturnType<typeof vi.fn> };
state: { setLocal: ReturnType<typeof vi.fn>; get: ReturnType<typeof vi.fn> };
modal: { open: ReturnType<typeof vi.fn> };
};
@@ -37,7 +37,7 @@ describe('requestPaymentHandler', () => {
get: vi.fn().mockResolvedValue(CLIENT_CONFIG_DATA),
post: vi.fn().mockResolvedValue({ success: true }),
},
state: { setLocal: vi.fn() },
state: { setLocal: vi.fn(), get: vi.fn().mockReturnValue({}) },
modal: { open: vi.fn() },
};
(window as any).G7Core = mockG7Core;
@@ -136,7 +136,28 @@ describe('requestPaymentHandler', () => {
{
cancel_code: 'USER_CANCEL',
cancel_message: 'User cancelled',
}
},
undefined
);
consoleInfoSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
it('비회원 컨텍스트에서는 취소 기록에 X-Guest-Order-Token 헤더를 첨부한다', async () => {
// 이 엔드포인트는 회원/비회원 공유라 서버가 소유권을 대조한다. 토큰이 빠지면
// 서버가 404 로 거부하는데 화면은 console.warn 만 남기고 평소대로 취소 안내를
// 띄우므로, 이력이 유실된 사실이 어디에도 드러나지 않는다.
const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
mockG7Core.state.get.mockReturnValue({ guestOrderToken: 'guest-token-abc' });
await callWithCancel();
expect(mockG7Core.api.post).toHaveBeenCalledWith(
'/modules/sirsoft-ecommerce/orders/ORD-002/cancel-payment',
expect.anything(),
{ headers: { 'X-Guest-Order-Token': 'guest-token-abc' } }
);
consoleInfoSpy.mockRestore();
@@ -400,13 +400,24 @@ export async function requestPaymentHandler(action: any, _context?: any): Promis
console.info('[sirsoft-tosspayments] Payment cancelled by user');
// 1. 결제 취소 이력 기록 API 호출 (PG사 응답값 전달)
//
// 이 엔드포인트는 회원/비회원 공유라 서버가 소유권을 대조한다. 비회원 주문은 조회
// 토큰이 그 증명이므로 반드시 함께 보낸다 — 빠지면 서버가 404 로 거부하고, 여기서는
// console.warn 만 남긴 채 취소 안내 모달이 평소대로 떠서 이력 유실이 드러나지 않는다.
// 토큰은 주문 생성 직후 체크아웃이 발급해 _global.guestOrderToken 에 넣어 둔다.
try {
const guestToken = G7Core?.state?.get?.('_global')?.guestOrderToken;
const config = guestToken
? { headers: { 'X-Guest-Order-Token': guestToken } }
: undefined;
await G7Core.api.post(
`/modules/sirsoft-ecommerce/orders/${pgPaymentData.order_number}/cancel-payment`,
{
cancel_code: error.code,
cancel_message: error.message,
}
},
config
);
} catch (e) {
console.warn('[sirsoft-tosspayments] Failed to record cancellation', e);
+22 -11
View File
@@ -68,22 +68,34 @@ if (! function_exists('installer_binary_path_shape_ok')) {
if (! function_exists('installer_binary_path_is_absolute')) {
/**
* 절대경로인지 판정합니다 (POSIX / Windows 드라이브 / UNC).
* 절대경로인지 판정합니다 (POSIX / Windows 드라이브).
*
* UNC(`\\server\share`, `//server/share`)와 스트림 래퍼(`scheme://`)는 절대경로로
* 인정하지 않는다. UNC 는 공격자가 지정한 원격 SMB 공유의 파일을 그대로 실행하게 만들고,
* 스트림 래퍼는 로컬 파일이 아닌 것을 실행 대상으로 삼는다 (KVE-2026-2043).
* 판정은 전부 순수 문자열 규칙이라 `open_basedir` 회귀(#361)와 무관하다.
*
* @param string $token 경로 토큰
* @return bool 절대경로면 true
*/
function installer_binary_path_is_absolute(string $token): bool
{
// 스트림 래퍼(`scheme://`) 거부. 단일 문자 스킴은 Windows 드라이브와 구분되지
// 않으므로 두 글자 이상만 스킴으로 본다.
if (preg_match('#^[A-Za-z][A-Za-z0-9+.-]+://#', $token)) {
return false;
}
// UNC 거부 — 슬래시/백슬래시 두 형태 모두.
if (str_starts_with($token, '\\\\') || str_starts_with($token, '//')) {
return false;
}
if ($token[0] === '/') {
return true;
}
if (preg_match('#^[A-Za-z]:[\\\\/]#', $token)) {
return true;
}
return str_starts_with($token, '\\\\');
return (bool) preg_match('#^[A-Za-z]:[\\\\/]#', $token);
}
}
@@ -94,6 +106,9 @@ if (! function_exists('installer_is_composer_binary_path')) {
* 이 자리는 인터프리터가 실행하는 스크립트가 되므로 이름 형태를 제한한다.
* 제한이 없으면 `PHP경로 /tmp/올려둔파일` 형태가 그대로 남는다.
*
* 이름은 composer 계열만 허용한다. 임의 이름의 `.phar` 를 열어 두면 업로드해 둔
* 아카이브를 그대로 실행시킬 수 있다 (KVE-2026-2043).
*
* @param string $token 경로 토큰
* @return bool Composer 계열이면 true
*/
@@ -105,11 +120,7 @@ if (! function_exists('installer_is_composer_binary_path')) {
$basename = basename(str_replace('\\', '/', $token));
if (preg_match('/^composer[0-9]*(\.[0-9]+)*(\.phar|\.exe|\.bat|\.cmd)?$/i', $basename)) {
return true;
}
return (bool) preg_match('/\.phar$/i', $basename);
return (bool) preg_match('/^composer[0-9]*(\.[0-9]+)*(\.phar|\.exe|\.bat|\.cmd)?$/i', $basename);
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
/**
* 인스톨러 .env 값 직렬화 정책.
*
* `.env` 는 줄 단위 형식이라 값에 개행이 섞이면 그 뒤가 새로운 환경변수 줄로 해석된다.
* 그래서 사용자 입력에서 온 모든 값은 이 파일의 serializeEnvValue() 한 관문을 지난다.
* 관문이 하나면 나중에 필드가 늘어도 그 값이 자동으로 같은 검사를 받는다.
*
* 개행을 조용히 지우지 않고 예외로 거부하는 이유: 삭제하면 운영자가 입력한 값과 실제로
* 저장된 값이 달라지는데 그 사실이 화면에 나타나지 않는다. 정상 입력에는 개행이 들어갈
* 일이 없으므로 거부가 안전한 기본값이다. (KVE-2026-2042)
*
* 이 파일은 의존성이 없어야 한다 — 인스톨러 본 흐름(functions.php)과 설치 워커
* (installer-runtime.php) 양쪽에서 단독으로 require 된다.
*/
if (! function_exists('installer_env_value_is_single_line')) {
/**
* 값이 한 줄인지(개행·NUL 이 없는지) 확인합니다.
*
* 서버측 사전 거부(폼 검증)에서 예외를 던지지 않고 판정만 필요할 때 씁니다.
*
* @param string $value 검사할 값
* @return bool 개행·NUL 이 없으면 true
*/
function installer_env_value_is_single_line(string $value): bool
{
return strpbrk($value, "\r\n\0") === false;
}
}
if (! function_exists('serializeEnvValue')) {
/**
* .env 에 기록할 값을 직렬화합니다.
*
* CR/LF/NUL 이 포함되면 InvalidArgumentException 을 던집니다(조용한 삭제 금지).
* 그 외에는 백슬래시·큰따옴표를 이스케이프하고 큰따옴표로 감쌉니다.
*
* @param string $value 직렬화할 값
* @return string 큰따옴표로 감싼 값
*
* @throws InvalidArgumentException 값에 개행 또는 NUL 이 포함된 경우
*/
function serializeEnvValue(string $value): string
{
if (! installer_env_value_is_single_line($value)) {
throw new InvalidArgumentException(
'Environment values must not contain line breaks or NUL bytes.'
);
}
if ($value === '') {
return '""';
}
$escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value);
return '"'.$escaped.'"';
}
}
+24 -35
View File
@@ -9,6 +9,7 @@
// UTF-8 정규화 / JSON 출력 헬퍼 (js_escape · getWebServerUser 가 사용)
require_once __DIR__.'/utf8.php';
require_once __DIR__.'/env-value.php';
/**
* HTML 이스케이프 함수
@@ -1061,22 +1062,10 @@ if (! function_exists('escapeEnvValue')) {
*/
function escapeEnvValue(string $value): string
{
// 개행 문자는 .env 라인 구분자이므로 사용자 입력에 포함되면 추가 변수 라인이 주입될 수 있다.
// CR/LF 모두 제거 — DB 비밀번호·바이너리 경로·토큰 등 정상 값에는 개행이 들어갈 일이 없다.
if ($value !== '') {
$value = str_replace(["\r", "\n"], '', $value);
}
// 빈 값은 빈 따옴표로
if ($value === '') {
return '""';
}
// 큰따옴표와 백슬래시를 이스케이프
$escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value);
// 큰따옴표로 감싸기
return '"'.$escaped.'"';
// 직렬화 정책은 serializeEnvValue 단일 관문이 소유한다 (KVE-2026-2042).
// 이 이름은 하위호환 별칭일 뿐이며, 개행을 조용히 지우던 종전 동작은 남기지 않는다 —
// 남겨 두면 다음 호출자가 그 경로로 같은 결함을 다시 들여온다.
return serializeEnvValue($value);
}
}
@@ -1102,21 +1091,21 @@ function generateEnvContent(): ?string
// 데이터베이스 설정 치환
$replacements = [
'DB_CONNECTION=mysql' => 'DB_CONNECTION=mysql',
'DB_WRITE_HOST=127.0.0.1' => 'DB_WRITE_HOST='.($config['db_write_host'] ?? '127.0.0.1'),
'DB_WRITE_PORT=3306' => 'DB_WRITE_PORT='.($config['db_write_port'] ?? '3306'),
'DB_WRITE_DATABASE=g7' => 'DB_WRITE_DATABASE='.($config['db_write_database'] ?? 'g7'),
'DB_WRITE_USERNAME=root' => 'DB_WRITE_USERNAME='.($config['db_write_username'] ?? 'root'),
'DB_WRITE_PASSWORD=' => 'DB_WRITE_PASSWORD='.escapeEnvValue($config['db_write_password'] ?? ''),
'DB_PREFIX=g7_' => 'DB_PREFIX='.($config['db_prefix'] ?? 'g7_'),
'DB_WRITE_HOST=127.0.0.1' => 'DB_WRITE_HOST='.serializeEnvValue((string) ($config['db_write_host'] ?? '127.0.0.1')),
'DB_WRITE_PORT=3306' => 'DB_WRITE_PORT='.serializeEnvValue((string) ($config['db_write_port'] ?? '3306')),
'DB_WRITE_DATABASE=g7' => 'DB_WRITE_DATABASE='.serializeEnvValue((string) ($config['db_write_database'] ?? 'g7')),
'DB_WRITE_USERNAME=root' => 'DB_WRITE_USERNAME='.serializeEnvValue((string) ($config['db_write_username'] ?? 'root')),
'DB_WRITE_PASSWORD=' => 'DB_WRITE_PASSWORD='.serializeEnvValue((string) ($config['db_write_password'] ?? '')),
'DB_PREFIX=g7_' => 'DB_PREFIX='.serializeEnvValue((string) ($config['db_prefix'] ?? 'g7_')),
];
// Read DB 설정
if (! empty($config['use_read_db']) && $config['use_read_db']) {
$replacements['DB_READ_HOST='] = 'DB_READ_HOST='.($config['db_read_host'] ?? '127.0.0.1');
$replacements['DB_READ_PORT='] = 'DB_READ_PORT='.($config['db_read_port'] ?? '3306');
$replacements['DB_READ_DATABASE='] = 'DB_READ_DATABASE='.($config['db_read_database'] ?? 'g7');
$replacements['DB_READ_USERNAME='] = 'DB_READ_USERNAME='.($config['db_read_username'] ?? 'root');
$replacements['DB_READ_PASSWORD='] = 'DB_READ_PASSWORD='.escapeEnvValue($config['db_read_password'] ?? '');
$replacements['DB_READ_HOST='] = 'DB_READ_HOST='.serializeEnvValue((string) ($config['db_read_host'] ?? '127.0.0.1'));
$replacements['DB_READ_PORT='] = 'DB_READ_PORT='.serializeEnvValue((string) ($config['db_read_port'] ?? '3306'));
$replacements['DB_READ_DATABASE='] = 'DB_READ_DATABASE='.serializeEnvValue((string) ($config['db_read_database'] ?? 'g7'));
$replacements['DB_READ_USERNAME='] = 'DB_READ_USERNAME='.serializeEnvValue((string) ($config['db_read_username'] ?? 'root'));
$replacements['DB_READ_PASSWORD='] = 'DB_READ_PASSWORD='.serializeEnvValue((string) ($config['db_read_password'] ?? ''));
} else {
// Read DB를 사용하지 않는 경우 DB_READ_* 를 빈 값으로 둔다.
// config/database.php 의 write fallback(Elvis) 이 SELECT 를 write DB 로 넘기므로
@@ -1129,9 +1118,9 @@ function generateEnvContent(): ?string
}
// 앱 설정 치환
$replacements['APP_NAME=그누보드7'] = 'APP_NAME="'.($config['app_name'] ?? '그누보드7').'"';
$replacements['APP_ENV=production'] = 'APP_ENV='.($config['app_env'] ?? 'production');
$replacements['APP_URL=http://localhost'] = 'APP_URL='.($config['app_url'] ?? 'http://localhost');
$replacements['APP_NAME=그누보드7'] = 'APP_NAME='.serializeEnvValue((string) ($config['app_name'] ?? '그누보드7'));
$replacements['APP_ENV=production'] = 'APP_ENV='.serializeEnvValue((string) ($config['app_env'] ?? 'production'));
$replacements['APP_URL=http://localhost'] = 'APP_URL='.serializeEnvValue((string) ($config['app_url'] ?? 'http://localhost'));
// 언어 설정
$currentLang = getCurrentLanguage();
@@ -1147,15 +1136,15 @@ function generateEnvContent(): ?string
$coreGithubUrl = $config['core_update_github_url'] ?? 'https://github.com/gnuboard/g7';
$coreGithubToken = $config['core_update_github_token'] ?? '';
$replacements['G7_UPDATE_PENDING_PATH='] = 'G7_UPDATE_PENDING_PATH='.escapeEnvValue($corePendingPath);
$replacements['G7_UPDATE_GITHUB_URL=https://github.com/gnuboard/g7'] = 'G7_UPDATE_GITHUB_URL='.$coreGithubUrl;
$replacements['G7_UPDATE_GITHUB_TOKEN='] = 'G7_UPDATE_GITHUB_TOKEN='.escapeEnvValue($coreGithubToken);
$replacements['G7_UPDATE_PENDING_PATH='] = 'G7_UPDATE_PENDING_PATH='.serializeEnvValue((string) $corePendingPath);
$replacements['G7_UPDATE_GITHUB_URL=https://github.com/gnuboard/g7'] = 'G7_UPDATE_GITHUB_URL='.serializeEnvValue((string) $coreGithubUrl);
$replacements['G7_UPDATE_GITHUB_TOKEN='] = 'G7_UPDATE_GITHUB_TOKEN='.serializeEnvValue((string) $coreGithubToken);
// PHP CLI / Composer 바이너리 경로 치환
$phpBinary = $config['php_binary'] ?? 'php';
$composerBinary = $config['composer_binary'] ?? '';
$replacements['PHP_BINARY=php'] = 'PHP_BINARY='.escapeEnvValue($phpBinary);
$replacements['COMPOSER_BINARY='] = 'COMPOSER_BINARY='.escapeEnvValue($composerBinary);
$replacements['PHP_BINARY=php'] = 'PHP_BINARY='.serializeEnvValue((string) $phpBinary);
$replacements['COMPOSER_BINARY='] = 'COMPOSER_BINARY='.serializeEnvValue((string) $composerBinary);
foreach ($replacements as $search => $replace) {
$envContent = str_replace($search, $replace, $envContent);
+16 -23
View File
@@ -19,6 +19,9 @@ if (! defined('BASE_PATH')) {
throw new RuntimeException('installer-runtime.php requires BASE_PATH constant.');
}
// .env 값 직렬화 정책 (개행 주입 차단) — functions.php 와 같은 관문을 공유한다.
require_once __DIR__.'/env-value.php';
if (! defined('INSTALLER_RUNTIME_PATH')) {
define('INSTALLER_RUNTIME_PATH', BASE_PATH.'/storage/installer/runtime.php');
}
@@ -130,11 +133,11 @@ if (! function_exists('mergeRuntimeIntoEnv')) {
// DB 자격증명 치환 — state.config 결손 안전망
$write = $runtime['db']['write'] ?? null;
if (is_array($write)) {
$envContent = replaceEnvLine($envContent, 'DB_WRITE_HOST', (string) ($write['host'] ?? ''));
$envContent = replaceEnvLine($envContent, 'DB_WRITE_PORT', (string) ($write['port'] ?? ''));
$envContent = replaceEnvLine($envContent, 'DB_WRITE_DATABASE', (string) ($write['database'] ?? ''));
$envContent = replaceEnvLine($envContent, 'DB_WRITE_USERNAME', (string) ($write['username'] ?? ''));
$envContent = replaceEnvLine($envContent, 'DB_WRITE_PASSWORD', escapeEnvValue((string) ($write['password'] ?? '')));
$envContent = replaceEnvLine($envContent, 'DB_WRITE_HOST', serializeEnvValue((string) ($write['host'] ?? '')));
$envContent = replaceEnvLine($envContent, 'DB_WRITE_PORT', serializeEnvValue((string) ($write['port'] ?? '')));
$envContent = replaceEnvLine($envContent, 'DB_WRITE_DATABASE', serializeEnvValue((string) ($write['database'] ?? '')));
$envContent = replaceEnvLine($envContent, 'DB_WRITE_USERNAME', serializeEnvValue((string) ($write['username'] ?? '')));
$envContent = replaceEnvLine($envContent, 'DB_WRITE_PASSWORD', serializeEnvValue((string) ($write['password'] ?? '')));
}
// Read DB — runtime 에 read 키가 있을 때(use_read_db=true)만 명시 기록한다.
@@ -143,11 +146,11 @@ if (! function_exists('mergeRuntimeIntoEnv')) {
// (이슈 #63)
$read = $runtime['db']['read'] ?? null;
if (is_array($read)) {
$envContent = replaceEnvLine($envContent, 'DB_READ_HOST', (string) ($read['host'] ?? ''));
$envContent = replaceEnvLine($envContent, 'DB_READ_PORT', (string) ($read['port'] ?? ''));
$envContent = replaceEnvLine($envContent, 'DB_READ_DATABASE', (string) ($read['database'] ?? ''));
$envContent = replaceEnvLine($envContent, 'DB_READ_USERNAME', (string) ($read['username'] ?? ''));
$envContent = replaceEnvLine($envContent, 'DB_READ_PASSWORD', escapeEnvValue((string) ($read['password'] ?? '')));
$envContent = replaceEnvLine($envContent, 'DB_READ_HOST', serializeEnvValue((string) ($read['host'] ?? '')));
$envContent = replaceEnvLine($envContent, 'DB_READ_PORT', serializeEnvValue((string) ($read['port'] ?? '')));
$envContent = replaceEnvLine($envContent, 'DB_READ_DATABASE', serializeEnvValue((string) ($read['database'] ?? '')));
$envContent = replaceEnvLine($envContent, 'DB_READ_USERNAME', serializeEnvValue((string) ($read['username'] ?? '')));
$envContent = replaceEnvLine($envContent, 'DB_READ_PASSWORD', serializeEnvValue((string) ($read['password'] ?? '')));
} else {
$envContent = replaceEnvLine($envContent, 'DB_READ_HOST', '');
$envContent = replaceEnvLine($envContent, 'DB_READ_PORT', '');
@@ -157,7 +160,7 @@ if (! function_exists('mergeRuntimeIntoEnv')) {
}
if (isset($runtime['db']['prefix'])) {
$envContent = replaceEnvLine($envContent, 'DB_PREFIX', (string) $runtime['db']['prefix']);
$envContent = replaceEnvLine($envContent, 'DB_PREFIX', serializeEnvValue((string) $runtime['db']['prefix']));
}
// APP_KEY 치환
@@ -188,18 +191,8 @@ if (! function_exists('escapeEnvValue')) {
*/
function escapeEnvValue(string $value): string
{
// CR/LF 제거 — .env 라인 주입 차단 (functions.php 의 정의와 동일 정책)
if ($value !== '') {
$value = str_replace(["\r", "\n"], '', $value);
}
if ($value === '') {
return '""';
}
$escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value);
return '"'.$escaped.'"';
// 직렬화 정책은 serializeEnvValue 단일 관문이 소유한다 (KVE-2026-2042).
return serializeEnvValue($value);
}
}
@@ -236,6 +236,31 @@ function handleStep3Post(string $currentLang, array &$formData, array &$errors):
? $assetUrlMode
: '';
// .env 로 기록되는 사용자 입력의 사전 거부 (KVE-2026-2042)
// 직렬화기가 최종 관문이지만, 그 단계의 실패는 설치 진행 중에 예외로 드러나 운영자가
// 어느 입력이 문제인지 알기 어렵다. 여기서 필드별로 거부해 화면에 사유를 표시한다.
require_once __DIR__.'/env-value.php';
foreach (['app_name', 'app_url', 'core_update_github_url'] as $envField) {
if (! installer_env_value_is_single_line((string) ($formData[$envField] ?? ''))) {
$errors[$envField] = lang('error_env_value_line_break');
}
}
// URL 필드는 형태까지 확인한다 — 개행이 없어도 스킴이 없는 값이 그대로 기록되면
// 배포 후 절대 URL 생성이 어긋난다.
foreach (['app_url', 'core_update_github_url'] as $urlField) {
$urlValue = trim((string) ($formData[$urlField] ?? ''));
if ($urlValue === '' || isset($errors[$urlField])) {
continue;
}
$scheme = strtolower((string) parse_url($urlValue, PHP_URL_SCHEME));
if (! filter_var($urlValue, FILTER_VALIDATE_URL) || ! in_array($scheme, ['http', 'https'], true)) {
$errors[$urlField] = lang('error_env_value_invalid_url', ['value' => $urlValue]);
}
}
// 코어 업데이트 _pending 경로 검증 (입력된 경우만)
$corePendingPath = trim($formData['core_update_pending_path'] ?? '');
if ($corePendingPath !== '') {
+4 -2
View File
@@ -861,6 +861,8 @@ Firewalls or proxies may be blocking long-lived HTTP connections.',
'core_pending_path_ok' => 'Path is valid.',
'core_pending_info' => 'Owner: :owner, Group: :group, Permissions: :permissions',
'error_core_pending_not_directory' => 'The specified path is not a directory.',
'error_env_value_line_break' => 'Line breaks are not allowed.',
'error_env_value_invalid_url' => 'This is not a valid URL (:value). Enter an address starting with http:// or https://.',
'error_core_pending_not_writable' => 'Directory (:path) is not writable.',
'error_core_pending_parent_not_writable' => 'Parent directory (:path) is not writable, cannot create automatically.',
'error_path_required' => 'Please enter a path.',
@@ -885,8 +887,8 @@ Firewalls or proxies may be blocking long-lived HTTP connections.',
'error_php_path_empty' => 'PHP binary path is empty.',
'error_php_path_not_exists' => 'File does not exist: :path',
'error_php_exec_failed' => 'PHP execution failed: :path',
'error_php_binary_path_not_allowed' => 'This PHP path format cannot be used (:path). Enter the absolute path of the executable only — options (starting with -), relative paths and .. are not allowed.',
'error_composer_binary_path_not_allowed' => 'This Composer path format cannot be used (:path). Enter the absolute path of the composer executable or a .phar file. For multi-PHP environments use the "absolute-php-path absolute-composer-path" format.',
'error_php_binary_path_not_allowed' => 'This PHP path format cannot be used (:path). Enter the absolute path of an executable on this server only — options (starting with -), relative paths, .., network paths (\\\\server\\share or //server/share) and scheme:// forms are not allowed.',
'error_composer_binary_path_not_allowed' => 'This Composer path format cannot be used (:path). Enter the absolute path of a composer executable on this server — the file name must be a composer variant (composer, composer.phar, composer2.phar and so on); other .phar names, network paths (\\\\server\\share or //server/share) and scheme:// forms are not allowed. For multi-PHP environments use the "absolute-php-path absolute-composer-path" format.',
'error_php_version_too_low' => ':path — PHP :version (minimum :min required)',
'error_php_version_parse_failed' => 'Failed to parse PHP version.',
'error_php_cli_not_verified' => 'PHP CLI path has not been verified. Please click the "Verify Version" button.',
+4 -2
View File
@@ -861,6 +861,8 @@ ini_set(\'zlib.output_compression\', \'off\');
'core_pending_path_ok' => '경로가 유효합니다.',
'core_pending_info' => '소유자: :owner, 그룹: :group, 퍼미션: :permissions',
'error_core_pending_not_directory' => '지정한 경로가 디렉토리가 아닙니다.',
'error_env_value_line_break' => '줄바꿈 문자는 사용할 수 없습니다.',
'error_env_value_invalid_url' => '올바른 주소 형식이 아닙니다 (:value). http:// 또는 https:// 로 시작하는 주소를 입력하세요.',
'error_core_pending_not_writable' => '디렉토리(:path)에 쓰기 권한이 없습니다.',
'error_core_pending_parent_not_writable' => '상위 디렉토리(:path)에 쓰기 권한이 없어 자동 생성이 불가합니다.',
'error_path_required' => '경로를 입력해주세요.',
@@ -885,8 +887,8 @@ ini_set(\'zlib.output_compression\', \'off\');
'error_php_path_empty' => 'PHP 바이너리 경로가 비어있습니다.',
'error_php_path_not_exists' => '파일이 존재하지 않습니다: :path',
'error_php_exec_failed' => 'PHP 실행 실패: :path',
'error_php_binary_path_not_allowed' => '사용할 수 없는 PHP 경로 형식입니다 (:path). 실행 파일의 절대경로만 입력하세요 — 옵션(- 로 시작), 상대경로, .. 는 쓸 수 없습니다.',
'error_composer_binary_path_not_allowed' => '사용할 수 없는 Composer 경로 형식입니다 (:path). composer 실행 파일 또는 .phar 의 절대경로만 입력하세요. 멀티 PHP 환경은 "PHP절대경로 composer절대경로" 형식으로 입력할 수 있습니다.',
'error_php_binary_path_not_allowed' => '사용할 수 없는 PHP 경로 형식입니다 (:path). 이 서버 안에 있는 실행 파일의 절대경로만 입력하세요 — 옵션(- 로 시작), 상대경로, .., 네트워크 경로(\\\\서버\\공유 또는 //서버/공유), scheme:// 형태는 쓸 수 없습니다.',
'error_composer_binary_path_not_allowed' => '사용할 수 없는 Composer 경로 형식입니다 (:path). 이 서버 안에 있는 composer 실행 파일의 절대경로만 입력하세요 — 파일 이름은 composer 계열(composer, composer.phar, composer2.phar 등)이어야 하고, 다른 이름의 .phar, 네트워크 경로(\\\\서버\\공유 또는 //서버/공유), scheme:// 형태는 쓸 수 없습니다. 멀티 PHP 환경은 "PHP절대경로 composer절대경로" 형식으로 입력할 수 있습니다.',
'error_php_version_too_low' => ':path — PHP :version (최소 :min 필요)',
'error_php_version_parse_failed' => 'PHP 버전을 파싱할 수 없습니다.',
'error_php_cli_not_verified' => 'PHP CLI 경로가 확인되지 않았습니다. "버전 확인" 버튼을 클릭해주세요.',
+1 -1
View File
@@ -158,7 +158,7 @@ API 까지만 소유하고, 그 API 를 소비해 실제로 그리는 것은 이
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 0개 | — |
| Vitest | 143개 | `vitest.config.ts` |
| Vitest | 145개 | `vitest.config.ts` |
| Playwright | 8개 | `tests/Playwright` |
| 시나리오 매니페스트 | 3개 | `tests/scenarios` |
@@ -19,6 +19,8 @@
- 총 건수를 정확히 세지 못한 목록(대량 검색 결과, 게시글이 아주 많은 게시판 등)에서 페이지 번호가 전부 사라지고 현재 페이지 숫자 하나만 남던 문제를 수정했습니다. 이제 1 부터 현재 페이지까지의 번호와 다음 페이지 번호가 그대로 표시되며, 감춰지는 것은 마지막 페이지로 뛰는 버튼뿐입니다.
- 게시글·페이지 본문을 표시할 때 쓰는 HTML 정화 라이브러리가 구버전에 머물러 있던 문제를 고쳤습니다. 관리자 템플릿과 동일한 최신 버전으로 맞췄습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
- 글 수정 화면에서 비밀글의 기존 첨부 이미지가 빈 칸으로 표시되던 문제를 고쳤습니다. 미리보기를 불러오는 요청이 열람 자격을 함께 보내지 않아 거부되었고, 화면에는 오류가 나타나지 않았습니다.
- 비밀번호를 입력해 비밀글을 연 뒤 그 글의 첨부파일을 내려받으면 거부되던 문제를 고쳤습니다. 내려받기 버튼은 화면에 그대로 보이는데 눌러도 파일이 받아지지 않았고, 오류 메시지도 남지 않아 원인을 알기 어려웠습니다. 이미지를 크게 보는 화면에서 내려받을 때도 같은 문제가 있었으며 함께 고쳤습니다.
- 통화 표시·선호 통화 저장 관련 화면 동작 함수 4종이 실제 호출 규약과 다른 형태로 작성돼 있어, 호출되면 값이 전달되지 않고 상태가 잘못 기록되던 문제를 고쳤습니다.
- 주소 검색을 불러오지 못한 상태에서 주문서의 우편번호·주소를 직접 입력해도 값이 주문에 반영되지 않아 결제 버튼이 계속 눌리지 않던 문제를 고쳤습니다. 이제 직접 입력한 주소로 주문을 끝까지 진행할 수 있습니다.
- 레이아웃 편집기의 「액션 추가」로 만든 「테마 바꾸기」·「테마 초기화」·「화면 상태 바꾸기」 동작이 만들자마자 아무 일도 하지 않던 문제를 고쳤습니다. 편집기가 만들어 주는 값의 형태가 실제 동작이 읽는 형태와 달라 오류 표시도 없이 무시되고 있었습니다.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,27 @@
/**
* 비밀글 열람 확인 토큰 요청 헤더 (sirsoft-basic 공용)
*
* 레이아웃의 globalHeaders 배선은 데이터소스(DataSourceManager)와 apiCall 핸들러
* (ActionDispatcher)에만 적용된다. 코어 ApiClient(G7Core.api)를 직접 호출하는 경로는
* 그 배선을 타지 않아 Authorization·Accept-Language 만 실린다.
*
* 그래서 비밀번호로 원문을 연 사용자가 첨부파일을 받으려 하면, 화면은 다운로드 버튼을
* 내주는데(서버가 can_download=true 를 돌려준다) 실제 요청은 열람 사실을 증명하지 못해
* 403 으로 거부된다. 예외도 콘솔 오류도 남지 않고 다운로드만 조용히 실패한다.
*
* 직접 호출 경로가 늘어날 때 같은 결함이 재발하지 않도록, 그런 호출부는 이 한 곳을 통해
* 헤더를 만든다.
*/
/**
* 서버(SecretContentGate)가 읽는 열람 확인 토큰 헤더 이름.
*/
export declare const SECRET_VIEW_TOKEN_HEADER = "X-Board-Secret-View-Token";
/**
* 현재 전역 상태에 열람 확인 토큰이 있으면 요청 헤더 객체로 만들어 돌려줍니다.
*
* 토큰은 비밀번호 검증 응답으로만 발급되고 게시글 단위로 결속되므로, 무관한 요청에
* 실려도 서버 판정에 영향을 주지 않습니다.
*
* @return 토큰이 있으면 헤더 1개짜리 객체, 없으면 빈 객체
*/
export declare function secretContentHeaders(): Record<string, string>;
@@ -910,7 +910,7 @@ components:
```text
_user_base.json
├── globalHeaders: [X-Cart-Key 헤더 (이커머스, 인증 API)]
├── globalHeaders: [X-Cart-Key (이커머스·인증 API) / X-Guest-Order-Token (비회원 주문 후속 액션) / X-Board-Secret-View-Token (게시판 API)]
├── transition_overlay: { style: "skeleton", target: "main_content_area" }
├── init_actions: [initTheme, initCartKey, loadPreferredCurrency, setState(shopBase)]
├── data_sources:
@@ -934,7 +934,15 @@ _user_base.json
- `content` — 각 페이지의 메인 콘텐츠가 삽입되는 위치
**특수사항**:
- `globalHeaders`: 이커머스 API에 `X-Cart-Key` 자동 첨부
- `globalHeaders`: 패턴별 공통 헤더 자동 첨부
- `/api/modules/sirsoft-ecommerce/*` — `X-Cart-Key`, `X-Currency`, `X-Shipping-Country`
- `/api/modules/sirsoft-ecommerce/guest/orders/*` — `X-Guest-Order-Token` (비회원 주문 후속 액션)
- `/api/modules/sirsoft-board/*` — `X-Board-Secret-View-Token`
비밀번호를 입력해 비밀글 원문을 연 사실을 다음 요청으로 넘기는 값입니다. 서버는 이
사실을 검증 응답 하나에만 담고 있어서, 토큰을 싣지 않으면 원문을 연 사용자도 댓글·답글·
신고에서 전부 거부됩니다. 토큰은 게시글에 결속되므로 다른 글에는 통하지 않고, 비밀글이
아닌 요청에서는 서버가 아예 평가하지 않습니다. `board/types/basic/show.json` 의
비밀번호 검증 `onSuccess` 가 `_global.secretViewToken` 에 넣습니다.
- `transition_overlay.style: "skeleton"`: 페이지 전환 시 PageSkeleton 표시
- `responsive`: 모바일 오버레이/네비게이션은 `portable` breakpoint에서만 표시
- `auth_mode: "optional"`: 비회원도 장바구니 카운트 조회 가능
@@ -37,6 +37,13 @@
"headers": {
"X-Guest-Order-Token": "{{_global.guestOrderToken}}"
}
},
{
"_comment": "비밀글 열람 확인 토큰 — 비밀번호로 원문을 연 사실을 다음 요청(댓글·답글·신고)으로 넘긴다. 서버가 토큰을 게시글에 결속시키므로 다른 글에는 통하지 않고, 비밀글이 아닌 요청에서는 아예 평가되지 않는다. 결제 취소 기록(orders/{n}/cancel-payment) 처럼 헤더 없이는 정당 사용자가 막히는 경로라 globalHeaders 로 단일 배선한다.",
"pattern": "/api/modules/sirsoft-board/*",
"headers": {
"X-Board-Secret-View-Token": "{{_global.secretViewToken}}"
}
}
],
"transition_overlay": {
@@ -20,10 +20,13 @@
"refetchOnMount": true,
"initLocal": "form",
"loading_strategy": "blocking",
"_comment_verify_token": "검증 토큰은 자격증명이라 헤더로 보낸다 — 쿼리로 실으면 웹서버 접근 기록과 Referer 에 그대로 남는다. 빈 값은 엔진이 헤더에서 제외한다.",
"headers": {
"X-Board-Post-Verify-Token": "{{_local.verificationToken ?? ''}}"
},
"params": {
"post_id": "{{route?.id ?? ''}}",
"parent_id": "{{query?.parent_id ?? ''}}",
"verification_token": "{{_local.verificationToken ?? ''}}"
"parent_id": "{{query?.parent_id ?? ''}}"
},
"onSuccess": {
"comment": "refetch 후 _local.form을 응답값으로 직접 덮어씌우기 (비밀글 content 반영)",
@@ -59,10 +62,13 @@
"auth_mode": "optional",
"cache": false,
"refetchOnMount": true,
"_comment_verify_token": "검증 토큰은 자격증명이라 헤더로 보낸다 — 쿼리로 실으면 웹서버 접근 기록과 Referer 에 그대로 남는다. 빈 값은 엔진이 헤더에서 제외한다.",
"headers": {
"X-Board-Post-Verify-Token": "{{_local.verificationToken ?? ''}}"
},
"params": {
"post_id": "{{route?.id ?? ''}}",
"parent_id": "{{query?.parent_id ?? ''}}",
"verification_token": "{{_local.verificationToken ?? ''}}"
"parent_id": "{{query?.parent_id ?? ''}}"
},
"errorHandling": {
"403": {
@@ -190,7 +190,7 @@
"name": "Span",
"if": "{{post?.data?.is_new}}",
"props": {
"className": "px-2 py-1 text-xs font-bold bg-red-500 text-white rounded flex-shrink-0"
"className": "px-2 py-1 text-xs font-bold bg-red-500 dark:bg-red-600 text-white dark:text-white rounded flex-shrink-0"
},
"text": "N"
}
@@ -864,6 +864,14 @@
"data": "{{result}}"
}
},
{
"comment": "열람 확인 토큰 보관 — 비밀번호를 맞혔다는 사실은 이 응답에서만 살아 있다. 댓글·답글·신고는 각각 별도 요청이라, 이 토큰을 넘기지 않으면 원문을 연 사람도 화면이 내준 버튼에서 전부 거부된다. _user_base 의 globalHeaders 가 board API 요청에 자동으로 싣는다.",
"handler": "setState",
"params": {
"target": "global",
"secretViewToken": "{{result.secret_view_token ?? ''}}"
}
},
{
"handler": "setState",
"params": {
@@ -474,9 +474,55 @@
{
"if": "{{response.data.requires_pg_payment && response.data.pg_payment_handler}}",
"then": {
"handler": "{{response.data.pg_payment_handler}}",
"handler": "sequence",
"params": {
"pgPaymentData": "{{response.data.pg_payment_data}}"
"actions": [
{
"comment": "비회원 조회 토큰을 결제창을 열기 **전에** 발급한다. 결제창을 닫으면 그 즉시 취소 이력 기록(orders/{n}/cancel-payment)이 호출되는데, 그 엔드포인트는 회원/비회원 공유라 서버가 소유권을 대조한다 — 토큰이 없으면 정당한 비회원의 취소 기록이 404 로 거부되고 화면에는 아무 신호도 남지 않는다. 종전에는 non-PG 분기에서만 발급했다.",
"handler": "conditions",
"conditions": [
{
"if": "{{!_global.currentUser?.uuid}}",
"then": {
"handler": "apiCall",
"target": "/api/modules/sirsoft-ecommerce/guest/orders/verify",
"auth_mode": "optional",
"params": {
"method": "POST",
"body": {
"order_number": "{{response.data.order.order_number}}",
"orderer_phone": "{{_local.orderer?.phone ?? ''}}",
"guest_lookup_password": "{{_local.guestLookupPassword ?? ''}}"
}
},
"onSuccess": [
{
"comment": "토큰 + 주문번호 + 만료시각 sessionStorage 저장 + _global.guestOrderToken 동기 set (모듈 커스텀 핸들러).",
"handler": "saveGuestOrderToken",
"params": {
"token": "{{response.data.guest_order_token}}",
"orderNumber": "{{response.data.order.order_number}}",
"expiresAt": "{{response.data.expires_at}}"
}
}
],
"onError": [
{
"comment": "verify 실패는 결제 자체를 막지 않는다 — 주문은 이미 생성되었고 사용자는 나중에 조회 폼으로 재인증할 수 있다. 취소 이력만 남지 않는다.",
"handler": "suppress"
}
]
}
}
]
},
{
"handler": "{{response.data.pg_payment_handler}}",
"params": {
"pgPaymentData": "{{response.data.pg_payment_data}}"
}
}
]
}
}
},
@@ -10,6 +10,7 @@ import React, { useState, useEffect } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { secretContentHeaders } from '../../../support/secretContentHeaders';
import { Div } from '../../basic/Div';
import { Button } from '../../basic/Button';
import { Span } from '../../basic/Span';
@@ -92,8 +93,11 @@ export const SortableThumbnailItem: React.FC<SortableThumbnailItemProps> = ({
const loadAuthenticatedImage = async () => {
try {
// 비밀글 첨부는 서버가 열람 권한을 재확인한다. globalHeaders 는 이 경로에
// 적용되지 않으므로 열람 확인 토큰을 직접 싣는다 (없으면 빈 객체라 영향 없음).
const blob = await G7Core.api.get(downloadUrl, {
responseType: 'blob',
headers: secretContentHeaders(),
});
if (isMounted && blob) {
objectUrl = URL.createObjectURL(blob);
@@ -16,6 +16,7 @@ import imageCompression from 'browser-image-compression';
import type { Attachment, PendingFile, FileUploaderProps, ApiEndpoints } from './types';
import { formatFileSize, extractErrorMessage, t } from './utils';
import { isCrossOriginAssetUrl } from '../assetOrigin';
import { secretContentHeaders } from '../../../support/secretContentHeaders';
/** 동봉한 browser-image-compression 버전 */
const IMAGE_COMPRESSION_VERSION = '2.0.2';
@@ -744,8 +745,11 @@ export function useFileUploader(options: UseFileUploaderOptions): UseFileUploade
}
try {
// 비밀글 첨부는 서버가 열람 권한을 재확인한다. globalHeaders 는 이 경로에
// 적용되지 않으므로 열람 확인 토큰을 직접 싣는다 (없으면 빈 객체라 영향 없음).
const blob = await G7Core.api.get(file.download_url, {
responseType: 'blob',
headers: secretContentHeaders(),
});
if (!cancelled && blob) {
const objectUrl = URL.createObjectURL(blob);
@@ -18,6 +18,7 @@ import 'yet-another-react-lightbox/styles.css';
import 'yet-another-react-lightbox/plugins/counter.css';
import 'yet-another-react-lightbox/plugins/thumbnails.css';
import { secretContentHeaders } from '../../support/secretContentHeaders';
import { Button } from '../basic/Button';
import { I } from '../basic/I';
import { isCrossOriginAssetUrl } from './assetOrigin';
@@ -95,8 +96,11 @@ export interface ImageGalleryProps {
*/
const downloadAuthenticatedFile = async (url: string, filename: string): Promise<void> => {
try {
// 비밀글 첨부는 서버가 열람 권한을 재확인한다. globalHeaders 는 이 경로에 적용되지
// 않으므로 열람 확인 토큰을 여기서 직접 싣는다 (없으면 빈 객체라 영향 없음).
const blob = await G7Core.api.get(url, {
responseType: 'blob',
headers: secretContentHeaders(),
});
if (blob) {
@@ -0,0 +1,89 @@
/**
* ImageGallery 인증 다운로드의 비밀글 열람 토큰 동반 회귀 테스트
*
* 레이아웃의 globalHeaders 는 데이터소스와 apiCall 핸들러에만 적용되고, 코어
* ApiClient(G7Core.api)를 직접 호출하는 이 경로는 그 배선을 타지 않는다. 그래서
* 비밀번호로 비밀글을 연 사용자에게 화면은 다운로드 버튼을 내주는데(서버가
* can_download=true 를 돌려준다) 실제 요청은 열람 사실을 증명하지 못해 403 이 된다.
* 예외도 콘솔 오류도 남지 않고 다운로드만 조용히 실패한다.
*
* @scenario surface=image_gallery, transport=direct_api_client
*
* @effects gallery_download_carries_secret_view_token
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
/**
* ImageGallery 는 모듈 최상단에서 `const G7Core = window.G7Core` 로 참조를 캡처한다.
* 그래서 전역을 세운 뒤 동적 import 해야 그 캡처가 테스트 mock 을 가리킨다 —
* 정적 import 는 캡처가 undefined 로 굳어 요청 자체가 일어나지 않는다.
*/
async function loadDownload() {
vi.resetModules();
const mod = await import('../ImageGallery');
return mod.executeImageDownload;
}
describe('executeImageDownload — 비밀글 열람 확인 토큰 동반', () => {
let apiGetSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
apiGetSpy = vi.fn().mockResolvedValue(new Blob(['x']));
(window as any).G7Core = {
t: (key: string) => key,
api: { get: apiGetSpy },
toast: { error: vi.fn() },
};
(URL as any).createObjectURL = vi.fn().mockReturnValue('blob:mock');
(URL as any).revokeObjectURL = vi.fn();
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(vi.fn());
});
afterEach(() => {
delete (window as any).G7Core;
vi.restoreAllMocks();
});
it('토큰이 있으면 인증 다운로드 요청 헤더에 실어 보낸다', async () => {
(window as any).G7Core.state = {
get: (key: string) => (key === '_global' ? { secretViewToken: 'tok-gallery-view' } : undefined),
};
const executeImageDownload = await loadDownload();
await executeImageDownload({
src: '/api/modules/sirsoft-board/boards/free/attachment/aaaaaaaaaaaa/preview',
downloadUrl: '/api/modules/sirsoft-board/boards/free/attachment/aaaaaaaaaaaa',
filename: 'a.jpg',
downloadRequiresAuth: true,
} as any);
expect(apiGetSpy).toHaveBeenCalledWith(
'/api/modules/sirsoft-board/boards/free/attachment/aaaaaaaaaaaa',
{
responseType: 'blob',
headers: { 'X-Board-Secret-View-Token': 'tok-gallery-view' },
},
);
});
it('토큰이 없으면 헤더를 만들지 않는다 (빈 값 전송 금지)', async () => {
(window as any).G7Core.state = { get: () => ({}) };
const executeImageDownload = await loadDownload();
await executeImageDownload({
src: '/x',
downloadUrl: '/api/modules/sirsoft-board/boards/free/attachment/bbbbbbbbbbbb',
filename: 'b.jpg',
downloadRequiresAuth: true,
} as any);
expect(apiGetSpy).toHaveBeenCalledWith(
'/api/modules/sirsoft-board/boards/free/attachment/bbbbbbbbbbbb',
{ responseType: 'blob', headers: {} },
);
});
});
@@ -12,7 +12,7 @@
* 실제 호출 인자를 단언한다(거짓 통과 방지).
*
* @scenario card=user_post
* @effects download_via_api_client_with_token,filename_preserved,download_failure_shows_error_toast
* @effects download_via_api_client_with_token,filename_preserved,download_failure_shows_error_toast,download_carries_secret_view_token
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
@@ -58,10 +58,36 @@ describe('downloadAttachmentHandler — 토큰 동반 첨부 다운로드 (이
expect(apiGetSpy).toHaveBeenCalledTimes(1);
expect(apiGetSpy).toHaveBeenCalledWith(
'/api/modules/sirsoft-board/boards/notice/attachment/abc123',
{ responseType: 'blob' },
{ responseType: 'blob', headers: {} },
);
});
it('비밀글 열람 확인 토큰이 있으면 요청 헤더에 실어 보낸다', async () => {
(window as any).G7Core.state = {
get: (key: string) => (key === '_global' ? { secretViewToken: 'tok-secret-view-40' } : undefined),
};
await downloadAttachmentHandler({
params: { url: '/api/modules/sirsoft-board/boards/notice/attachment/abc123', filename: 'a.pdf' },
});
expect(apiGetSpy).toHaveBeenCalledWith(
'/api/modules/sirsoft-board/boards/notice/attachment/abc123',
{
responseType: 'blob',
headers: { 'X-Board-Secret-View-Token': 'tok-secret-view-40' },
},
);
});
it('토큰이 없으면 헤더를 만들지 않는다 (빈 값 전송 금지)', async () => {
(window as any).G7Core.state = { get: () => ({}) };
await downloadAttachmentHandler({ params: { url: '/d/x', filename: 'a.pdf' } });
expect(apiGetSpy).toHaveBeenCalledWith('/d/x', { responseType: 'blob', headers: {} });
});
it('받은 blob 을 objectURL 로 변환해 filename 으로 다운로드하고 revoke 한다', async () => {
await downloadAttachmentHandler({
params: { url: '/d/x', filename: '보고서.pdf' },
@@ -16,6 +16,8 @@
* 비회원: 토큰이 없으므로 종전과 동일하게 user_id 가 NULL 로 남는다(현행 정책 유지).
*/
import { secretContentHeaders } from '../support/secretContentHeaders';
// Logger 설정 (G7Core 초기화 전에도 동작하도록 폴백 포함)
const logger = (window as any).G7Core?.createLogger?.('Handler:DownloadAttachment') ?? {
log: (...args: unknown[]) => console.log('[Handler:DownloadAttachment]', ...args),
@@ -49,7 +51,12 @@ export async function downloadAttachmentHandler(action?: any, _context?: any): P
try {
// 코어 ApiClient 경로 → Authorization(Bearer) 헤더 자동 첨부 → 회원 토큰이 실린다.
const blob = await G7Core.api.get(url, { responseType: 'blob' });
// 비밀글 첨부는 서버가 열람 권한을 재확인한다. globalHeaders 는 이 경로에 적용되지
// 않으므로 열람 확인 토큰을 여기서 직접 싣는다 (없으면 빈 객체라 영향 없음).
const blob = await G7Core.api.get(url, {
responseType: 'blob',
headers: secretContentHeaders(),
});
if (blob) {
const objectUrl = URL.createObjectURL(blob);
@@ -0,0 +1,77 @@
/**
* 비밀글 열람 확인 토큰 헤더 헬퍼 + 직접 호출 경로 배선 검증
*
* globalHeaders 는 데이터소스와 apiCall 핸들러에만 적용된다. 코어 ApiClient
* (G7Core.api)를 직접 호출하는 경로는 그 배선을 타지 않아, 비밀번호로 원문을 연
* 사용자에게 화면이 버튼·썸네일을 내주고도 서버가 403 으로 거부한다. 예외도 콘솔
* 오류도 남지 않고 그 자리만 조용히 비는 결함이라 구조로 잠근다.
*
* @scenario surface=direct_api_client
*
* @effects secret_view_token_header_helper,direct_api_call_sites_carry_secret_header
*/
import { describe, it, expect, afterEach } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { secretContentHeaders, SECRET_VIEW_TOKEN_HEADER } from '../secretContentHeaders';
const SRC = resolve(__dirname, '../..');
afterEach(() => {
delete (window as any).G7Core;
});
describe('secretContentHeaders', () => {
it('전역 상태에 토큰이 있으면 헤더 객체를 만든다', () => {
(window as any).G7Core = {
state: { get: (k: string) => (k === '_global' ? { secretViewToken: 'tok-40' } : undefined) },
};
expect(secretContentHeaders()).toEqual({ [SECRET_VIEW_TOKEN_HEADER]: 'tok-40' });
});
it('토큰이 없거나 빈 문자열이면 빈 객체를 돌려준다 (빈 헤더 전송 금지)', () => {
(window as any).G7Core = { state: { get: () => ({ secretViewToken: '' }) } };
expect(secretContentHeaders()).toEqual({});
(window as any).G7Core = { state: { get: () => ({}) } };
expect(secretContentHeaders()).toEqual({});
});
it('G7Core 가 아직 없어도 예외를 던지지 않는다', () => {
expect(secretContentHeaders()).toEqual({});
});
});
describe('직접 호출 경로 배선', () => {
const CALL_SITES = [
'handlers/downloadAttachment.ts',
'components/composite/ImageGallery.tsx',
'components/composite/FileUploader/SortableThumbnailItem.tsx',
'components/composite/FileUploader/useFileUploader.ts',
];
it.each(CALL_SITES)('%s 가 blob 요청에 열람 토큰 헤더를 싣는다', (relative) => {
const source = readFileSync(resolve(SRC, relative), 'utf-8');
expect(
source.includes('secretContentHeaders'),
`${relative} 가 열람 토큰 헤더를 싣지 않습니다 — 비밀글 첨부에서 서버가 403 으로 거부하는데 화면에는 오류가 남지 않습니다.`
).toBe(true);
const blobCalls = source.split("responseType: 'blob'");
expect(
blobCalls.length - 1,
`${relative} 에 blob 요청이 없습니다 — 테스트가 실제 경로를 보고 있는지 확인이 필요합니다.`
).toBeGreaterThan(0);
blobCalls.slice(1).forEach((chunk, index) => {
expect(
chunk.slice(0, 200).includes('secretContentHeaders()'),
`${relative} 의 ${index + 1}번째 blob 요청에 열람 토큰 헤더가 빠졌습니다.`
).toBe(true);
});
});
});
@@ -0,0 +1,35 @@
/**
* 비밀글 열람 확인 토큰 요청 헤더 (sirsoft-basic 공용)
*
* 레이아웃의 globalHeaders 배선은 데이터소스(DataSourceManager)와 apiCall 핸들러
* (ActionDispatcher)에만 적용된다. 코어 ApiClient(G7Core.api)를 직접 호출하는 경로는
* 그 배선을 타지 않아 Authorization·Accept-Language 만 실린다.
*
* 그래서 비밀번호로 원문을 연 사용자가 첨부파일을 받으려 하면, 화면은 다운로드 버튼을
* 내주는데(서버가 can_download=true 를 돌려준다) 실제 요청은 열람 사실을 증명하지 못해
* 403 으로 거부된다. 예외도 콘솔 오류도 남지 않고 다운로드만 조용히 실패한다.
*
* 직접 호출 경로가 늘어날 때 같은 결함이 재발하지 않도록, 그런 호출부는 이 한 곳을 통해
* 헤더를 만든다.
*/
/**
* 서버(SecretContentGate)가 읽는 열람 확인 토큰 헤더 이름.
*/
export const SECRET_VIEW_TOKEN_HEADER = 'X-Board-Secret-View-Token';
/**
* 현재 전역 상태에 열람 확인 토큰이 있으면 요청 헤더 객체로 만들어 돌려줍니다.
*
* 토큰은 비밀번호 검증 응답으로만 발급되고 게시글 단위로 결속되므로, 무관한 요청에
* 실려도 서버 판정에 영향을 주지 않습니다.
*
* @return 토큰이 있으면 헤더 1개짜리 객체, 없으면 빈 객체
*/
export function secretContentHeaders(): Record<string, string> {
const token = (window as any).G7Core?.state?.get?.('_global')?.secretViewToken;
return typeof token === 'string' && token !== ''
? { [SECRET_VIEW_TOKEN_HEADER]: token }
: {};
}
@@ -242,21 +242,22 @@ class UserControllerDeleteTest extends TestCase
}
// ========================================================================
// 삭제 실패 시 에러 상세 메시지 노출 (:error placeholder 치환)
// 삭제 실패 응답 — placeholder 미노출 + 예외 원문 비노출
// ========================================================================
/**
* 회귀 — 삭제 실패 시 토스트 message 에 `:error` placeholder 가 그대로 노출되지 않고,
* 구체적 실패 사유가 치환되어 사용자에게 보여야 한다.
* 회귀 — 삭제 실패 시 응답 message 에 `:error` placeholder 가 그대로 남지 않고,
* 동시에 예외 원문(내부 사정)이 사용자 응답에 실리지 않아야 한다.
*
* 사례: 플러그인 FK 제약 등으로 UserService::deleteUser 가 ValidationException 을
* 던질 때, UserController::destroy 의 422 경로가 message 용 치환값을 전달하지 않아
* 토스트에 `사용자 삭제에 실패했습니다: :error` 가 그대로 노출됨 (#415).
* 이력: #415 는 토스트에 `사용자 삭제에 실패했습니다: :error` 가 그대로 노출되던 것을
* 고치면서 구체 사유를 message 에 실었다. 이후 보안 정정(#577)이 그 방향을 뒤집어,
* UserService::deleteUser 는 원본 예외를 로그로만 남기고 응답에는 일반 안내만 싣는다.
* 두 요구는 양립한다 — placeholder 가 남지 않으면서 원문도 새지 않으면 된다.
*
* 검증: 응답 message 에 `:error` 가 남지 않고 구체 사유가 포함되며, errors.general 에도
* 동일 상세가 담긴다 (에러 상세 표시 기능 유지 — 메시지를 숨기지 않음).
* 검증: message 에 `:error` 가 없고, 훅이 던진 예외 원문도 응답 어디에도 없다.
* 그리고 사용자가 읽을 수 있는 일반 안내 문구가 온다.
*/
public function test_delete_failure_message_substitutes_error_detail_not_raw_placeholder(): void
public function test_delete_failure_message_has_no_placeholder_and_no_raw_exception_detail(): void
{
$target = User::factory()->create(['is_super' => false]);
@@ -280,14 +281,20 @@ class UserControllerDeleteTest extends TestCase
$response->assertJsonPath('success', false);
$message = (string) $response->json('message');
$body = (string) $response->getContent();
// 핵심: placeholder 가 그대로 노출되면 안 됨
// 핵심 1: placeholder 가 그대로 노출되면 안 된다 (#415 회귀 방지)
$this->assertStringNotContainsString(':error', $message, 'message 에 미치환 :error 가 남으면 안 된다');
// 핵심: 실패 사유가 사용자에게 보여야 함 (기능 유지)
$this->assertStringContainsString($reason, $message, '구체적 실패 사유가 message 에 노출되어야 한다');
$this->assertStringNotContainsString(':error', $body, '응답 어디에도 미치환 :error 가 남으면 안 된다');
// errors.general 에도 동일 상세가 담긴다
$this->assertStringContainsString($reason, (string) $response->json('errors.general.0'));
$this->assertStringNotContainsString(':error', (string) $response->json('errors.general.0'));
// 핵심 2: 예외 원문(내부 사정)이 응답에 실리면 안 된다 (#577 보안 정정)
$this->assertStringNotContainsString(
$reason,
$body,
'예외 원문이 응답에 실리면 안 된다 — 원본은 로그로만 남긴다'
);
// 사용자가 읽을 수 있는 일반 안내가 온다
$this->assertSame(__('user.delete_failed'), $message);
}
}
@@ -38,6 +38,7 @@ class IdentityChallengeShowTest extends TestCase
/**
* @scenario source=identity-challenge-status-field-exposure axis=field:id,field:status,field:public_payload
*
* @effects public_safe_fields_present_in_response
*/
public function test_show_returns_public_status_fields(): void
@@ -61,6 +62,7 @@ class IdentityChallengeShowTest extends TestCase
/**
* @scenario source=identity-challenge-status-field-exposure axis=field:attempts,field:max_attempts,field:target_hash
*
* @effects attempts_absent_from_response, max_attempts_absent_from_response, target_hash_absent_from_response, verification_token_absent_from_response, metadata_absent_from_response
*/
public function test_show_does_not_expose_sensitive_fields(): void
@@ -73,13 +75,18 @@ class IdentityChallengeShowTest extends TestCase
$response = $this->getJson("/api/identity/challenges/{$id}");
// 시도 횟수(attempts / max_attempts)·코드 본체·target_hash·verification_token 등 노출 금지.
// 이 엔드포인트는 권한 가드 없는 공개 폴링용이므로 남은 시도 횟수를 추론할 단서를 응답에 담지 않는다.
// 누적 시도 횟수(attempts)·코드 본체·target_hash·verification_token 등 노출 금지.
// 이 엔드포인트는 권한 가드 없는 공개 폴링용이므로, 남의 인증이 몇 번 실패했는지를
// 추론할 단서를 응답에 담지 않는다.
$response->assertJsonMissingPath('data.attempts')
->assertJsonMissingPath('data.max_attempts')
->assertJsonMissingPath('data.target_hash')
->assertJsonMissingPath('data.verification_token')
->assertJsonMissingPath('data.metadata');
// 상한(max_attempts)은 정책 상수라 노출한다 — 화면이 남은 횟수를 표시하려면 필요하고,
// 상수 자체로는 그 인증 건이 몇 번 실패했는지가 드러나지 않는다.
// (Service::getStatus 의 판정과 같은 계약. 한쪽만 바뀌면 이 단언이 먼저 깨진다.)
$this->assertIsInt($response->json('data.max_attempts'));
}
public function test_show_reflects_status_transitions(): void
+66 -2
View File
@@ -156,6 +156,63 @@ class BinaryPathPolicyTest extends TestCase
$this->assertTrue(installer_is_composer_binary_path('/usr/local/php99/bin/composer'));
}
/**
* 검증 지점과 실행 지점이 같은 정책 파일을 쓰는지 확인합니다.
*
* 정책이 한 파일에 있어도 어느 한 진입점이 그 파일을 require 하지 않으면
* "저장은 막히는데 실행은 통과" 같은 어긋난 상태가 남고, 그 어긋남은 오류를
* 남기지 않는다. 그래서 파일 참조 자체를 계약으로 고정한다 (KVE-2026-2043).
*/
#[Test]
public function validation_and_execution_entrypoints_share_the_same_policy_file(): void
{
$projectRoot = dirname(__DIR__, 3);
$entrypoints = [
'api/check-configuration.php' => $projectRoot.'/public/install/api/check-configuration.php',
'includes/task-runner.php' => $projectRoot.'/public/install/includes/task-runner.php',
'includes/request-handler.php' => $projectRoot.'/public/install/includes/request-handler.php',
];
foreach ($entrypoints as $label => $path) {
$this->assertFileExists($path, "정책 소비 진입점이 사라졌다: {$label}");
$this->assertStringContainsString(
'binary-path-policy.php',
(string) file_get_contents($path),
"{$label} 이 공용 정책을 로드하지 않는다 — 자체 판정으로 갈라지면 우회로가 된다"
);
}
}
/**
* UNC·임의 phar 는 (PHP, Composer) 쌍 해석에서도 거부된다.
*
* 쌍 해석은 자리별 규칙을 재사용하므로 여기서도 같은 결론이 나와야 한다 —
* 한쪽만 막히면 그 경로가 우회로가 된다.
*/
#[Test]
public function the_pair_resolver_rejects_unc_and_arbitrary_phar(): void
{
$this->assertNull(
installer_resolve_php_composer_pair('\\\\server\\share\\php.exe /usr/local/bin/composer'),
'UNC PHP 경로가 쌍 해석에서 통과했다'
);
$this->assertNull(
installer_resolve_php_composer_pair('/usr/bin/php \\\\server\\share\\composer.phar'),
'UNC Composer 경로가 쌍 해석에서 통과했다'
);
$this->assertNull(
installer_resolve_php_composer_pair('/usr/bin/php /tmp/evil.phar'),
'임의 이름 phar 가 쌍 해석에서 통과했다'
);
// 정상 조합은 그대로 동작한다 (회귀 방지).
$this->assertSame(
['php' => '/usr/bin/php', 'composer' => '/usr/local/bin/composer.phar'],
installer_resolve_php_composer_pair('/usr/bin/php /usr/local/bin/composer.phar')
);
}
/**
* 통과해야 하는 토큰 목록.
*
@@ -171,7 +228,6 @@ class BinaryPathPolicyTest extends TestCase
'Plesk 경로' => ['/opt/plesk/php/8.2/bin/php', '패널 환경'],
'Windows 드라이브' => ['C:\\php\\php.exe', 'Windows'],
'Windows 슬래시' => ['C:/php/php.exe', 'Windows 정방향 슬래시'],
'UNC 경로' => ['\\\\server\\share\\php.exe', '네트워크 공유'],
'점 포함 디렉토리' => ['/usr/local/php.d/bin/php', '단일 점은 상위 참조가 아님'],
];
}
@@ -200,6 +256,12 @@ class BinaryPathPolicyTest extends TestCase
'따옴표' => ['/usr/bin/"php"', '셸 메타문자'],
'개행' => ["/usr/bin/php\nid", '제어문자'],
'NUL 바이트' => ["/usr/bin/php\x00", '제어문자'],
// KVE-2026-2043: UNC 경로는 공격자가 지정한 원격 SMB 공유의 파일을 실행하게 만든다.
'UNC 경로' => ['\\\\server\\share\\php.exe', '원격 공유 실행 — KVE-2026-2043'],
'UNC 정방향 슬래시' => ['//server/share/php.exe', '원격 공유 실행'],
'phar 스트림 래퍼' => ['phar:///tmp/evil.phar/run', 'stream wrapper'],
'http 스트림 래퍼' => ['http://evil.example.com/x', 'stream wrapper'],
'file 스트림 래퍼' => ['file:///usr/bin/php', 'stream wrapper'],
];
}
@@ -215,7 +277,9 @@ class BinaryPathPolicyTest extends TestCase
'절대경로 composer' => ['/usr/local/bin/composer', true, '일반 설치'],
'composer2' => ['/usr/local/bin/composer2', true, '버전 붙은 이름'],
'composer.phar' => ['/opt/composer.phar', true, 'phar 아카이브'],
'임의 이름 phar' => ['/opt/tools/mytool.phar', true, 'phar 는 이름 무관 허용'],
// KVE-2026-2043: 임의 이름 .phar 허용은 업로드된 아카이브를 그대로 실행시킨다.
'임의 이름 phar' => ['/opt/tools/mytool.phar', false, '이름이 composer 계열이 아닌 phar'],
'composerN.phar' => ['/opt/composer2.phar', true, '버전 붙은 composer phar'],
'composer.bat' => ['C:\\laragon\\bin\\composer\\composer.bat', true, 'Windows 배치'],
'composer.exe' => ['C:\\tools\\composer.exe', true, 'Windows 실행 파일'],
'임의 스크립트' => ['/tmp/evil.py', false, '인터프리터가 실행할 임의 스크립트 — 이번 취약점'],
@@ -0,0 +1,153 @@
<?php
namespace Tests\Unit\Installer;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* 인스톨러 .env 값 직렬화 테스트 (KVE-2026-2042).
*
* 회귀 배경: `.env` 는 줄 단위 형식이라 사용자 입력에 개행이 섞이면 그 뒤가 새로운
* 환경변수 줄로 해석된다. 기존 escapeEnvValue() 는 개행을 조용히 지웠고 그마저도
* 일부 값(호스트·DB명·앱 이름·업데이트 URL 등)에는 적용되지 않아, 설치 창이 열려 있는
* 동안 임의 환경변수를 주입할 수 있었다.
*
* 정책: 직렬화기는 CR/LF/NUL 을 만나면 예외를 던진다(조용한 삭제 금지 — 삭제하면
* 운영자가 자기가 입력한 값과 다른 값이 저장된 것을 알 수 없다). 값 전달 경로가
* 하나이므로 새 필드가 추가돼도 같은 관문을 지난다.
*
* 정책 파일은 의존성이 없어야 한다 — 인스톨러 본 흐름과 워커 양쪽에서 단독으로
* require 되므로, 이 테스트도 BASE_PATH·lang 스텁 없이 파일만 로드한다.
*/
class EnvValueSerializationTest extends TestCase
{
public static function setUpBeforeClass(): void
{
require_once dirname(__DIR__, 3).'/public/install/includes/env-value.php';
}
/**
* 정상 값은 큰따옴표로 감싸 반환된다.
*/
#[Test]
public function it_quotes_a_normal_value(): void
{
$this->assertSame('"g7"', serializeEnvValue('g7'));
$this->assertSame('"127.0.0.1"', serializeEnvValue('127.0.0.1'));
}
/**
* 빈 값은 빈 따옴표로 반환된다 (기존 동작 보존).
*/
#[Test]
public function it_returns_empty_quotes_for_an_empty_value(): void
{
$this->assertSame('""', serializeEnvValue(''));
}
/**
* 큰따옴표와 백슬래시는 이스케이프된다 (기존 동작 보존).
*/
#[Test]
public function it_escapes_quotes_and_backslashes(): void
{
$this->assertSame('"a\\\\b"', serializeEnvValue('a\\b'));
$this->assertSame('"say \\"hi\\""', serializeEnvValue('say "hi"'));
}
/**
* 줄바꿈·NUL 이 섞인 값은 예외로 거부된다 (조용한 삭제 금지).
*
* @param string $value 주입 시도 값
* @param string $reason 거부 사유 (실패 메시지용)
*/
#[Test]
#[DataProvider('lineBreakInjectionProvider')]
public function it_rejects_values_containing_line_breaks_or_nul(string $value, string $reason): void
{
$this->expectException(InvalidArgumentException::class);
serializeEnvValue($value);
}
/**
* 개행 주입 벡터.
*
* @return array<string, array{0: string, 1: string}>
*/
public static function lineBreakInjectionProvider(): array
{
return [
'LF 로 새 변수 줄 주입' => ["g7\nAPP_DEBUG=true", 'LF'],
'CR 로 새 변수 줄 주입' => ["g7\rAPP_DEBUG=true", 'CR'],
'CRLF 로 새 변수 줄 주입' => ["g7\r\nAPP_DEBUG=true", 'CRLF'],
'NUL 바이트' => ["g7\0APP_DEBUG=true", 'NUL'],
'선두 개행' => ["\nAPP_DEBUG=true", 'leading LF'],
];
}
/**
* 개행 검사는 값 전체를 본다 — 따옴표 안에 숨겨도 거부된다.
*/
#[Test]
public function it_rejects_line_breaks_even_inside_quotes(): void
{
$this->expectException(InvalidArgumentException::class);
serializeEnvValue("\"g7\"\nAPP_DEBUG=true");
}
/**
* 하위호환 별칭 `escapeEnvValue` 의 두 정의가 모두 단일 관문에 위임한다.
*
* 종전 구현은 개행을 **조용히 지웠다**. 그 동작이 어느 한쪽에라도 남아 있으면 다음
* 호출자가 그 이름을 불러 같은 결함을 다시 들여온다 — 실제로 과거 KVE 수정들이
* "한쪽만 고쳐서" 재수정된 이력이 있다.
*
* 함수를 실행해 대조하지 않고 소스를 검사하는 이유: 두 정의는 BASE_PATH 상수를
* 요구하는 파일에 있고, 그 상수를 여기서 박으면 인접 Installer 테스트의 안전 가드가
* 걸려 그 테스트들이 조용히 skip 된다(초록인데 검사는 멈춘 상태가 된다).
*/
#[Test]
public function both_legacy_alias_definitions_delegate_to_the_single_gate(): void
{
$projectRoot = dirname(__DIR__, 3);
$definitions = [
'includes/functions.php' => $projectRoot.'/public/install/includes/functions.php',
'includes/installer-runtime.php' => $projectRoot.'/public/install/includes/installer-runtime.php',
];
foreach ($definitions as $label => $path) {
$source = (string) file_get_contents($path);
$this->assertMatchesRegularExpression(
'/function escapeEnvValue\(string \$value\): string\s*\{[^}]*return serializeEnvValue\(\$value\);/s',
$source,
"{$label} 의 escapeEnvValue 가 단일 관문에 위임하지 않는다"
);
$this->assertStringNotContainsString(
'str_replace(["\r", "\n"], \'\', $value)',
$source,
"{$label} 에 개행을 조용히 지우는 경로가 남아 있다 — 그 이름을 부르면 결함이 재유입된다"
);
}
}
/**
* 개행이 없는 값은 검사기가 통과시킨다.
*/
#[Test]
public function the_line_break_checker_accepts_clean_values(): void
{
$this->assertTrue(installer_env_value_is_single_line('https://github.com/gnuboard/g7'));
$this->assertTrue(installer_env_value_is_single_line(''));
$this->assertFalse(installer_env_value_is_single_line("a\nb"));
$this->assertFalse(installer_env_value_is_single_line("a\rb"));
$this->assertFalse(installer_env_value_is_single_line("a\0b"));
}
}
@@ -312,12 +312,13 @@ ENV;
$merged = mergeRuntimeIntoEnv($envContent, $runtime);
$this->assertStringContainsString('DB_WRITE_HOST=real-host.example.com', $merged);
$this->assertStringContainsString('DB_WRITE_PORT=3307', $merged);
$this->assertStringContainsString('DB_WRITE_DATABASE=real_db', $merged);
$this->assertStringContainsString('DB_WRITE_USERNAME=real_user', $merged);
// 값은 전부 serializeEnvValue 를 지나 따옴표로 감싸진다 (개행 주입 차단 관문 일원화).
$this->assertStringContainsString('DB_WRITE_HOST="real-host.example.com"', $merged);
$this->assertStringContainsString('DB_WRITE_PORT="3307"', $merged);
$this->assertStringContainsString('DB_WRITE_DATABASE="real_db"', $merged);
$this->assertStringContainsString('DB_WRITE_USERNAME="real_user"', $merged);
$this->assertStringContainsString('DB_WRITE_PASSWORD="real_pass"', $merged);
$this->assertStringContainsString('DB_PREFIX=real_', $merged);
$this->assertStringContainsString('DB_PREFIX="real_"', $merged);
// Read 라인도 write 와 동기화되어 치환되어야 함
$this->assertStringNotContainsString('DB_WRITE_HOST=127.0.0.1', $merged);
@@ -350,10 +351,10 @@ ENV;
$this->assertStringContainsString("DB_READ_HOST=\n", $merged);
$this->assertStringContainsString("DB_READ_DATABASE=\n", $merged);
$this->assertStringContainsString("DB_READ_USERNAME=\n", $merged);
$this->assertStringNotContainsString('DB_READ_HOST=w-host', $merged);
$this->assertStringNotContainsString('DB_READ_DATABASE=wdb', $merged);
$this->assertStringNotContainsString('DB_READ_HOST="w-host"', $merged);
$this->assertStringNotContainsString('DB_READ_DATABASE="wdb"', $merged);
// write 라인은 정상 기록
$this->assertStringContainsString('DB_WRITE_HOST=w-host', $merged);
$this->assertStringContainsString('DB_WRITE_HOST="w-host"', $merged);
}
public function test_merge_uses_separate_read_when_specified(): void
@@ -369,8 +370,8 @@ ENV;
$merged = mergeRuntimeIntoEnv($envContent, $runtime);
$this->assertStringContainsString('DB_READ_HOST=r-host', $merged);
$this->assertStringContainsString('DB_READ_DATABASE=rdb', $merged);
$this->assertStringContainsString('DB_READ_HOST="r-host"', $merged);
$this->assertStringContainsString('DB_READ_DATABASE="rdb"', $merged);
}
public function test_merge_appends_db_lines_when_absent_from_template(): void
@@ -392,8 +393,8 @@ ENV;
$merged = mergeRuntimeIntoEnv($envContent, $runtime);
// .env.example 에 라인이 없는 비표준 케이스 — 끝에 추가
$this->assertStringContainsString('DB_WRITE_HOST=h', $merged);
$this->assertStringContainsString('DB_WRITE_DATABASE=d', $merged);
$this->assertStringContainsString('DB_WRITE_HOST="h"', $merged);
$this->assertStringContainsString('DB_WRITE_DATABASE="d"', $merged);
}
public function test_merge_preserves_db_lines_when_runtime_has_no_db(): void
@@ -2,6 +2,7 @@
namespace Tests\Unit\Installer;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use ValidationApi;
@@ -484,29 +485,25 @@ class InstallerSecurityHardeningTest extends TestCase
}
// ========================================================================
// Medium-1 — escapeEnvValue 개행 제거
// Medium-1 — escapeEnvValue 개행 거부
// ========================================================================
//
// 종전에는 개행을 조용히 지웠다(Medium-1). KVE-2026-2042 정정으로 직렬화기는 개행을
// 만나면 예외를 던진다 — 지우면 운영자가 입력한 값과 저장된 값이 달라지는데 그 사실이
// 화면에 나타나지 않기 때문이다. 하위호환 별칭 escapeEnvValue 도 같은 관문에 위임한다.
public function test_escape_env_value_strips_newlines(): void
public function test_escape_env_value_rejects_newlines(): void
{
$payload = "secret\nINJECTED=true";
$result = escapeEnvValue($payload);
$this->expectException(InvalidArgumentException::class);
// 핵심 보안 속성: 결과에 개행 문자가 없어야 한다 (라인 주입 차단).
// INJECTED=true 가 따옴표 내부 일부로 포함되는 것은 문제 아님 — .env 파서는
// 따옴표 닫힘 전까지 단일 값으로만 해석.
$this->assertStringNotContainsString("\n", $result, 'LF 가 결과에 포함되면 안 됨');
$this->assertStringNotContainsString("\r", $result);
// 결과는 따옴표로 시작/종료 (라인 주입이 성립하려면 따옴표가 닫힌 뒤 개행이 와야 하나 개행이 제거됨)
$this->assertStringStartsWith('"', $result);
$this->assertStringEndsWith('"', $result);
escapeEnvValue("secret\nINJECTED=true");
}
public function test_escape_env_value_strips_crlf(): void
public function test_escape_env_value_rejects_crlf(): void
{
$result = escapeEnvValue("a\r\nb");
$this->expectException(InvalidArgumentException::class);
$this->assertSame('"ab"', $result);
escapeEnvValue("a\r\nb");
}
public function test_escape_env_value_preserves_normal_password(): void

Some files were not shown because too many files have changed in this diff Show More