From 93eb5dd96d058dcd5d30b31a409df979e091b431 Mon Sep 17 00:00:00 2001 From: zenn Date: Wed, 29 Jul 2026 17:34:43 +0900 Subject: [PATCH] =?UTF-8?q?CSP=20=EC=B0=A8=EB=8B=A8=20=EC=B6=9C=EC=B2=98?= =?UTF-8?q?=20=EB=B6=84=EB=A5=98=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog.md | 4 ++++ docs/deploy.md | 8 ++++---- docs/history.md | 4 ++++ docs/spec.md | 2 +- docs/update.md | 5 +++++ lib/csp-report.js | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- tests/csp-report.test.js | 5 +++++ 9 files changed, 28 insertions(+), 10 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 4b5d96f..aa1aba2 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,9 @@ # 업데이트 요약 +## v1.12.11 + +- CSP 관찰 표에서 빈 차단 출처와 브라우저 확장 프로그램 계열을 더 구분하기 쉽게 정리했다. + ## v1.12.10 - CSP 관찰 표에서 강제 정책 허용 후보를 함께 볼 수 있게 했다. diff --git a/docs/deploy.md b/docs/deploy.md index 2164205..1b8b8c2 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,7 +1,7 @@ # 배포 가이드 -> v1.12.10은 Google 로그인/계정 연동, 통계 콘텐츠 세션 원장, CSP 관찰 표와 허용 후보 표시를 포함한다. 운영 배포 전 `068_csp_violation_aggregates.sql`, `069_media_catalog.sql`, `070_post_import_jobs.sql`, `071_users_google_oauth.sql`, `072_analytics_content_sessions.sql`을 순서대로 적용하고 미디어 카탈로그는 reconcile 완료 후 `MEDIA_CATALOG_MODE=database` 고정 순서를 지킨다. 게시물 Export·Import·일반 업로드는 디스크·임시 디렉터리 스트리밍을 사용하므로 NAS 앱 컨테이너의 임시 저장 공간과 `public/uploads/exports`, `public/uploads/_import-jobs` 쓰기 권한을 확인한다. -> 로컬 기준 v1.12.10에서 `npm run lint`, `npm test`, `npm run build` 검증을 통과했다. +> v1.12.11은 Google 로그인/계정 연동, 통계 콘텐츠 세션 원장, CSP 관찰 표와 허용 후보 표시를 포함한다. 운영 배포 전 `068_csp_violation_aggregates.sql`, `069_media_catalog.sql`, `070_post_import_jobs.sql`, `071_users_google_oauth.sql`, `072_analytics_content_sessions.sql`을 순서대로 적용하고 미디어 카탈로그는 reconcile 완료 후 `MEDIA_CATALOG_MODE=database` 고정 순서를 지킨다. 게시물 Export·Import·일반 업로드는 디스크·임시 디렉터리 스트리밍을 사용하므로 NAS 앱 컨테이너의 임시 저장 공간과 `public/uploads/exports`, `public/uploads/_import-jobs` 쓰기 권한을 확인한다. +> 로컬 기준 v1.12.11에서 `npm run lint`, `npm test`, `npm run build` 검증을 통과했다. ## 빌드 유형 @@ -14,7 +14,7 @@ > `npm run dev`는 프로젝트 전용 실행 스크립트를 통해 개발 서버, Admin, Tailwind Viewer 링크만 요약 출력한다. -## v1.12.10 배포 전 검증 +## v1.12.11 배포 전 검증 Playwright API 테스트도 브라우저 바이너리는 필요하지 않지만 `verify:ci`에는 Chromium E2E가 포함된다. 최초 1회 의존성과 Chromium을 설치하고 Docker daemon이 실행 중인지 확인한다. @@ -54,7 +54,7 @@ sh scripts/migrate-production-db.sh status v1.11.0은 `base-uri`, `object-src`, `frame-ancestors`만 강제한다. 광고 코드, 사용자 사이트 코드, 외부 이미지·미디어·iframe에 영향을 줄 수 있는 출처 정책은 계속 Report-Only이므로 기존 광고와 사용자 코드 실행을 차단하지 않는다. 전체 HTML 문서 `/pages/**`는 강제 CSP에서 제외하고 Report-Only 보고만 유지한다. -배포 후 관리자 대시보드의 `CSP 관찰` 표 또는 `csp_violation_aggregates`의 `policy_version='compat-v1'`, `effective_directive`, `blocked_origin`, `document_path`, `user_agent_family`, `violation_count`, `last_seen_at`을 관찰한다. URL 쿼리·fragment·차단 자산 경로와 User-Agent 원문은 저장되지 않는다. `허용 후보`는 지시어별 외부 origin을 빠르게 묶어 보여주는 보조 정보일 뿐이므로, 반복 위반이 광고·사이트 코드의 정상 출처인지 확인한 뒤에만 강제 정책 확대를 별도 배포한다. +배포 후 관리자 대시보드의 `CSP 관찰` 표 또는 `csp_violation_aggregates`의 `policy_version='compat-v1'`, `effective_directive`, `blocked_origin`, `document_path`, `user_agent_family`, `violation_count`, `last_seen_at`을 관찰한다. URL 쿼리·fragment·차단 자산 경로와 User-Agent 원문은 저장되지 않는다. `empty`, `about:`, 브라우저 확장 프로그램 scheme은 실제 외부 origin 허용 후보가 아니라 브라우저·확장 프로그램·빈 iframe 계열 신호로 본다. `허용 후보`는 지시어별 외부 origin을 빠르게 묶어 보여주는 보조 정보일 뿐이므로, 반복 위반이 광고·사이트 코드의 정상 출처인지 확인한 뒤에만 강제 정책 확대를 별도 배포한다. --- diff --git a/docs/history.md b/docs/history.md index a5d1b17..91e1a7a 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,3 +1,7 @@ +## 2026-07-29 v1.12.11 — CSP unknown은 가능한 한 구분해서 관찰한다 + +`frame-src unknown`처럼 출처가 비어 보이는 보고는 실제 외부 서비스 차단인지, 빈 iframe·`about:blank`·브라우저 확장 프로그램이 만든 보고인지 구분하기 어렵다. Report-Only 관찰 초반에는 이런 분류가 허용 목록 판단의 잡음을 크게 만들 수 있으므로, 빈 blocked-uri는 `empty`, `about:blank` 계열은 `about:`, 확장 프로그램 URL은 브라우저별 extension scheme으로 따로 집계한다. 이 값들은 외부 origin 허용 후보가 아니라 원인 추적용 신호로만 본다. + ## 2026-07-29 v1.12.10 — CSP 허용 후보는 자동 적용이 아니라 검토 보조로 둔다 Report-Only 위반 집계에서 반복되는 외부 origin은 강제 CSP로 넘어갈 때 허용 목록 후보가 될 수 있지만, 모든 위반이 곧바로 정상 출처라는 뜻은 아니다. 따라서 관리자 대시보드는 `script-src-elem` 같은 세부 지시어를 실제 정책 지시어로 묶고 `http(s)` origin만 허용 후보로 표시하되, 정책 문자열을 자동 생성하거나 적용하지 않는다. 운영자는 후보를 보고 광고·사용자 코드·외부 미디어의 정상 동작 여부를 확인한 뒤 별도 배포에서 강제 정책을 단계적으로 넓힌다. diff --git a/docs/spec.md b/docs/spec.md index 1f29a5e..9f7b57d 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -67,7 +67,7 @@ - 강제 CSP는 `base-uri 'self'`, `object-src 'none'`, `frame-ancestors 'self'`만 적용한다. 전체 HTML 문서의 자체 ``·자산 구성을 보존해야 하는 `/pages/**` 응답은 강제 CSP에서 제외한다. - 호환 정책은 모든 경로에서 `Content-Security-Policy-Report-Only`로 유지한다. 사용자 사이트 코드·광고·외부 미디어를 차단하지 않은 채 script/style/img/font/media/frame/connect/worker/manifest 위반을 `/api/csp-report`로 보고하며 `Reporting-Endpoints`와 `Report-To`도 같은 origin 수집 주소를 가리킨다. - `POST /api/csp-report`는 `application/csp-report`, `application/reports+json`, `application/json`, `application/*+json`만 허용하고 요청 본문은 32KB, Reporting API 배열은 최대 20건으로 제한한다. IP 원문을 보관하지 않는 해시 키 기준으로 분당 60회까지만 받는다. -- CSP 위반은 정책 버전, 쿼리·fragment를 제거한 문서 경로, 지시어, 경로·쿼리를 제거한 차단 origin, 브라우저 계열만 `csp_violation_aggregates`에 누적한다. 원문 URL과 User-Agent는 저장하지 않으며 저장 실패가 일반 응답을 실패시키지 않는다. +- CSP 위반은 정책 버전, 쿼리·fragment를 제거한 문서 경로, 지시어, 경로·쿼리를 제거한 차단 origin, 브라우저 계열만 `csp_violation_aggregates`에 누적한다. 원문 URL과 User-Agent는 저장하지 않으며 저장 실패가 일반 응답을 실패시키지 않는다. 차단 출처가 비어 있으면 `empty`, `about:blank` 계열은 `about:`, 브라우저 확장 프로그램은 `chrome-extension:`·`moz-extension:`·`safari-web-extension:` 같은 scheme 단위로 분류한다. - 관리자 대시보드는 CSP Report-Only 위반 집계가 1건 이상 있을 때만 `CSP 관찰` 표를 표시한다. 표는 최근 30일 기준 상위 위반의 지시어, 차단 출처, 문서 경로, 브라우저 계열, 횟수, 최근 관찰 시각을 보여준다. `script-src-elem`, `style-src-attr`, `child-src` 같은 세부 지시어는 각각 `script-src`, `style-src`, `frame-src` 후보로 묶고, `http(s)` origin만 허용 후보로 별도 표시한다. ### 홈 Featured (인덱스) diff --git a/docs/update.md b/docs/update.md index 1628d9e..8ec5035 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 이력 +## v1.12.11 + +- CSP Report-Only 차단 출처 정규화에서 빈 값과 특수 scheme을 `unknown`과 분리. +- `about:`, 브라우저 확장 프로그램 scheme, 빈 blocked-uri 분류 테스트 추가. + ## v1.12.10 - CSP 관찰 요약에 지시어별 외부 origin 허용 후보 계산 추가. diff --git a/lib/csp-report.js b/lib/csp-report.js index ef7093a..42dba82 100644 --- a/lib/csp-report.js +++ b/lib/csp-report.js @@ -71,14 +71,14 @@ export const normalizeCspBlockedOrigin = (value) => { const normalized = String(value || '').trim().toLowerCase() if (!normalized) { - return 'unknown' + return 'empty' } if (['inline', 'eval', 'self'].includes(normalized)) { return normalized } - if (/^(data|blob|filesystem):/.test(normalized)) { + if (/^(about|data|blob|filesystem|chrome-extension|moz-extension|safari-web-extension|ms-browser-extension):/.test(normalized)) { return `${normalized.split(':', 1)[0]}:` } diff --git a/package-lock.json b/package-lock.json index 87bad0d..35a2429 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.12.10", + "version": "1.12.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.12.10", + "version": "1.12.11", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index fd951b3..c624e3c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.12.10", + "version": "1.12.11", "private": true, "type": "module", "imports": { diff --git a/tests/csp-report.test.js b/tests/csp-report.test.js index aaa2c64..073c527 100644 --- a/tests/csp-report.test.js +++ b/tests/csp-report.test.js @@ -32,6 +32,11 @@ test('CSP 보고 정규화는 실행 토큰과 브라우저 계열만 보존한 assert.equal(normalizeCspDocumentPath('/settings?email=user@example.com'), '/settings') assert.equal(normalizeCspBlockedOrigin('inline'), 'inline') assert.equal(normalizeCspBlockedOrigin('data:text/javascript,secret'), 'data:') + assert.equal(normalizeCspBlockedOrigin(''), 'empty') + assert.equal(normalizeCspBlockedOrigin('about:blank'), 'about:') + assert.equal(normalizeCspBlockedOrigin('chrome-extension://abcdef/private.html'), 'chrome-extension:') + assert.equal(normalizeCspBlockedOrigin('moz-extension://abcdef/private.html'), 'moz-extension:') + assert.equal(normalizeCspBlockedOrigin('safari-web-extension://abcdef/private.html'), 'safari-web-extension:') assert.equal(classifyCspUserAgentFamily('Mozilla/5.0 Version/18.0 Safari/605.1.15'), 'Safari') })