허용목록이 플러그인 코드 상수라, 새 확장이 저장 키를 도입할 때마다 이 플러그인을 고쳐야 했다. 고치지 않으면 그 키는 방문마다 파기되는데 예외도 로그도 남지 않아 운영자에게는 "설정이 저장되지 않는다" 는 증상만 보인다. 허용목록을 운영자 설정(necessary_storage_allowlist)으로 옮기고, 관리자 환경설정에 브라우저 저장소·세션 저장소·쿠키 세 카드를 두어 직접 편집하게 했다. 확장은 GDPR 을 알 필요가 없고 GDPR 도 확장을 알 필요가 없다 — 연결은 운영자가 화면에서 한다. 저장 키를 플러그인이 관측·수집하지 않는 것도 같은 이유다: 관측 자체가 추적이다. 판정은 서버(미들웨어)와 클라이언트 셋(인터셉터 둘 + 정리기)이 같은 설정을 같은 매칭 규칙으로 읽는다. 잠금 항목(로그인 토큰·CSRF·세션 쿠키·동의 기록)만 설정 밖에 두고 판정 시점에 합집합으로 얹는다 — 설정에 담기면 저장 요청 한 번으로 지워져 잠금이 아니게 된다. 기설치본은 업그레이드 스텝이 현재 카탈로그를 설정 파일에 못박되, 운영자가 이미 편집했으면 그대로 둔다. 함께 고친 선재 결함 셋: - 차단 도메인 추천 목록이 응답에 실리지 않아 자동완성이 동작하지 않던 문제 - 세션 쿠키 이름을 기본값에서 바꾼 사이트에서 브라우저 측 목록의 그 항목이 죽던 문제 - 쿠키 목록에만 앞부분 매칭(`이름_*`)이 적용되지 않던 문제
203 lines
6.3 KiB
TypeScript
203 lines
6.3 KiB
TypeScript
/**
|
||
* 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;
|
||
} |