fix(sirsoft-gdpr): HMAC 서명 만료시각 추가 + sequence handler 위치 정리

PR 전 마지막 재검토에서 남겨뒀던 두 항목을 처리한다.

- 게스트 세션 쿠키 HMAC 서명에 만료시각을 추가했다. 결제 플러그인
 (IssuesReceiptCookie)과 동일하게 값 형식을 {uuid}|{만료시각}|{서명}
 3단 구조로 바꾸고, 서명 자체가 만료시각도 함께 해시하므로 만료시각만
 위조해 유효기간을 늘리는 시도도 차단된다. 만료 기간은 쿠키 자체의
 브라우저 만료(1년)와 동일하게 맞췄다 — 결제 정보가 아닌 쿠키 선호도
 식별자라 짧게 잘라 잦은 재발급을 강제할 실익이 없다.
- 관리자 설정 화면의 정책 버전 이력 토글 버튼에서 sequence handler의
 actions 배열이 params 안에 잘못 중첩되어 있던 걸 발견해 바로잡았다.
 엔진(ActionDispatcher.handleSequence)이 두 위치를 모두 인식하는
 fallback을 갖고 있어 실제 동작은 정상이었지만, 정석 위치(top-level)로
 정리하고 실제 클릭 시 이력 표가 펼쳐지는지 검증하는 E2E를 추가했다.
This commit is contained in:
chym1217 committed 2026-07-30 12:19:53 +09:00
1 parent 4d50fa2cde
commit ac57df93bd
7 files changed
+121 -33

No files matched your search

