From ac57df93bd44da781da9253f4180b7b927d4385b Mon Sep 17 00:00:00 2001 From: chym1217 Date: Thu, 30 Jul 2026 10:41:45 +0900 Subject: [PATCH] =?UTF-8?q?fix(sirsoft-gdpr):=20HMAC=20=EC=84=9C=EB=AA=85?= =?UTF-8?q?=20=EB=A7=8C=EB=A3=8C=EC=8B=9C=EA=B0=81=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?+=20sequence=20handler=20=EC=9C=84=EC=B9=98=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 전 마지막 재검토에서 남겨뒀던 두 항목을 처리한다. - 게스트 세션 쿠키 HMAC 서명에 만료시각을 추가했다. 결제 플러그인 (IssuesReceiptCookie)과 동일하게 값 형식을 {uuid}|{만료시각}|{서명} 3단 구조로 바꾸고, 서명 자체가 만료시각도 함께 해시하므로 만료시각만 위조해 유효기간을 늘리는 시도도 차단된다. 만료 기간은 쿠키 자체의 브라우저 만료(1년)와 동일하게 맞췄다 — 결제 정보가 아닌 쿠키 선호도 식별자라 짧게 잘라 잦은 재발급을 강제할 실익이 없다. - 관리자 설정 화면의 정책 버전 이력 토글 버튼에서 sequence handler의 actions 배열이 params 안에 잘못 중첩되어 있던 걸 발견해 바로잡았다. 엔진(ActionDispatcher.handleSequence)이 두 위치를 모두 인식하는 fallback을 갖고 있어 실제 동작은 정상이었지만, 정석 위치(top-level)로 정리하고 실제 클릭 시 이력 표가 펼쳐지는지 검증하는 E2E를 추가했다. --- plugins/_bundled/sirsoft-gdpr/CHANGELOG.md | 2 +- .../layouts/admin_plugin_settings.test.tsx | 19 +++++++++ .../_policy_version_snapshot_modal.json | 2 + .../layouts/admin/plugin_settings.json | 31 +++++++------- .../src/Concerns/IssuesGuestSessionCookie.php | 41 +++++++++++++------ .../policy-version-snapshot-link.spec.ts | 26 +++++++++++- .../Concerns/IssuesGuestSessionCookieTest.php | 33 ++++++++++++++- 7 files changed, 121 insertions(+), 33 deletions(-) diff --git a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md index 11705fd0..29867e42 100644 --- a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md @@ -31,7 +31,7 @@ - 관리자 환경설정의 저장/취소 버튼을 다른 관리자 화면과 동일하게 화면 하단 고정 위치로 옮기고, 상단 탭 영역의 불필요한 구분선과 좌우 여백 어긋남을 바로잡았습니다. - 관리자 환경설정 「정책 버전 보기」에서 본문 페이지 링크를 클릭하면 페이지를 찾을 수 없던 문제를 바로잡았습니다. - 관리자 환경설정의 「운영 주체」·「쿠키 배너 노출」·「배너 위치」 필드에서 안내 문구가 길어질 때 입력·토글 컨트롤이 옆이 아닌 아래 줄로 밀려나던 문제를 바로잡았습니다. -- 비회원(게스트) 방문자의 쿠키 동의 식별자가 위·변조에 노출되던 문제를 바로잡았습니다 — 다른 방문자의 쿠키 동의 상태를 임의로 조회·변경할 수 없도록 서명을 적용했습니다. 이 조치로 이번 업데이트 이전에 발급된 비회원 쿠키 배너 상태는 1회에 한해 초기화되며, 이미 배너를 닫았던 비회원 방문자는 다음 방문 시 배너를 다시 보게 됩니다(회원 동의 이력은 영향 없음). +- 비회원(게스트) 방문자의 쿠키 동의 식별자가 위·변조에 노출되던 문제를 바로잡았습니다 — 다른 방문자의 쿠키 동의 상태를 임의로 조회·변경할 수 없도록 서명을 적용하고, 유효기간(쿠키 자체와 동일하게 1년)도 함께 적용했습니다. 이 조치로 이번 업데이트 이전에 발급된 비회원 쿠키 배너 상태는 1회에 한해 초기화되며, 이미 배너를 닫았던 비회원 방문자는 다음 방문 시 배너를 다시 보게 됩니다(회원 동의 이력은 영향 없음). - 비회원으로 쿠키 배너를 닫은 뒤 동의 이력이 없는 다른 계정으로 로그인하면 배너가 계속 숨겨져 있던 문제를 바로잡았습니다 — 이제 로그인한 계정 기준으로 다시 판단해 배너가 정상적으로 노출됩니다. - 관리자 환경설정의 「운영 주체」·「쿠키 동의 배너」·「자동 차단 정책」 카드 제목과 차단 도메인 안내 문구가 빨간색(경고 색상)으로 표시되던 문제를 바로잡았습니다 — 일반 안내에도 경고 색상이 쓰여 어색하게 강조되던 부분을 다른 관리자 화면과 동일한 색상으로 정리했습니다. diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx index e5fb9dc0..178eb42f 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx @@ -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 ?? ''); diff --git a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/partials/_shared/_policy_version_snapshot_modal.json b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/partials/_shared/_policy_version_snapshot_modal.json index be293185..609692bc 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/partials/_shared/_policy_version_snapshot_modal.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/partials/_shared/_policy_version_snapshot_modal.json @@ -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", diff --git a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json index 9edbe227..ece7337b 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json @@ -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", diff --git a/plugins/_bundled/sirsoft-gdpr/src/Concerns/IssuesGuestSessionCookie.php b/plugins/_bundled/sirsoft-gdpr/src/Concerns/IssuesGuestSessionCookie.php index 9850d90f..c17ac5ca 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Concerns/IssuesGuestSessionCookie.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Concerns/IssuesGuestSessionCookie.php @@ -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', '')); } } diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/policy-version-snapshot-link.spec.ts b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/policy-version-snapshot-link.spec.ts index 8f707a5f..1dcca1f4 100644 --- a/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/policy-version-snapshot-link.spec.ts +++ b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/policy-version-snapshot-link.spec.ts @@ -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 { @@ -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 }); +}); diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Unit/Concerns/IssuesGuestSessionCookieTest.php b/plugins/_bundled/sirsoft-gdpr/tests/Unit/Concerns/IssuesGuestSessionCookieTest.php index 827492fd..0c81797a 100644 --- a/plugins/_bundled/sirsoft-gdpr/tests/Unit/Concerns/IssuesGuestSessionCookieTest.php +++ b/plugins/_bundled/sirsoft-gdpr/tests/Unit/Concerns/IssuesGuestSessionCookieTest.php @@ -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();