fix(sirsoft-gdpr): 쿠키 동의 보안 결함 3건 수정 + 하위호환 고지

이슈 체크리스트와는 별개로, 처리 완료 후 지시로 진행한
sirsoft-gdpr 플러그인 전수 비판적 검토에서 발견한 보안·결함 3건을
수정한다.

- Admin 설정 저장 API 의 검증 우회 fallback(빈 요청 시 $request->all
 사용) 제거. UpdateAdminSettingsRequest 필드가 모두 nullable 이라
 검증을 거치지 않은 임의 키가 저장 로직까지 전달될 수 있었다.
- 게스트 세션 식별 쿠키(gdpr_session) 에 HMAC-SHA256 서명 적용.
 api 미들웨어 그룹에는 EncryptCookies 가 없어 쿠키가 평문으로 오가
 임의 UUID로 타인의 게스트 동의 상태를 조회/철회할 수 있었다.
 결제 플러그인들의 IssuesReceiptCookie 트레이트와 동일 컨벤션 채택.
 이 조치로 배포 이전 발급된 비회원 쿠키는 1회 초기화되며 이미
 배너를 닫았던 비회원은 다음 방문 시 배너를 다시 보게 된다(회원
 동의 이력은 영향 없음) — CHANGELOG 에 고지.
- 게스트로 쿠키 배너를 닫은 뒤 동의 이력 없는 다른 계정으로 로그인해도
 배너가 계속 숨겨져 있던 결함 수정. _global.gdprBannerDismissedFor 가
 "닫힘 여부"(boolean) 대신 "누가 닫았는지"(사용자 식별자)를 저장하도록
 변경해, 로그인으로 사용자가 바뀌면 다시 노출되게 한다. 실제 로그인
 폼 제출을 거쳐 재현하는 E2E spec 신규 작성(PlaywrightSeedGdprGuestLogin
 시드 커맨드 포함, sirsoft-page 의 PlaywrightSeedPage 패턴 재사용).

부수: 자동 차단 정책 안내에 보호 범위 한계 고지 추가, GDPR 플러그인
번들 로딩 우선순위 상향(다른 확장의 스크립트로부터 보호 범위 확대).
This commit is contained in:
chym1217 committed 2026-07-30 10:50:49 +09:00
1 parent 192000cfce
commit 2a036704f4
25 files changed
+720 -218

No files matched your search