+1 -1
View File
@@ -31,7 +31,7 @@
- 관리자 환경설정의 저장/취소 버튼을 다른 관리자 화면과 동일하게 화면 하단 고정 위치로 옮기고, 상단 탭 영역의 불필요한 구분선과 좌우 여백 어긋남을 바로잡았습니다.
- 관리자 환경설정 「정책 버전 보기」에서 본문 페이지 링크를 클릭하면 페이지를 찾을 수 없던 문제를 바로잡았습니다.
- 관리자 환경설정의 「운영 주체」·「쿠키 배너 노출」·「배너 위치」 필드에서 안내 문구가 길어질 때 입력·토글 컨트롤이 옆이 아닌 아래 줄로 밀려나던 문제를 바로잡았습니다.
- 비회원(게스트) 방문자의 쿠키 동의 식별자가 위·변조에 노출되던 문제를 바로잡았습니다 — 다른 방문자의 쿠키 동의 상태를 임의로 조회·변경할 수 없도록 서명을 적용했습니다. 이 조치로 이번 업데이트 이전에 발급된 비회원 쿠키 배너 상태는 1회에 한해 초기화되며, 이미 배너를 닫았던 비회원 방문자는 다음 방문 시 배너를 다시 보게 됩니다(회원 동의 이력은 영향 없음).
- 비회원(게스트) 방문자의 쿠키 동의 식별자가 위·변조에 노출되던 문제를 바로잡았습니다 — 다른 방문자의 쿠키 동의 상태를 임의로 조회·변경할 수 없도록 서명을 적용하고, 유효기간(쿠키 자체와 동일하게 1년)도 함께 적용했습니다. 이 조치로 이번 업데이트 이전에 발급된 비회원 쿠키 배너 상태는 1회에 한해 초기화되며, 이미 배너를 닫았던 비회원 방문자는 다음 방문 시 배너를 다시 보게 됩니다(회원 동의 이력은 영향 없음).
- 비회원으로 쿠키 배너를 닫은 뒤 동의 이력이 없는 다른 계정으로 로그인하면 배너가 계속 숨겨져 있던 문제를 바로잡았습니다 — 이제 로그인한 계정 기준으로 다시 판단해 배너가 정상적으로 노출됩니다.
- 관리자 환경설정의 「운영 주체」·「쿠키 동의 배너」·「자동 차단 정책」 카드 제목과 차단 도메인 안내 문구가 빨간색(경고 색상)으로 표시되던 문제를 바로잡았습니다 — 일반 안내에도 경고 색상이 쓰여 어색하게 강조되던 부분을 다른 관리자 화면과 동일한 색상으로 정리했습니다.
@@ -562,6 +562,25 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au
expect(partials.some((p) => p.includes('_policy_version_history_modal'))).toBe(false);
});
/**
* sequence handler 구조 회귀 가드 — actions 배열이 params 안에 잘못 중첩되어
* (CLAUDE.md 절대 금지 패턴) 있었던 결함. 엔진(ActionDispatcher.handleSequence)이
* 두 위치를 모두 인식하는 fallback을 갖고 있어 동작 자체는 정상이었지만, 정석
* 위치(actions가 액션 객체의 top-level)로 바로잡는다.
*/
it('이력 토글 버튼의 sequence handler 는 actions 를 top-level 에 둔다 (params 중첩 금지)', () => {
const wrapper = findById(root, 'field_cookie_policy_version');
const json = JSON.stringify(wrapper);
// 정상 패턴: sequence handler 바로 뒤에 "actions" 가 이어짐 (params 를 거치지 않음)
expect(json).toMatch(/"handler":"sequence","actions":\[/);
// 금지 패턴: sequence handler 뒤에 params.actions 로 중첩된 형태가 없어야 한다
expect(json).not.toMatch(/"handler":"sequence","params":\{"actions":\[/);
});
it('이력 표 컨테이너에 policy_version_history_table id 가 부여되어 E2E 로 펼침 여부를 검증할 수 있다', () => {
expect(findById(root, 'policy_version_history_table')).toBeTruthy();
});
it('정책 버전 발행 modal (publish modal) partial 이 modals 배열에 등록되어 있다 — 운영자 수동 발행 진입점', () => {
const modals = (root as { modals?: Array<{ partial?: string }> }).modals ?? [];
const partials = modals.map((m) => m.partial ?? '');
@@ -1,5 +1,7 @@
{
"meta": {
"is_partial": true,
"_e2e_allow": "e2e:allow meta.is_partial 플래그만 추가 — 렌더링/동작 변화 없는 레이아웃 개수 집계 정정",
"editor_label": "$t:sirsoft-gdpr.settings.policy_version.snapshot_modal.editor_label"
},
"id": "policy_version_snapshot_modal",
@@ -924,23 +924,21 @@
{
"type": "click",
"handler": "sequence",
"params": {
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"policyHistoryOpen": "{{!_local.policyHistoryOpen}}"
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "gdprPolicyVersionHistory"
}
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"policyHistoryOpen": "{{!_local.policyHistoryOpen}}"
}
]
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "gdprPolicyVersionHistory"
}
}
]
}
],
"children": [
@@ -964,6 +962,7 @@
]
},
{
"id": "policy_version_history_table",
"comment": "이력 표 — collapsible. _local.policyHistoryOpen 일 때만 노출. gdprPolicyVersionHistory 데이터소스 페이지네이션 표시.",
"type": "basic",
"name": "Div",
@@ -12,29 +12,39 @@ namespace Plugins\Sirsoft\Gdpr\Concerns;
* 게스트 동의 이력을 조회/철회할 수 있으므로, HMAC 서명을 붙여 위조를 차단한다.
*
* sirsoft-pay_nicepayments 등 결제 플러그인의 IssuesReceiptCookie 트레이트와
* 동일한 서명 컨벤션(hash_hmac + config('app.key'))을 사용한다.
* 동일한 서명 컨벤션(hash_hmac + config('app.key'))을 사용하며, 만료시각도 서명에
* 포함해(주문 영수증 쿠키와 동일 구조) 값이 유출되어도 영구히 재사용되지 않게 한다.
* 쿠키 자체의 브라우저 만료(1년)와 동일하게 맞춘다 — 이 쿠키는 결제 정보가 아니라
* 쿠키 선호도 식별자라 짧게 잘라 잦은 재발급을 강제할 실익이 없다.
*/
trait IssuesGuestSessionCookie
{
/**
* 서명 만료 기간(초). 쿠키 자체의 브라우저 만료(1년, 60*24*365분)와 동일하게 맞춘다.
*/
private const SIGNATURE_TTL_SECONDS = 60 * 60 * 24 * 365;
/**
* 쿠키 값에 서명을 붙입니다.
*
* @param string $sessionId UUID v4 게스트 세션 식별자
* @return string "{sessionId}|{signature}" 형태의 쿠키 값
* @return string "{sessionId}|{expiresTs}|{signature}" 형태의 쿠키 값
*/
protected function signGuestSessionId(string $sessionId): string
{
return $sessionId.'|'.$this->computeGuestSessionSignature($sessionId);
$expiresTs = time() + self::SIGNATURE_TTL_SECONDS;
return $sessionId.'|'.$expiresTs.'|'.$this->computeGuestSessionSignature($sessionId, $expiresTs);
}
/**
* 쿠키 값에서 서명을 검증하고 원본 session_id 를 반환합니다.
*
* 서명이 없거나 위조된 값은 신뢰하지 않고 null 을 반환합니다
* 서명이 없거나 위조된 값, 만료된 값은 신뢰하지 않고 null 을 반환합니다
* (호출자는 미식별 게스트로 취급해야 함).
*
* @param string|null $cookieValue 요청 쿠키 원본 값
* @return string|null 검증된 session_id, 위조/형식 오류 시 null
* @return string|null 검증된 session_id, 위조/형식 오류/만료 시 null
*/
protected function verifyGuestSessionId(?string $cookieValue): ?string
{
@@ -42,18 +52,22 @@ trait IssuesGuestSessionCookie
return null;
}
$parts = explode('|', $cookieValue, 2);
if (count($parts) !== 2) {
$parts = explode('|', $cookieValue, 3);
if (count($parts) !== 3) {
return null;
}
[$sessionId, $signature] = $parts;
[$sessionId, $expiresTs, $signature] = $parts;
if ($sessionId === '' || ! ctype_xdigit($signature) || strlen($signature) !== 64) {
if ($sessionId === '' || ! ctype_digit($expiresTs) || ! ctype_xdigit($signature) || strlen($signature) !== 64) {
return null;
}
if (! hash_equals($this->computeGuestSessionSignature($sessionId), $signature)) {
if ((int) $expiresTs < time()) {
return null;
}
if (! hash_equals($this->computeGuestSessionSignature($sessionId, (int) $expiresTs), $signature)) {
return null;
}
@@ -61,13 +75,14 @@ trait IssuesGuestSessionCookie
}
/**
* session_id 에 대한 HMAC-SHA256 서명을 계산합니다.
* session_id + 만료시각에 대한 HMAC-SHA256 서명을 계산합니다.
*
* @param string $sessionId 게스트 세션 식별자
* @param int $expiresTs 서명 만료 시각(unix timestamp)
* @return string 64자 hex 서명
*/
private function computeGuestSessionSignature(string $sessionId): string
private function computeGuestSessionSignature(string $sessionId, int $expiresTs): string
{
return hash_hmac('sha256', $sessionId, (string) config('app.key', ''));
return hash_hmac('sha256', $sessionId.'|'.$expiresTs, (string) config('app.key', ''));
}
}
@@ -1,9 +1,10 @@
/**
* E2E: 관리자 환경설정 > 쿠키 배너 > 정책 버전 > 보기 — 본문 페이지 링크 (#509 체크리스트 12번)
* + 관리자 환경설정 카드 제목 색상 회귀 가드 (PO 스크린샷 발견)
* + 정책 버전 이력 토글 sequence handler 회귀 가드
*
* @scenario admin_gdpr_policy_version_snapshot_open_policy_page_link
* @effects snapshot_modal_opens, policy_page_link_href_matches_page_module_url_pattern, card_title_not_error_color
* @effects snapshot_modal_opens, policy_page_link_href_matches_page_module_url_pattern, card_title_not_error_color, policy_history_toggle_expands_and_refetches
*
* 배경: 정책 버전 snapshot 모달의 본문 페이지 링크가 `/{{slug}}` 로 조합되어 페이지 모듈
* (sirsoft-page) 의 실제 URL 패턴 `/page/{slug}` 와 불일치, 클릭 시 404 가 발생하던 결함.
@@ -15,11 +16,17 @@
* "운영 주체" 등 일반 카드 제목이 빨간색으로 표시되던 결함을 PO 가 스크린샷으로 발견해
* section-heading-md(중립 톤)로 수정 — 실제 렌더링된 색상을 브라우저에서 검증한다.
*
* 정책 버전 이력 토글 버튼의 sequence handler 가 actions 를 params 안에 잘못 중첩하고
* 있던 결함(CLAUDE.md 절대 금지 패턴)도 함께 발견 — 엔진이 두 위치 모두 인식하는
* fallback을 갖고 있어 동작 자체는 정상이었으나 정석 위치(top-level)로 바로잡았다.
* 실제 클릭 시 이력 표가 펼쳐지는지(=sequence 의 setState 가 실행됐는지)를 검증한다.
*
* 검증:
* 1. 환경설정 화면에서 v배지 옆 "본문 보기" 버튼 클릭 시 snapshot 모달이 열린다
* 2. 모달의 본문 페이지 링크 href 가 `/page/{slug}` 형식으로 조합되어 페이지 모듈 URL 패턴과 일치한다
* (프리픽스 누락으로 `/{slug}` 형태가 되는 결함 패턴이 재발하지 않았는지 회귀 가드)
* 3. 「운영 주체 / 개인정보처리방침」 카드 제목이 에러 색상(red 계열)이 아닌 중립 톤으로 렌더링된다
* 4. 이력 토글 버튼 클릭 시 이력 표가 펼쳐진다 (sequence 내 setState 정상 실행 확인)
*/
import { test, expect, authenticatePage } from '../../fixtures/gdpr-auth';
@@ -27,6 +34,7 @@ const CARD_COOKIE_BANNER = '#card_cookie_banner';
const SNAPSHOT_VIEW_BUTTON = '#policy_version_current_snapshot_view_button';
const OPEN_POLICY_PAGE_LINK = '#policy_version_snapshot_open_policy_page_link';
const CARD_OPERATOR_HEADER = '#card_operator_header h3';
const HISTORY_TOGGLE_BUTTON = '#field_cookie_policy_version button:has-text("이력")';
/** 관리자 GDPR 환경설정 진입 후 쿠키 배너 탭(정책 버전 UI 포함)으로 스크롤 이동 */
async function gotoGdprSettings(page: import('@playwright/test').Page): Promise<void> {
@@ -76,3 +84,19 @@ test('#509 - 관리자 환경설정 카드 제목이 에러 색상(빨간색)으
const [, r, g, b] = rgbMatch as unknown as [string, string, string, string];
expect(Number(r) - Math.max(Number(g), Number(b))).toBeLessThan(40);
});
// @scenario tab=card_cookie_banner, permitted=yes
// @effects policy_history_toggle_expands_and_refetches
test('#509 - 정책 버전 이력 토글 버튼 클릭 시 이력 표가 펼쳐진다 (sequence handler 정상 동작 확인)', async ({ page, privacyManageToken }) => {
await authenticatePage(page, privacyManageToken);
await gotoGdprSettings(page);
const historyTable = page.locator('#policy_version_history_table');
await expect(historyTable).toBeHidden();
await page.locator(HISTORY_TOGGLE_BUTTON).click();
// sequence 의 setState(policyHistoryOpen 토글) 가 실행되어야 이 if 조건이 true 로
// 바뀌어 이력 표가 렌더된다 — params 안에 잘못 중첩됐던 결함이 재발하면 여기서 실패한다.
await expect(historyTable).toBeVisible({ timeout: 10_000 });
});
@@ -49,12 +49,41 @@ class IssuesGuestSessionCookieTest extends PluginTestCase
$subject = $this->subject();
$signed = $subject->sign('11111111-2222-3333-4444-555555555555');
[, $signature] = explode('|', $signed, 2);
$tampered = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee|'.$signature;
[, $expiresTs, $signature] = explode('|', $signed, 3);
$tampered = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee|'.$expiresTs.'|'.$signature;
$this->assertNull($subject->verify($tampered));
}
public function test_tampered_expiry_with_stale_signature_is_rejected(): void
{
// 만료시각만 늘려서 서명 유효기간을 연장하려는 위조 시도 — 서명이 session_id
// 뿐 아니라 expiresTs 도 함께 서명하므로 expiresTs 변조 시 서명 불일치로 거부.
$subject = $this->subject();
$signed = $subject->sign('11111111-2222-3333-4444-555555555555');
[$sessionId, $expiresTs, $signature] = explode('|', $signed, 3);
$tampered = $sessionId.'|'.((int) $expiresTs + 3600).'|'.$signature;
$this->assertNull($subject->verify($tampered));
}
public function test_expired_signature_is_rejected(): void
{
// 만료시각이 과거인 서명(정상 서명이지만 유효기간 경과)은 거부되어야 한다.
$subject = $this->subject();
$sessionId = '11111111-2222-3333-4444-555555555555';
$pastExpiresTs = time() - 1;
$signMethod = new \ReflectionMethod($subject, 'computeGuestSessionSignature');
$signMethod->setAccessible(true);
$signature = $signMethod->invoke($subject, $sessionId, $pastExpiresTs);
$expiredValue = $sessionId.'|'.$pastExpiresTs.'|'.$signature;
$this->assertNull($subject->verify($expiredValue));
}
public function test_arbitrary_unsigned_value_is_rejected(): void
{
$subject = $this->subject();