Files
Gnuboard7/plugins/_bundled/sirsoft-gdpr/resources/js/cookieInterceptor.ts
T
HeuJung 1124fad027 feat(gdpr): 필수 저장 항목 허용목록을 운영자 설정으로 이관
허용목록이 플러그인 코드 상수라, 새 확장이 저장 키를 도입할 때마다 이 플러그인을
고쳐야 했다. 고치지 않으면 그 키는 방문마다 파기되는데 예외도 로그도 남지 않아
운영자에게는 "설정이 저장되지 않는다" 는 증상만 보인다.

허용목록을 운영자 설정(necessary_storage_allowlist)으로 옮기고, 관리자 환경설정에
브라우저 저장소·세션 저장소·쿠키 세 카드를 두어 직접 편집하게 했다. 확장은 GDPR 을
알 필요가 없고 GDPR 도 확장을 알 필요가 없다 — 연결은 운영자가 화면에서 한다.
저장 키를 플러그인이 관측·수집하지 않는 것도 같은 이유다: 관측 자체가 추적이다.

판정은 서버(미들웨어)와 클라이언트 셋(인터셉터 둘 + 정리기)이 같은 설정을 같은 매칭
규칙으로 읽는다. 잠금 항목(로그인 토큰·CSRF·세션 쿠키·동의 기록)만 설정 밖에 두고
판정 시점에 합집합으로 얹는다 — 설정에 담기면 저장 요청 한 번으로 지워져 잠금이
아니게 된다. 기설치본은 업그레이드 스텝이 현재 카탈로그를 설정 파일에 못박되,
운영자가 이미 편집했으면 그대로 둔다.

함께 고친 선재 결함 셋:
- 차단 도메인 추천 목록이 응답에 실리지 않아 자동완성이 동작하지 않던 문제
- 세션 쿠키 이름을 기본값에서 바꾼 사이트에서 브라우저 측 목록의 그 항목이 죽던 문제
- 쿠키 목록에만 앞부분 매칭(`이름_*`)이 적용되지 않던 문제
2026-09-03 22:18:18 +09:00