@@ -12,6 +12,7 @@
- 거부한 이력을 동의·철회와 구분해 관리자 「GDPR 동의 이력」에서 별도 배지와 [거부] 필터로 확인할 수 있습니다.
- 거부 후 다시 동의하면 상태가 동의로 갱신되며, 거부·동의 이력은 모두 보존됩니다.
- 마이페이지 「쿠키 동의 관리」에 「모두 동의」 버튼 추가 — 동의하지 않은 선택 항목을 한 번에 동의할 수 있습니다.
- 자동 차단 정책 안내에 보호 범위 한계를 명시했습니다 — 이 자동 차단은 플러그인·모듈이 등록한 스크립트만 대상이며, 운영자가 템플릿 외부 스크립트 등으로 head 영역에 직접 삽입한 외부 스크립트는 보호 범위 밖임을 안내합니다.
### Changed
@@ -30,6 +31,8 @@
- 관리자 환경설정의 저장/취소 버튼을 다른 관리자 화면과 동일하게 화면 하단 고정 위치로 옮기고, 상단 탭 영역의 불필요한 구분선과 좌우 여백 어긋남을 바로잡았습니다.
- 관리자 환경설정 「정책 버전 보기」에서 본문 페이지 링크를 클릭하면 페이지를 찾을 수 없던 문제를 바로잡았습니다.
- 관리자 환경설정의 「운영 주체」·「쿠키 배너 노출」·「배너 위치」 필드에서 안내 문구가 길어질 때 입력·토글 컨트롤이 옆이 아닌 아래 줄로 밀려나던 문제를 바로잡았습니다.
- 비회원(게스트) 방문자의 쿠키 동의 식별자가 위·변조에 노출되던 문제를 바로잡았습니다 — 다른 방문자의 쿠키 동의 상태를 임의로 조회·변경할 수 없도록 서명을 적용했습니다. 이 조치로 이번 업데이트 이전에 발급된 비회원 쿠키 배너 상태는 1회에 한해 초기화되며, 이미 배너를 닫았던 비회원 방문자는 다음 방문 시 배너를 다시 보게 됩니다(회원 동의 이력은 영향 없음).
- 비회원으로 쿠키 배너를 닫은 뒤 동의 이력이 없는 다른 계정으로 로그인하면 배너가 계속 숨겨져 있던 문제를 바로잡았습니다 — 이제 로그인한 계정 기준으로 다시 판단해 배너가 정상적으로 노출됩니다.
## [1.0.1] - 2026-07-16
@@ -65,7 +65,7 @@ _대표 에러 없음 (공개 조회). <!-- TODO: 도메인 특이 에러가 있
<!-- @generated:end -->
**설명** 공개 쿠키 동의 배너에서 방문자가 선택한 카테고리별 동의를 저장합니다. `optional.sanctum` 라우트로 게스트와 회원 모두 호출할 수 있으며, 회원(sanctum 토큰 보유)이면 user_id 기준으로 status를 upsert하고 history를 남기고, 게스트면 session_id 기준으로 history를 기록합니다. 게스트가 처음 호출해 세션 식별자가 없으면 UUID 기반 `gdpr_session` 쿠키(1년, SameSite=Lax)를 응답에 발급해 첨부합니다. 동의 철회 시 실제 쿠키 파기는 이 엔드포인트가 아니라 클라이언트 정리기와 후속 응답의 CookieConsentMiddleware가 담당합니다.
**설명** 공개 쿠키 동의 배너에서 방문자가 선택한 카테고리별 동의를 저장합니다. `optional.sanctum` 라우트로 게스트와 회원 모두 호출할 수 있으며, 회원(sanctum 토큰 보유)이면 user_id 기준으로 status를 upsert하고 history를 남기고, 게스트면 session_id 기준으로 history를 기록합니다. 게스트가 처음 호출해 세션 식별자가 없으면 UUID에 HMAC-SHA256 서명을 붙인(`{uuid}|{서명}`) `gdpr_session` 쿠키(1년, SameSite=Lax)를 응답에 발급해 첨부합니다 — 서명은 위조 방지 목적이며, 요청으로 들어온 값의 서명이 유효하지 않으면 신원 불명(새 게스트)으로 처리합니다. 동의 철회 시 실제 쿠키 파기는 이 엔드포인트가 아니라 클라이언트 정리기와 후속 응답의 CookieConsentMiddleware가 담당합니다.
### GET /api/plugins/sirsoft-gdpr/consent/cookie/status
@@ -188,6 +188,8 @@ Content-Type: application/json
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
**참고** 위 표에 정의되지 않은 요청 필드는 검증 대상에서 제외되며(`FormRequest::validated()` 결과만 사용), 저장 로직에 전달되지 않습니다.
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
+1 -1
View File
@@ -31,6 +31,6 @@
},
"loading": {
"strategy": "global",
"priority": 50
"priority": 0
}
}
@@ -27,8 +27,8 @@
"id": "gdpr_cookie_banner",
"type": "basic",
"name": "Div",
"comment": "GDPR 쿠키 동의 배너 (W-1) — banner_enabled 토글 기반. 환경설정은 모달 대신 배너 내부 인라인 펼침으로 처리. 동의 완료 여부는 gdprMyConsent (서버) 가 우선이며, 사용자 클릭 직후 즉시 사라지도록 _global.gdprBannerDismissed 도 함께 검사",
"if": "{{gdprPublicSettings?.data?.banner_enabled === true && gdprMyConsent?.data?.has_consented !== true && _global.gdprBannerDismissed !== true}}",
"comment": "GDPR 쿠키 동의 배너 (W-1) — banner_enabled 토글 기반. 환경설정은 모달 대신 배너 내부 인라인 펼침으로 처리. 동의 완료 여부는 gdprMyConsent (서버) 가 우선이며, 사용자 클릭 직후 즉시 사라지도록 _global.gdprBannerDismissedFor 도 함께 검사 — 닫은 시점의 사용자 식별자(uuid 또는 'guest')를 저장해 현재 사용자와 비교. 로그인으로 사용자가 바뀌면(uuid 불일치) 다시 노출되어, 게스트로 닫은 뒤 미동의 계정으로 로그인해도 배너가 계속 숨겨지는 회귀를 방지.",
"if": "{{gdprPublicSettings?.data?.banner_enabled === true && gdprMyConsent?.data?.has_consented !== true && _global.gdprBannerDismissedFor !== (_global.currentUser?.uuid ?? 'guest')}}",
"props": {
"className": "fixed z-50 {{(gdprPublicSettings?.data?.banner_position === 'bottom_left_popup') ? 'bottom-4 left-4 max-w-md rounded-lg' : ((gdprPublicSettings?.data?.banner_position === 'bottom_right_popup') ? 'bottom-4 right-4 max-w-md rounded-lg' : ((gdprPublicSettings?.data?.banner_position === 'centered_modal') ? 'top-1/2 -translate-y-1/2 inset-x-4 mx-auto max-w-md rounded-lg' : 'bottom-4 left-4 right-4 rounded-lg'))}} bg-white dark:bg-gray-800 shadow-lg border border-gray-200 dark:border-gray-700"
},
@@ -178,7 +178,7 @@
"params": {
"target": "global",
"gdprBannerSubmittingAction": null,
"gdprBannerDismissed": true,
"gdprBannerDismissedFor": "{{_global.currentUser?.uuid ?? 'guest'}}",
"gdprPreferencesOpen": false,
"gdprConsent": "{{(gdprPublicSettings?.data?.cookie_categories ?? []).reduce((acc, c) => ({ ...acc, [c.key]: c.required === true }), {})}}"
}
@@ -270,7 +270,7 @@
"target": "/api/plugins/sirsoft-gdpr/consent/renew-all",
"params": { "method": "POST" },
"onSuccess": [
{ "handler": "setState", "params": { "target": "global", "gdprBannerSubmittingAction": null, "gdprBannerDismissed": true } },
{ "handler": "setState", "params": { "target": "global", "gdprBannerSubmittingAction": null, "gdprBannerDismissedFor": "{{_global.currentUser?.uuid ?? 'guest'}}" } },
{ "handler": "refetchDataSource", "params": { "dataSourceId": "gdprMyConsent" } },
{ "handler": "toast", "params": { "type": "success", "message": "$t:sirsoft-gdpr.mypage.privacy.renew_all_success|renewed={{response?.data?.renewed ?? 0}}" } }
],
@@ -318,7 +318,7 @@
"params": {
"target": "global",
"gdprBannerSubmittingAction": null,
"gdprBannerDismissed": true,
"gdprBannerDismissedFor": "{{_global.currentUser?.uuid ?? 'guest'}}",
"gdprPreferencesOpen": false,
"gdprConsent": "{{(gdprPublicSettings?.data?.cookie_categories ?? []).reduce((acc, c) => ({ ...acc, [c.key]: true }), {})}}"
}
@@ -535,7 +535,7 @@
"params": {
"target": "global",
"gdprBannerSubmittingAction": null,
"gdprBannerDismissed": true,
"gdprBannerDismissedFor": "{{_global.currentUser?.uuid ?? 'guest'}}",
"gdprPreferencesOpen": false
}
},
@@ -3,7 +3,7 @@
*
* @description
* - extension_point: user_global_overlay (sirsoft-basic _user_base 가 사전에 정의)
* - banner_enabled === true && !gdprBannerDismissed 조건 표시
* - banner_enabled === true && !has_consented && dismissedFor(닫은 주체) !== 현재 사용자 조건 표시
* - 모두 동의 / 필수만 사용 / 환경설정 / 정책 링크 제공
* - 환경설정은 모달이 아니라 배너 내부 인라인 펼침 패널 (gdpr_preferences_panel)
* - banner_position 4종 분기 (bottom_bar / bottom_left_popup / bottom_right_popup / centered_modal)
@@ -79,17 +79,86 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => {
expect(banner).toBeTruthy();
});
it('banner_enabled + has_consented + dismissed 3중 조건 — 데이터소스 경로는 한 단계 (?.data?.<key>)', () => {
it('banner_enabled + has_consented + dismissedFor 3중 조건 — 데이터소스 경로는 한 단계 (?.data?.<key>)', () => {
// 회귀 가드:
// 1. ResponseHelper::success($msg, $data) 응답은 데이터소스에서 그대로 보존되므로 단일 객체 응답은 `<id>.data.<key>` 한 단계 경로.
// 과거 통합본에서 `?.data?.data?` 두 단계로 작성되어 항상 undefined → if false → 배너 미출력 사고.
// 2. _global.gdprBannerDismissed 만으로는 새로고침 시 휘발 — 서버 조회 (gdprMyConsent.has_consented) 가 우선이며 클릭 직후 즉시 사라지는 효과만 글로벌 상태가 보장.
// 2. _global.gdprBannerDismissedFor 만으로는 새로고침 시 휘발 — 서버 조회 (gdprMyConsent.has_consented) 가 우선이며 클릭 직후 즉시 사라지는 효과만 글로벌 상태가 보장.
// 3. needs_renewal=true 일 때도 배너만 노출 (모달 폐기 — 결정 모달/배너 중복 제거). 배너 본문에 사유 안내 + 회원 한정 "현 상태 유지" 액션 통합.
// 4. dismissedFor 는 "누가 닫았는지"(uuid|'guest') 를 저장해 현재 사용자(_global.currentUser?.uuid)와 비교한다.
// boolean(gdprBannerDismissed) 만 저장하던 이전 방식은 게스트로 닫은 뒤 미동의 계정으로 로그인해도
// 배너가 계속 숨겨지는 회귀가 있었다 (닫은 주체를 구분하지 않았기 때문).
expect(banner?.if).toBe(
'{{gdprPublicSettings?.data?.banner_enabled === true && gdprMyConsent?.data?.has_consented !== true && _global.gdprBannerDismissed !== true}}'
"{{gdprPublicSettings?.data?.banner_enabled === true && gdprMyConsent?.data?.has_consented !== true && _global.gdprBannerDismissedFor !== (_global.currentUser?.uuid ?? 'guest')}}"
);
});
/**
* 회귀 가드 — 게스트로 배너를 닫은 뒤 미동의 계정으로 로그인해도 배너가 계속
* 숨겨지던 결함. banner.if 표현식을 실제 JS 로 평가해 각 시나리오의 최종
* 노출 여부를 직접 검증한다 (문자열 일치만으로는 실제 평가 결과를 보장하지 못함).
*/
describe('닫은 주체(dismissedFor) vs 현재 사용자 비교 — 실제 평가', () => {
const evaluateBannerIf = (
gdprMyConsent: { data?: { has_consented?: boolean } } | undefined,
_global: { gdprBannerDismissedFor?: string; currentUser?: { uuid?: string } }
): boolean => {
const gdprPublicSettings = { data: { banner_enabled: true } };
const expr = (banner?.if ?? '').replace(/^\{\{/, '').replace(/\}\}$/, '');
// eslint-disable-next-line no-new-func
const fn = new Function('gdprPublicSettings', 'gdprMyConsent', '_global', `return (${expr});`);
return fn(gdprPublicSettings, gdprMyConsent, _global) as boolean;
};
it('게스트로 배너를 닫으면(dismissedFor="guest") 같은 게스트 방문에는 배너가 다시 뜨지 않는다', () => {
const shown = evaluateBannerIf(
{ data: { has_consented: false } },
{ gdprBannerDismissedFor: 'guest', currentUser: undefined }
);
expect(shown).toBe(false);
});
it('게스트로 배너를 닫은 뒤 미동의 계정으로 로그인하면 배너가 다시 뜬다 (회귀 재현 케이스)', () => {
const shown = evaluateBannerIf(
{ data: { has_consented: false } },
{ gdprBannerDismissedFor: 'guest', currentUser: { uuid: 'user-uuid-1' } }
);
expect(shown).toBe(true);
});
it('회원 A 가 닫은 뒤 같은 세션에서 회원 A 로 계속 조회하면 배너가 다시 뜨지 않는다', () => {
const shown = evaluateBannerIf(
{ data: { has_consented: false } },
{ gdprBannerDismissedFor: 'user-uuid-1', currentUser: { uuid: 'user-uuid-1' } }
);
expect(shown).toBe(false);
});
it('회원 A 가 닫은 뒤 (동일 세션에서) 회원 B 로 전환되면 배너가 다시 뜬다', () => {
const shown = evaluateBannerIf(
{ data: { has_consented: false } },
{ gdprBannerDismissedFor: 'user-uuid-1', currentUser: { uuid: 'user-uuid-2' } }
);
expect(shown).toBe(true);
});
it('서버가 이미 동의 완료(has_consented=true)로 응답하면 dismissedFor 값과 무관하게 배너를 띄우지 않는다', () => {
const shown = evaluateBannerIf(
{ data: { has_consented: true } },
{ gdprBannerDismissedFor: 'guest', currentUser: { uuid: 'user-uuid-1' } }
);
expect(shown).toBe(false);
});
it('dismissedFor 가 아직 설정되지 않은 최초 방문(게스트)에는 배너가 뜬다', () => {
const shown = evaluateBannerIf(
{ data: { has_consented: false } },
{ gdprBannerDismissedFor: undefined, currentUser: undefined }
);
expect(shown).toBe(true);
});
});
it('needs_renewal 강제 모달 (gdpr_needs_renewal_modal) 이 더 이상 존재하지 않는다 (결정 — 배너 통합)', () => {
// 모달 폐기 회귀 가드: 모달과 배너의 시각적 중복 + UX 혼란 + dark pattern 회피 위해
// 모달 통째 제거하고 배너에 사유 안내 + 회원 한정 "현 상태 유지" 액션 통합.
@@ -293,7 +362,7 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => {
it('동의 저장 직후 onSuccess 에서 gdprMyConsent 를 refetch (3개 버튼 모두)', () => {
// 회귀 가드: 동의 후 새로고침 시 배너 재출력을 막으려면 서버 동의 상태를 다시 조회해야 함.
// setState gdprBannerDismissed=true 만으로는 글로벌 상태가 새로고침 시 휘발됨.
// setState gdprBannerDismissedFor=<uuid|'guest'> 만으로는 글로벌 상태가 새로고침 시 휘발됨.
const refetchOccurrences = (text.match(/"handler":\s*"refetchDataSource"/g) ?? []).length;
expect(refetchOccurrences).toBeGreaterThanOrEqual(3);
expect(text).toContain('"dataSourceId": "gdprMyConsent"');
@@ -398,10 +467,10 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => {
expect(text).toContain('"disabled": "{{category?.required === true}}"');
});
it('저장 버튼 클릭 시 POST /consent/cookie + 배너 dismiss + 성공 토스트', () => {
it('저장 버튼 클릭 시 POST /consent/cookie + 배너 dismissedFor 갱신 + 성공 토스트', () => {
const text = serializeForSearch(panel);
expect(text).toContain('/api/plugins/sirsoft-gdpr/consent/cookie');
expect(text).toContain('"gdprBannerDismissed": true');
expect(text).toContain('"gdprBannerDismissedFor": "{{_global.currentUser?.uuid ?? \'guest\'}}"');
expect(text).toContain('"gdprPreferencesOpen": false');
expect(text).toContain('sirsoft-gdpr.consent.granted');
});
@@ -104,6 +104,7 @@
"warning_visual_break": "Be careful when classifying external font/stylesheet domains. Misclassification can break the page silently.",
"warning_policy_bump": "Bump the cookie policy version after changing the blocked domain list. This prevents existing consenters from being implicitly enrolled into newly added trackers.",
"warning_domain_format": "Domain format: example.com, *.example.com (wildcard) are supported. Names without dots (like localhost) and non-ASCII domains are not supported.",
"warning_scope_limit": "Auto-blocking only covers scripts registered by plugins/modules. External scripts inserted directly into the head area by admins (e.g. via Template External Scripts) are outside this protection — verify their load conditions separately.",
"tag_input_placeholder": "Type a domain and press Enter (e.g., google-analytics.com, *.hotjar.com)",
"tag_no_options": "No suggestions. Type to add.",
"category": {
@@ -104,6 +104,7 @@
"warning_visual_break": "외부 폰트·스타일시트는 분류에 주의하세요. 잘못 차단하면 페이지가 조용히 깨질 수 있습니다.",
"warning_policy_bump": "차단 도메인을 변경한 후 쿠키 정책 버전을 올리세요. 기존 동의자가 새 도메인에도 동의한 것으로 처리되는 문제를 막습니다.",
"warning_domain_format": "도메인 형식: example.com, *.example.com (와일드카드) 가능. localhost 처럼 점이 없는 이름이나 한글 도메인은 지원하지 않습니다.",
"warning_scope_limit": "이 자동 차단은 플러그인·모듈이 등록하는 스크립트만 대상입니다. 「템플릿 외부 스크립트」 등 운영자가 직접 head 영역에 삽입한 외부 스크립트는 이 자동 차단의 보호 범위 밖이므로, 별도로 로드 조건을 확인하세요.",
"tag_input_placeholder": "도메인 입력 후 Enter (예: google-analytics.com, *.hotjar.com)",
"tag_no_options": "추천 도메인이 없습니다. 직접 입력하세요.",
"category": {
@@ -1668,6 +1668,11 @@
"type": "basic",
"name": "Li",
"text": "$t:sirsoft-gdpr.settings.fields.blocked_domains.warning_domain_format"
},
{
"type": "basic",
"name": "Li",
"text": "$t:sirsoft-gdpr.settings.fields.blocked_domains.warning_scope_limit"
}
]
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\Gdpr\Concerns;
/**
* 게스트 세션 식별 쿠키(gdpr_session) 발급/검증 트레이트
*
* api 미들웨어 그룹에는 EncryptCookies 가 등록되지 않아 쿠키가 평문으로 오간다.
* 서버가 발급하지 않은 값을 그대로 신뢰하면 임의로 조작한 session_id 로 타인의
* 게스트 동의 이력을 조회/철회할 수 있으므로, HMAC 서명을 붙여 위조를 차단한다.
*
* sirsoft-pay_nicepayments 등 결제 플러그인의 IssuesReceiptCookie 트레이트와
* 동일한 서명 컨벤션(hash_hmac + config('app.key'))을 사용한다.
*/
trait IssuesGuestSessionCookie
{
/**
* 쿠키 값에 서명을 붙입니다.
*
* @param string $sessionId UUID v4 게스트 세션 식별자
* @return string "{sessionId}|{signature}" 형태의 쿠키 값
*/
protected function signGuestSessionId(string $sessionId): string
{
return $sessionId.'|'.$this->computeGuestSessionSignature($sessionId);
}
/**
* 쿠키 값에서 서명을 검증하고 원본 session_id 를 반환합니다.
*
* 서명이 없거나 위조된 값은 신뢰하지 않고 null 을 반환합니다
* (호출자는 미식별 게스트로 취급해야 함).
*
* @param string|null $cookieValue 요청 쿠키 원본 값
* @return string|null 검증된 session_id, 위조/형식 오류 시 null
*/
protected function verifyGuestSessionId(?string $cookieValue): ?string
{
if (! is_string($cookieValue) || $cookieValue === '') {
return null;
}
$parts = explode('|', $cookieValue, 2);
if (count($parts) !== 2) {
return null;
}
[$sessionId, $signature] = $parts;
if ($sessionId === '' || ! ctype_xdigit($signature) || strlen($signature) !== 64) {
return null;
}
if (! hash_equals($this->computeGuestSessionSignature($sessionId), $signature)) {
return null;
}
return substr($sessionId, 0, 100);
}
/**
* session_id 에 대한 HMAC-SHA256 서명을 계산합니다.
*
* @param string $sessionId 게스트 세션 식별자
* @return string 64자 hex 서명
*/
private function computeGuestSessionSignature(string $sessionId): string
{
return hash_hmac('sha256', $sessionId, (string) config('app.key', ''));
}
}
@@ -0,0 +1,106 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Console\Commands;
use App\Models\User;
use App\Services\PluginSettingsService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Str;
use Plugins\Sirsoft\Gdpr\Concerns\IssuesGuestSessionCookie;
use Plugins\Sirsoft\Gdpr\Services\GdprConsentService;
/**
* Playwright E2E 용 GDPR "게스트로 배너 닫음 → 다른 계정 로그인" 시나리오 시드 커맨드.
*
* 배너 노출 조건 회귀(cookie_banner.json 의 gdprBannerDismissedFor)를 실제 로그인 폼
* 제출을 거쳐 재현하기 위해 다음을 준비한다:
* 1. banner_enabled=true 저장 (배너 자체가 꺼져 있으면 시나리오 자체가 성립하지 않음)
* 2. 서명된 게스트 세션 쿠키 값 발급 + 그 세션 ID 로 "모두 동의" 이력을 실제로 기록
* (spec 이 쿠키를 심어 "이미 게스트로 배너를 닫은 상태"를 재현)
* 3. 로그인 폼에 실제로 제출할 미동의 회원 계정(email/password 고정) 1명 생성
* (이 계정은 신규 유저라 동의 이력이 전혀 없음 — 로그인 시 배너가 다시 떠야 정상)
*
* 보안 가드 (코어 PlaywrightIssueToken 과 동일 3중 패턴):
* ① CLI 한정 — `php_sapi_name() === 'cli'`
* ② G7_PLAYWRIGHT_BYPASS=1 환경변수 옵트인
* ③ APP_DEBUG=true inline override
*
* 호출 예시:
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:seed-gdpr-guest-login --json
*/
class PlaywrightSeedGdprGuestLogin extends Command
{
use IssuesGuestSessionCookie;
/**
* 시나리오에서 로그인 폼에 실제로 입력할 고정 비밀번호.
*/
private const MEMBER_PASSWORD = 'e2e-test-password-1234';
protected $signature = 'playwright:seed-gdpr-guest-login
{--json : 결과를 JSON 으로 출력}';
protected $description = 'Playwright E2E 용 GDPR 게스트→로그인 배너 재노출 시나리오 데이터 시드 (CLI + G7_PLAYWRIGHT_BYPASS 가드)';
/**
* 커맨드를 실행합니다.
*
* @param GdprConsentService $consentService 게스트 동의 이력 기록용
* @param PluginSettingsService $pluginSettings banner_enabled 저장용
* @return int 종료 코드
*/
public function handle(GdprConsentService $consentService, PluginSettingsService $pluginSettings): int
{
// ① CLI 한정
if (php_sapi_name() !== 'cli') {
$this->error('CLI 전용 커맨드입니다.');
return self::FAILURE;
}
// ② 명시 옵트인
if (env('G7_PLAYWRIGHT_BYPASS') !== '1') {
$this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다.');
return self::FAILURE;
}
// ③ APP_DEBUG 강제
Config::set('app.debug', true);
// 배너 자체가 꺼져 있으면 재현이 불가능하므로 명시적으로 켠다.
$pluginSettings->save('sirsoft-gdpr', ['banner_enabled' => true]);
// 게스트 세션: 서명된 쿠키 값을 발급하고, 동일 session_id 로 "모두 동의" 이력을 기록한다.
$guestSessionId = (string) Str::uuid();
$signedCookieValue = $this->signGuestSessionId($guestSessionId);
$consentService->updateConsents(
null,
$guestSessionId,
['cookie_necessary' => true, 'cookie_functional' => true, 'cookie_analytics' => true, 'cookie_marketing' => true],
'banner'
);
// 로그인 대상 회원: 동의 이력이 전혀 없는 신규 계정.
$email = 'e2e-gdpr-guest-login-'.uniqid().'@example.test';
$member = User::factory()->create([
'email' => $email,
'password' => bcrypt(self::MEMBER_PASSWORD),
]);
$result = [
'guest_session_cookie_value' => $signedCookieValue,
'member_email' => $member->email,
'member_password' => self::MEMBER_PASSWORD,
];
if ($this->option('json')) {
$this->line(json_encode($result, JSON_UNESCAPED_UNICODE));
} else {
$this->info('GDPR 게스트→로그인 시나리오 시드 완료: '.$member->email);
}
return self::SUCCESS;
}
}
@@ -30,8 +30,8 @@ class GdprAdminSettingsController extends AdminBaseController
/**
* GdprAdminSettingsController 생성자
*
* @param PluginSettingsService $pluginSettings 플러그인 설정 서비스 (코어 — 조회용)
* @param GdprSettingsService $settingsService GDPR 설정 저장 서비스
* @param PluginSettingsService $pluginSettings 플러그인 설정 서비스 (코어 — 조회용)
* @param GdprSettingsService $settingsService GDPR 설정 저장 서비스
*/
public function __construct(
private readonly PluginSettingsService $pluginSettings,
@@ -62,18 +62,13 @@ class GdprAdminSettingsController extends AdminBaseController
* 정책 버전은 자동 발행되지 않습니다. 운영자가 별도로
* 「+ 새 버전 발행」 을 클릭해야 발행됩니다.
*
* @param UpdateAdminSettingsRequest $request 검증된 요청
* @param UpdateAdminSettingsRequest $request 검증된 요청
* @return JsonResponse
*/
public function update(UpdateAdminSettingsRequest $request): JsonResponse
{
$validated = $request->validated();
// 동적 스키마 fallback (validation.md "동적 스키마 기반 FormRequest 패턴")
if (empty($validated)) {
$validated = $request->all();
}
// 옛 자동 발행 흐름의 change_memo 필드는 더 이상 사용하지 않음 — 정책 버전 발행은 별도 엔드포인트로 일원화
unset($validated['change_memo']);
@@ -87,7 +82,7 @@ class GdprAdminSettingsController extends AdminBaseController
/**
* 응답에서 JSON 필드는 디코드하여 객체/배열로 노출합니다.
*
* @param array<string, mixed> $settings 설정 배열
* @param array<string, mixed> $settings 설정 배열
* @return array<string, mixed>
*/
private function normalizeJsonFields(array $settings): array
@@ -5,6 +5,7 @@ namespace Plugins\Sirsoft\Gdpr\Http\Controllers\Public;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\PublicBaseController;
use Illuminate\Http\JsonResponse;
use Plugins\Sirsoft\Gdpr\Concerns\IssuesGuestSessionCookie;
use Plugins\Sirsoft\Gdpr\Http\Requests\StoreCookieConsentRequest;
use Plugins\Sirsoft\Gdpr\Services\GdprConsentService;
@@ -18,6 +19,8 @@ use Plugins\Sirsoft\Gdpr\Services\GdprConsentService;
*/
class GdprCookieConsentController extends PublicBaseController
{
use IssuesGuestSessionCookie;
/**
* GdprCookieConsentController 생성자
*
@@ -120,17 +123,18 @@ class GdprCookieConsentController extends PublicBaseController
/**
* 게스트 세션 ID를 결정합니다.
*
* 클라이언트가 쿠키 또는 헤더로 전달한 session_id가 있으면 사용,
* 없으면 Laravel session ID를 fallback으로 사용합니다.
* 클라이언트가 쿠키로 전달한 session_id는 서명을 검증한 뒤 신뢰합니다
* (위조된 값은 미식별 게스트로 취급). 쿠키가 없으면 Laravel session ID를
* fallback으로 사용합니다.
*
* @param \Illuminate\Http\Request $request 요청
* @return string|null
*/
private function resolveGuestSessionId($request): ?string
{
$cookieValue = $request->cookie('gdpr_session');
if (is_string($cookieValue) && $cookieValue !== '') {
return substr($cookieValue, 0, 100);
$verified = $this->verifyGuestSessionId($request->cookie('gdpr_session'));
if ($verified !== null) {
return $verified;
}
try {
@@ -156,6 +160,7 @@ class GdprCookieConsentController extends PublicBaseController
* 응답에 게스트 세션 쿠키 (gdpr_session) 를 첨부합니다.
*
* 1년 유효, path=/, SameSite=Lax. HTTPS 환경에서는 Secure 자동 적용.
* 쿠키 값은 HMAC 서명이 붙어 위변조 시 서버가 거부합니다.
*
* @param JsonResponse $response 응답
* @param string $sessionId 발급된 세션 ID
@@ -165,7 +170,7 @@ class GdprCookieConsentController extends PublicBaseController
{
$response->cookie(
'gdpr_session',
$sessionId,
$this->signGuestSessionId($sessionId),
60 * 24 * 365,
'/',
null,
@@ -5,6 +5,7 @@ namespace Plugins\Sirsoft\Gdpr\Http\Middleware;
use Closure;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\Request;
use Plugins\Sirsoft\Gdpr\Concerns\IssuesGuestSessionCookie;
use Plugins\Sirsoft\Gdpr\Services\GdprConsentService;
use Symfony\Component\HttpFoundation\Response;
@@ -25,6 +26,8 @@ use Symfony\Component\HttpFoundation\Response;
*/
class CookieConsentMiddleware
{
use IssuesGuestSessionCookie;
/**
* CookieConsentMiddleware 생성자
*
@@ -106,6 +109,9 @@ class CookieConsentMiddleware
/**
* 게스트 세션 식별자를 추출합니다 (gdpr_session cookie 또는 Laravel session ID).
*
* 쿠키 값은 서명을 검증한 뒤 신뢰합니다 — 위조된 값은 미식별 게스트로
* 취급하고 Laravel session ID로 폴백합니다.
*
* @param Request $request HTTP 요청
* @return string|null 세션 식별자 (회원이거나 식별 불가 시 null)
*/
@@ -115,9 +121,9 @@ class CookieConsentMiddleware
return null;
}
$cookieValue = $request->cookie('gdpr_session');
if (is_string($cookieValue) && $cookieValue !== '') {
return substr($cookieValue, 0, 100);
$verified = $this->verifyGuestSessionId($request->cookie('gdpr_session'));
if ($verified !== null) {
return $verified;
}
try {
@@ -3,6 +3,7 @@
namespace Plugins\Sirsoft\Gdpr\Providers;
use App\Extension\BasePluginServiceProvider;
use Plugins\Sirsoft\Gdpr\Console\Commands\PlaywrightSeedGdprGuestLogin;
use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprPolicyVersionRepositoryInterface;
use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentHistoryRepositoryInterface;
use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentRepositoryInterface;
@@ -27,4 +28,15 @@ class GdprServiceProvider extends BasePluginServiceProvider
GdprUserConsentHistoryRepositoryInterface::class => GdprUserConsentHistoryRepository::class,
GdprPolicyVersionRepositoryInterface::class => GdprPolicyVersionRepository::class,
];
public function boot(): void
{
parent::boot();
if ($this->app->runningInConsole()) {
$this->commands([
PlaywrightSeedGdprGuestLogin::class,
]);
}
}
}
@@ -153,11 +153,32 @@ class GdprCookieConsentControllerTest extends PluginTestCase
'신규 게스트 session_id 는 UUID v4 형식이어야 함'
);
// 응답에 gdpr_session 쿠키가 첨부되어야 함
// 응답에 gdpr_session 쿠키가 첨부되어야 함 — 값은 위조 방지를 위해 HMAC 서명이 붙어
// "{session_id}|{signature}" 형식이므로 session_id 는 접두 일치로 확인한다.
$cookies = collect($response->headers->getCookies())
->firstWhere(fn ($c) => $c->getName() === 'gdpr_session');
$this->assertNotNull($cookies, '게스트 신규 동의 시 gdpr_session 쿠키가 발급되어야 함');
$this->assertSame($sessionId, $cookies->getValue());
$this->assertStringStartsWith($sessionId.'|', (string) $cookies->getValue());
}
/**
* 회귀 가드: gdpr_session 쿠키 값을 임의로 조작해 보내면 서버가 그 값을 신뢰하지 않고
* 새로운(다른) session_id 로 취급해야 한다 — 서명 없는 값을 그대로 신뢰하던 결함 회귀 방지.
*/
public function test_tampered_gdpr_session_cookie_is_not_trusted(): void
{
$this->mockSettings(['cookie_policy_version' => '1.0']);
$forgedSessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
$response = $this->withCookie('gdpr_session', $forgedSessionId)
->postJson('/api/plugins/sirsoft-gdpr/consent/cookie', [
'consents' => ['cookie_necessary' => true, 'cookie_analytics' => true],
'source' => 'banner',
]);
$response->assertOk();
$this->assertNotSame($forgedSessionId, $response->json('data.session_id'), '서명 없는 위조 session_id 는 신뢰되면 안 됨');
}
public function test_guest_consent_persists_via_service_with_session_id(): void
@@ -0,0 +1,43 @@
/**
* GDPR "게스트로 배너 닫음 → 다른 계정 로그인" 시나리오 도메인 시드 fixture.
*
* `playwright:seed-gdpr-guest-login` artisan 커맨드를 호출하여 다음을 발급받는다:
* - 서명된 게스트 세션 쿠키 값 (이미 "모두 동의" 이력이 기록된 상태)
* - 동의 이력이 전혀 없는 신규 회원 계정 (email/password 고정, 로그인 폼에 실제 입력)
*/
import { test as base } from '@playwright/test';
import { execSync } from 'node:child_process';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// ESM 환경(package.json "type": "module")에서는 __dirname 미정의 → import.meta.url 로 재구성.
const __dirname = dirname(fileURLToPath(import.meta.url));
type GdprGuestLoginSeedFixtures = {
/** 게스트→로그인 배너 재노출 시나리오용 시드 데이터 */
gdprGuestLoginSeed: {
guest_session_cookie_value: string;
member_email: string;
member_password: string;
};
};
export const test = base.extend<GdprGuestLoginSeedFixtures>({
gdprGuestLoginSeed: async ({}, use) => {
// 6단계 상위 = 코어 루트 (artisan 실행 cwd)
const coreRoot = process.env.G7_ROOT || resolve(__dirname, '../../../../../../');
const out = execSync('php artisan playwright:seed-gdpr-guest-login --json', {
cwd: coreRoot,
encoding: 'utf-8',
env: {
...process.env,
G7_PLAYWRIGHT_BYPASS: '1',
},
});
const jsonLine = out.trim().split(/\r?\n/).filter((l) => l.trim().startsWith('{')).pop() ?? '{}';
const seed = JSON.parse(jsonLine);
await use(seed);
},
});
export { expect } from '@playwright/test';
@@ -0,0 +1,60 @@
/**
* E2E: 게스트로 쿠키 배너를 닫은 뒤 동의 이력 없는 계정으로 로그인 시 배너 재노출 회귀 가드
*
* @scenario cookie_banner_guest_dismiss_then_login_reappear
* @effects banner_hidden_for_consented_guest, banner_reappears_after_login_with_no_consent_history
*
* 배경: `_global.gdprBannerDismissedFor` 가 "닫힘 여부"(boolean)가 아니라 "누가 닫았는지"
* (사용자 식별자)를 저장하도록 수정되기 전에는, 게스트로 배너를 닫은 뒤 동의 이력이 없는
* 다른 계정으로 로그인해도 배너가 계속 숨겨져 있는 결함이 있었다 (PO 가 스크린샷으로 재현·지적).
* 현재는 cookie_banner.json 의 노출 조건이 `_global.gdprBannerDismissedFor !== (사용자 식별자)`
* 로 비교하므로, 로그인으로 사용자가 바뀌면(게스트 uuid → 회원 uuid) 다시 노출되어야 한다.
*
* 검증:
* 1. 서명된 "모두 동의" 게스트 세션 쿠키를 심은 상태로 홈에 진입하면 배너가 노출되지 않는다
* (게스트 자신은 이미 동의했으므로 정상)
* 2. 로그인 폼으로 동의 이력이 전혀 없는 신규 계정에 실제로 로그인하면 배너가 다시 노출된다
* (회귀 재현 케이스 — 수정 전에는 여기서 실패)
*/
import { test, expect } from '../../fixtures/gdpr-guest-login-seed';
const BANNER = '#gdpr_cookie_banner';
test('#509 보안검토 - 게스트로 동의 완료 후 미동의 계정으로 로그인하면 배너가 다시 노출된다', async ({ page, gdprGuestLoginSeed }) => {
// 먼저 baseURL 로 진입해 현재 오리진을 확보한 뒤(하드코딩 회피), 서명된 게스트 세션
// 쿠키를 심는다 (이미 "모두 동의" 이력이 기록된 상태).
await page.goto('/');
await page.context().addCookies([
{
name: 'gdpr_session',
value: gdprGuestLoginSeed.guest_session_cookie_value,
url: page.url(),
path: '/',
},
]);
await page.reload();
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
// 이미 동의한 게스트이므로 배너가 노출되지 않아야 한다.
await expect(page.locator(BANNER)).not.toBeVisible({ timeout: 15_000 });
// 2. 로그인 폼으로 동의 이력이 전혀 없는 신규 계정에 실제로 로그인한다.
await page.goto('/login');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const emailInput = page.locator('input[name="email"]').first();
await expect(emailInput).toBeVisible({ timeout: 15_000 });
await emailInput.fill(gdprGuestLoginSeed.member_email);
const passwordInput = page.locator('input[name="password"]').first();
await passwordInput.fill(gdprGuestLoginSeed.member_password);
await page.locator('button[type="submit"]').first().click();
// 로그인 성공 후 리다이렉트 대기 (로그인 페이지를 벗어남).
await expect(page).not.toHaveURL(/\/login/, { timeout: 20_000 });
// 회귀 가드: 동의 이력 없는 계정으로 전환됐으므로 배너가 다시 노출되어야 한다.
await expect(page.locator(BANNER)).toBeVisible({ timeout: 15_000 });
});
@@ -0,0 +1,93 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Tests\Unit\Concerns;
use Plugins\Sirsoft\Gdpr\Concerns\IssuesGuestSessionCookie;
use Plugins\Sirsoft\Gdpr\Tests\PluginTestCase;
/**
* IssuesGuestSessionCookie 트레이트 테스트
*
* gdpr_session 쿠키의 HMAC 서명 발급/검증 — 위조된 session_id 를 신뢰하지 않는지 검증.
*/
class IssuesGuestSessionCookieTest extends PluginTestCase
{
/**
* @return object 트레이트를 사용하는 익명 클래스 인스턴스
*/
private function subject(): object
{
return new class
{
use IssuesGuestSessionCookie;
public function sign(string $sessionId): string
{
return $this->signGuestSessionId($sessionId);
}
public function verify(?string $cookieValue): ?string
{
return $this->verifyGuestSessionId($cookieValue);
}
};
}
public function test_signed_cookie_value_round_trips_to_original_session_id(): void
{
$subject = $this->subject();
$sessionId = '11111111-2222-3333-4444-555555555555';
$signed = $subject->sign($sessionId);
$verified = $subject->verify($signed);
$this->assertSame($sessionId, $verified);
}
public function test_tampered_session_id_with_stale_signature_is_rejected(): void
{
$subject = $this->subject();
$signed = $subject->sign('11111111-2222-3333-4444-555555555555');
[, $signature] = explode('|', $signed, 2);
$tampered = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee|'.$signature;
$this->assertNull($subject->verify($tampered));
}
public function test_arbitrary_unsigned_value_is_rejected(): void
{
$subject = $this->subject();
$this->assertNull($subject->verify('just-a-random-string'));
}
public function test_empty_or_null_value_is_rejected(): void
{
$subject = $this->subject();
$this->assertNull($subject->verify(null));
$this->assertNull($subject->verify(''));
}
public function test_malformed_signature_format_is_rejected(): void
{
$subject = $this->subject();
// 서명 길이가 64자 hex 가 아님
$this->assertNull($subject->verify('11111111-2222-3333-4444-555555555555|not-a-valid-signature'));
}
public function test_signature_computed_with_different_app_key_is_rejected(): void
{
$subject = $this->subject();
$sessionId = '11111111-2222-3333-4444-555555555555';
config(['app.key' => 'base64:'.base64_encode(random_bytes(32))]);
$signed = $subject->sign($sessionId);
config(['app.key' => 'base64:'.base64_encode(random_bytes(32))]);
$this->assertNull($subject->verify($signed));
}
}