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:
1 parent
4d50fa2cde
commit
ac57df93bd
7 files changed
+121
-33
No files matched your search
@@ -31,7 +31,7 @@
|
||||
- 관리자 환경설정의 저장/취소 버튼을 다른 관리자 화면과 동일하게 화면 하단 고정 위치로 옮기고, 상단 탭 영역의 불필요한 구분선과 좌우 여백 어긋남을 바로잡았습니다.
|
||||
- 관리자 환경설정 「정책 버전 보기」에서 본문 페이지 링크를 클릭하면 페이지를 찾을 수 없던 문제를 바로잡았습니다.
|
||||
- 관리자 환경설정의 「운영 주체」·「쿠키 배너 노출」·「배너 위치」 필드에서 안내 문구가 길어질 때 입력·토글 컨트롤이 옆이 아닌 아래 줄로 밀려나던 문제를 바로잡았습니다.
|
||||
- 비회원(게스트) 방문자의 쿠키 동의 식별자가 위·변조에 노출되던 문제를 바로잡았습니다 — 다른 방문자의 쿠키 동의 상태를 임의로 조회·변경할 수 없도록 서명을 적용했습니다. 이 조치로 이번 업데이트 이전에 발급된 비회원 쿠키 배너 상태는 1회에 한해 초기화되며, 이미 배너를 닫았던 비회원 방문자는 다음 방문 시 배너를 다시 보게 됩니다(회원 동의 이력은 영향 없음).
|
||||
- 비회원(게스트) 방문자의 쿠키 동의 식별자가 위·변조에 노출되던 문제를 바로잡았습니다 — 다른 방문자의 쿠키 동의 상태를 임의로 조회·변경할 수 없도록 서명을 적용하고, 유효기간(쿠키 자체와 동일하게 1년)도 함께 적용했습니다. 이 조치로 이번 업데이트 이전에 발급된 비회원 쿠키 배너 상태는 1회에 한해 초기화되며, 이미 배너를 닫았던 비회원 방문자는 다음 방문 시 배너를 다시 보게 됩니다(회원 동의 이력은 영향 없음).
|
||||
- 비회원으로 쿠키 배너를 닫은 뒤 동의 이력이 없는 다른 계정으로 로그인하면 배너가 계속 숨겨져 있던 문제를 바로잡았습니다 — 이제 로그인한 계정 기준으로 다시 판단해 배너가 정상적으로 노출됩니다.
|
||||
- 관리자 환경설정의 「운영 주체」·「쿠키 동의 배너」·「자동 차단 정책」 카드 제목과 차단 도메인 안내 문구가 빨간색(경고 색상)으로 표시되던 문제를 바로잡았습니다 — 일반 안내에도 경고 색상이 쓰여 어색하게 강조되던 부분을 다른 관리자 화면과 동일한 색상으로 정리했습니다.
|
||||
|
||||
|
||||
+19
@@ -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 ?? '');
|
||||
|
||||
+2
@@ -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', ''));
|
||||
}
|
||||
}
|
||||
+25
-1
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user