203 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* GDPR 1st-party Cookie 가로채기 (cookieInterceptor)
*
* `document.cookie` setter 를 가로채 functional 카테고리 미동의 상태에서
* 신규 쿠키 쓰기를 차단. EDPB Guidelines 2/2023 §16 "동의 전 사전 차단" 충족.
*
* 본 모듈은 **클라이언트 측 쓰기** 만 가로챔. 서버 Set-Cookie 응답은
* CookieConsentMiddleware (서버) 가 처리. 두 라인이 함께 동작해야 완전.
*
* 게이팅 규칙 (Phase 2 단순화 — 4단계):
* 1. cleared cookie (Max-Age=0 / expires 과거) → 항상 허용 (§117 충돌 회피)
* 2. strictly necessary allowlist 매칭 → 허용
* 3. functional 동의 → 허용
* 4. user-initiated 면제 (WP29 §3.6, 항상 활성) → 사용자 인터랙션 직후 허용
* 5. 그 외 → 차단
*
* "운영자 등록 표" 는 제거됨 — 모든 비-필수 cookie 는 동일 규칙 적용.
*
* @module sirsoft-gdpr/cookieInterceptor
*/
import {
LOCKED_FALLBACK,
isNecessary,
type NecessaryAllowlist,
} from './necessaryAllowlist';
import { isUserInitiated } from './userInitiatedTracker';
/**
* 인터셉터 설정.
*
* @property functionalConsented functional 카테고리 동의 여부
* @property necessaryAllowlist strictly necessary 허용목록 (운영자 설정 ∪ 잠금 집합) — `cookie` 스코프만 사용
*/
export interface CookieInterceptorConfig {
functionalConsented: boolean;
necessaryAllowlist: NecessaryAllowlist;
}
let installed = false;
let originalCookieDescriptor: PropertyDescriptor | null = null;
let config: CookieInterceptorConfig = {
functionalConsented: false,
necessaryAllowlist: LOCKED_FALLBACK,
};
/**
* "name=value; Path=/; ..." 형식의 cookie 문자열에서 name 만 추출합니다.
*
* @param raw document.cookie setter 에 전달된 raw 문자열
* @return 쿠키 이름 (실패 시 null)
*/
function extractCookieName(raw: string): string | null {
const eqIdx = raw.indexOf('=');
if (eqIdx <= 0) {
return null;
}
return raw.substring(0, eqIdx).trim();
}
/**
* 이름이 strictly necessary 허용목록에 포함되는지 검사합니다.
*
* 판정은 저장소 목록과 **같은 함수**를 쓴다 — 쿠키만 정확 일치 전용이면 운영자가 쿠키 카드에
* 적은 `myplugin_*` 이 저장소 카드와 달리 동작하지 않는다.
*
* @param name 쿠키 이름
* @return 매칭 여부
*/
function matchesNecessary(name: string): boolean {
return isNecessary(name, 'cookie', config.necessaryAllowlist);
}
/**
* cookie 쓰기가 허용되는지 판정합니다.
*
* 파기 cookie (값 비어있음 + expires 또는 Max-Age 가 과거/0) 는 항상 통과 —
* EDPB §117 (철회 즉시 파기) 와 인터셉터의 §16 (사전 차단) 가 충돌하지 않도록.
* cleaner 가 발송하는 Max-Age=0 cookie 도 본 분기로 통과.
*
* @param rawValue document.cookie setter 입력
* @return 허용 여부
*/
export function isCookieAllowed(rawValue: string): boolean {
const name = extractCookieName(rawValue);
if (name === null) {
// 파싱 불가 → 보수적 차단 (잘못된 cookie 문자열은 어차피 브라우저가 무시).
return false;
}
// 파기 cookie 패턴 — Max-Age=0 / Max-Age=-N / expires=past
if (isClearingCookie(rawValue)) {
return true;
}
if (matchesNecessary(name)) {
return true;
}
if (config.functionalConsented) {
return true;
}
// user-initiated 면제 (WP29 §3.6) — 사용자가 직접 트리거한 일회성 설정은 동의 없이 허용.
if (isUserInitiated()) {
return true;
}
return false;
}
/**
* cookie 문자열이 파기 의도 (Max-Age=0 / 음수 또는 expires 과거) 인지 판정.
*
* 표준 cookie 삭제 패턴 — 브라우저가 즉시 해당 cookie 를 파기. EDPB §117 (철회 즉시 파기)
* 의 필수 메커니즘이므로 인터셉터가 항상 통과시켜야 함.
*
* @param rawValue document.cookie setter 입력
* @return 파기 cookie 여부
*/
function isClearingCookie(rawValue: string): boolean {
const lower = rawValue.toLowerCase();
// Max-Age=0 또는 음수
const maxAgeMatch = lower.match(/max-age\s*=\s*(-?\d+)/);
if (maxAgeMatch && parseInt(maxAgeMatch[1], 10) <= 0) {
return true;
}
// expires=과거 — 1970 휴리스틱
if (lower.includes('expires=thu, 01 jan 1970') || lower.includes('expires=thu, 01-jan-1970')) {
return true;
}
return false;
}
/**
* 인터셉터를 설치합니다 — Document.prototype 의 cookie setter 를 가로채기.
*
* @param initialConfig 초기 설정
* @return void
*/
export function installCookieInterceptor(initialConfig: CookieInterceptorConfig): void {
if (installed) {
return;
}
installed = true;
config = initialConfig;
originalCookieDescriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie') ?? null;
if (!originalCookieDescriptor || !originalCookieDescriptor.set || !originalCookieDescriptor.get) {
// 환경 미지원 — 폴백 없이 종료 (jsdom 일부 버전).
installed = false;
return;
}
const originalSet = originalCookieDescriptor.set;
const originalGet = originalCookieDescriptor.get;
Object.defineProperty(Document.prototype, 'cookie', {
configurable: true,
get(this: Document): string {
return originalGet.call(this);
},
set(this: Document, value: string) {
if (!isCookieAllowed(value)) {
return;
}
originalSet.call(this, value);
},
});
}
/**
* 인터셉터 설정을 갱신합니다.
*
* @param newConfig 갱신할 설정
* @return void
*/
export function updateCookieInterceptorConfig(newConfig: CookieInterceptorConfig): void {
config = newConfig;
}
/**
* 인터셉터를 해제합니다 (테스트 / cleanup).
*
* @return void
*/
export function uninstallCookieInterceptor(): void {
if (!installed) {
return;
}
if (originalCookieDescriptor) {
Object.defineProperty(Document.prototype, 'cookie', originalCookieDescriptor);
}
originalCookieDescriptor = null;
config = {
functionalConsented: false,
necessaryAllowlist: LOCKED_FALLBACK,
};
installed = false;
}