diff --git a/docs/changelog.md b/docs/changelog.md index b037163..7afa8bb 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,9 @@ # 업데이트 요약 +## v1.12.9 + +- 관리자 대시보드에서 CSP Report-Only 위반 집계를 확인할 수 있게 했다. + ## v1.12.3 - Google 로그인 연동 해제가 DB 제약 조건 오류 없이 완료되도록 수정했다. diff --git a/docs/deploy.md b/docs/deploy.md index e5533de..b266a15 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,7 +1,7 @@ # 배포 가이드 -> v1.12.8은 Google 로그인/계정 연동과 통계 콘텐츠 세션 원장을 포함한다. 운영 배포 전 `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.8에서 `npm run lint`, `npm test`, `npm run build` 검증을 통과했다. +> v1.12.9는 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.9에서 `npm run lint`, `npm test`, `npm run build` 검증을 통과했다. ## 빌드 유형 @@ -14,7 +14,7 @@ > `npm run dev`는 프로젝트 전용 실행 스크립트를 통해 개발 서버, Admin, Tailwind Viewer 링크만 요약 출력한다. -## v1.12.8 배포 전 검증 +## v1.12.9 배포 전 검증 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_violation_aggregates`의 `policy_version='compat-v1'`, `effective_directive`, `blocked_origin`, `document_path`, `user_agent_family`, `violation_count`, `last_seen_at`을 관찰한다. URL 쿼리·fragment·차단 자산 경로와 User-Agent 원문은 저장되지 않는다. 반복 위반이 광고·사이트 코드의 정상 출처인지 확인한 뒤에만 강제 정책 확대를 별도 배포한다. +배포 후 관리자 대시보드의 `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 원문은 저장되지 않는다. 반복 위반이 광고·사이트 코드의 정상 출처인지 확인한 뒤에만 강제 정책 확대를 별도 배포한다. --- diff --git a/docs/history.md b/docs/history.md index e9623f1..52a5f80 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,3 +1,7 @@ +## 2026-07-29 v1.12.9 — CSP 강제 전환 전 관찰 경로를 관리자 화면에 둔다 + +CSP 호환 정책은 광고 코드와 사용자가 작성한 사이트 코드, 외부 이미지·iframe 출처에 직접 영향을 줄 수 있으므로 운영 위반 보고를 충분히 본 뒤에만 강제 정책으로 확대해야 한다. 기존에는 `csp_violation_aggregates`를 DB에서 직접 확인해야 해서 반복 관찰 비용이 컸다. 따라서 관리자 대시보드에 최근 30일 위반 상위 항목을 표시해 정상 출처와 반복 오탐을 운영 화면에서 먼저 분류하고, 실제 차단 정책은 별도 배포에서 단계적으로 강화한다. + ## 2026-07-29 v1.12.8 — 콘텐츠 세션 원장은 32일 보관으로 확정한다 콘텐츠 세션 원장은 최근 기간의 평균 체류, 스크롤, 읽음 중복 제거를 보정하기 위한 상세 기록이다. 일별 누적 집계는 별도로 계속 보존하므로 원장까지 장기 보관하지 않아도 누적 방문자·조회수·과거 차트의 기본 값은 유지된다. 따라서 방문자 해시 보관 정책과 동일하게 32일만 유지해 개인정보성 식별 해시 보관 범위를 짧게 유지하고, 최근 운영 판단에 필요한 세부 참여 지표만 원장 기준으로 보정한다. diff --git a/docs/map.md b/docs/map.md index 9636584..7700c72 100644 --- a/docs/map.md +++ b/docs/map.md @@ -244,7 +244,7 @@ | 파일 | 용도 | |------|------| -| server/repositories/csp-report-repository.js | 민감 원문 없이 정규화된 CSP 위반 차원을 PostgreSQL에 누적 집계 | +| server/repositories/csp-report-repository.js | 민감 원문 없이 정규화된 CSP 위반 차원을 PostgreSQL에 누적 집계하고 관리자 관찰 요약 조회 | | server/repositories/media-catalog-repository.js | 미디어 파일·참조 dual-write, DB SQL 목록, 준비 상태 검사, 파일 비파괴 reconcile·missing 표시 | | server/repositories/content-repository.js | 게시물·페이지·태그·사이트 설정 저장 후 미디어 참조 최선 노력 동기화 | @@ -549,6 +549,7 @@ | server/routes/admin/api/analytics/realtime.get.js | 관리자 실시간 접속자 API | | server/routes/admin/api/analytics/traffic.get.js | 관리자 유입원·디바이스·키워드 통계 API | | server/routes/admin/api/analytics/range-detail.get.js | 관리자 통계 추이 날짜·구간 상세 API | +| server/routes/admin/api/security/csp-violations.get.js | 관리자 대시보드 CSP Report-Only 위반 관찰 요약 API | | server/routes/admin/api/adsense/summary.get.js | 관리자 대시보드 AdSense 예상 수익·페이지뷰·노출·클릭 요약 API | | plugins/site-analytics.client.js | 공개 라우트 pageview·heartbeat·read 클라이언트 전송 | | plugins/bfcache-page-restore.client.js | 뒤로가기 캐시(bfcache) 복원 시 라우터 경로와 실제 URL 어긋남을 감지해 새로고침(HTML 문서 모드 페이지 이동 후 뒤로가기 잔상 방지) | diff --git a/docs/spec.md b/docs/spec.md index 8247965..b474fb2 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -68,6 +68,7 @@ - 호환 정책은 모든 경로에서 `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 Report-Only 위반 집계가 1건 이상 있을 때만 `CSP 관찰` 표를 표시한다. 표는 최근 30일 기준 상위 위반의 지시어, 차단 출처, 문서 경로, 브라우저 계열, 횟수, 최근 관찰 시각을 보여준다. ### 홈 Featured (인덱스) @@ -683,6 +684,7 @@ components/content/ - `GET /admin/api/analytics/realtime?limit=20` - 현재 접속자 요약·목록(로그인 사용자 닉네임·아바타, IP, 열린 페이지 수와 열린 페이지 목록 포함) - `GET /admin/api/analytics/traffic?days=30` - 기간 내 유입원·디바이스·유입 키워드 통계 - `GET /admin/api/analytics/range-detail?startDay=YYYY-MM-DD&endDay=YYYY-MM-DD&limit=5` - 통계 추이 막대 상세(요약·유입·디바이스·인기 게시물·인기 페이지). 인기 콘텐츠는 조회·읽음/방문자·최근 30일 조회·작성일·스크롤 도달 지표를 포함한다. 최대 31일 범위로 제한한다. +- `GET /admin/api/security/csp-violations?days=30&limit=8` - CSP Report-Only 위반 집계 관찰 요약. `days`는 1~90일, `limit`은 1~20개로 제한하며 전체 누적 횟수, 최근 기간 횟수, 마지막 관찰 시각, 상위 위반 목록을 반환한다. - `GET /admin/api/analytics/posts/:id?period=daily&days=14` - 게시물별 통계(누적·기간 요약, 일간/주간/월간 조회·방문자 추이, 게시물별 유입·디바이스·키워드) - `GET /admin/api/analytics/pages/:id?period=daily&days=14` - 페이지별 통계(누적·기간 요약, 일간/주간/월간 조회·방문자 추이, 체류·스크롤 구간) - `GET /admin/api/posts` - 글 목록. 기본은 본문 없는 배열 응답이며, `paged=true&page=1&pageSize=50&q=검색어&status=published&tag=slug&featured=featured&sort=newest|oldest` 쿼리를 보내면 서버 필터링된 `{ items, pagination, counts }` 페이지 응답을 반환한다. diff --git a/docs/todo.md b/docs/todo.md index 4df7e3a..2fd6af2 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -7,7 +7,7 @@ ## 프론트엔드 개발 - CSP Report-Only 운영 보고 관찰 후 강제 정책 추가 강화 검토. - - 광고·사용자 사이트 코드·외부 미디어의 정상 출처를 `csp_violation_aggregates`에서 충분히 확인. + - 광고·사용자 사이트 코드·외부 미디어의 정상 출처를 관리자 대시보드 `CSP 관찰` 표와 `csp_violation_aggregates`에서 충분히 확인. - 정상 출처 허용 목록과 반복 오탐을 정리한 뒤 script/style/img/frame/connect 지시어를 단계별 강제 전환. ## 콘텐츠 스타일 구현 diff --git a/docs/update.md b/docs/update.md index 0199fa2..22cb0ec 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v1.12.9 + +- 관리자 대시보드에 CSP Report-Only 위반 관찰 표 추가. +- CSP 위반 집계 조회용 관리자 API 추가. +- CSP 관찰 TODO를 운영 보고 확인 단계로 좁히고 관련 문서 갱신. + ## v1.12.8 - 콘텐츠 세션 원장 보관 기간을 방문자 해시와 동일한 32일 유지로 확정. diff --git a/package-lock.json b/package-lock.json index 81855c1..817620a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.12.8", + "version": "1.12.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.12.8", + "version": "1.12.9", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 42af622..90d4124 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.12.8", + "version": "1.12.9", "private": true, "type": "module", "imports": { diff --git a/pages/admin/index.vue b/pages/admin/index.vue index 241b2d9..96f1dee 100644 --- a/pages/admin/index.vue +++ b/pages/admin/index.vue @@ -94,6 +94,22 @@ const { }) }) +const { + data: cspViolationSummary, + refresh: refreshCspViolationSummary +} = await useFetch('/admin/api/security/csp-violations', { + query: { days: 30, limit: 8 }, + server: false, + default: () => ({ + days: 30, + totalViolations: 0, + totalRows: 0, + recentViolations: 0, + latestSeenAt: null, + topViolations: [] + }) +}) + const todayKey = getAnalyticsDayKey() const publishedCount = computed(() => posts.value.filter((post) => post.status === 'published').length) const draftCount = computed(() => posts.value.filter((post) => post.status === 'draft').length) @@ -186,6 +202,8 @@ const chartPagination = computed(() => { const adsenseCards = computed(() => adsenseSummary.value.periods || []) const adsenseBalance = computed(() => adsenseSummary.value.balance || createEmptyAdsenseBalance()) +const cspViolations = computed(() => cspViolationSummary.value?.topViolations || []) +const hasCspViolations = computed(() => Number(cspViolationSummary.value?.totalViolations || 0) > 0) /** * AdSense 통화 코드를 표시 가능한 값으로 보정한다. @@ -246,6 +264,29 @@ const formatAdsenseRpm = (value, currencyCode = '') => { }).format(Number(value || 0)) } +/** + * ISO 날짜를 관리자 대시보드용 시각으로 변환한다. + * @param {string | null | undefined} value - ISO 날짜 + * @returns {string} 표시 문자열 + */ +const formatDashboardDateTime = (value) => { + if (!value) { + return '-' + } + + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return '-' + } + + return new Intl.DateTimeFormat('ko-KR', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }).format(date) +} + /** * 통계 차트 이전 기간으로 이동한다. * @returns {void} @@ -622,6 +663,7 @@ onMounted(() => { refreshSummary() refreshTrendDetail() refreshRealtime() + refreshCspViolationSummary() }, 30000) }) @@ -734,6 +776,92 @@ watch(trendRows, () => { +
+
+
+

+ CSP 관찰 +

+

+ 최근 {{ cspViolationSummary.days }}일 Report-Only 위반 상위 항목 +

+
+
+

+ 최근 {{ formatNumber(cspViolationSummary.recentViolations) }}회 +

+

+ 전체 {{ formatNumber(cspViolationSummary.totalViolations) }}회 · 마지막 {{ formatDashboardDateTime(cspViolationSummary.latestSeenAt) }} +

+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ 지시어 + + 차단 출처 + + 문서 + + 브라우저 + + 횟수 + + 최근 +
+ {{ row.effectiveDirective }} + + {{ row.blockedOrigin }} + + {{ row.documentPath }} + + {{ row.userAgentFamily }} + + {{ formatNumber(row.violationCount) }} + + {{ formatDashboardDateTime(row.lastSeenAt) }} +
+
+

+ 최근 {{ cspViolationSummary.days }}일에 집계된 CSP 위반은 없습니다. +

+
+
diff --git a/server/repositories/csp-report-repository.js b/server/repositories/csp-report-repository.js index 6f4f973..7fa2b1f 100644 --- a/server/repositories/csp-report-repository.js +++ b/server/repositories/csp-report-repository.js @@ -48,3 +48,77 @@ export const recordCspReports = async (reports) => { } }) } + +/** + * CSP 위반 집계 행을 관리자 표시용 값으로 변환한다. + * @param {Object} row - DB 집계 행 + * @returns {Object} 관리자 표시용 집계 행 + */ +const normalizeCspViolationRow = (row) => { + return { + policyVersion: row.policy_version || '', + documentPath: row.document_path || '/', + effectiveDirective: row.effective_directive || 'unknown', + blockedOrigin: row.blocked_origin || 'unknown', + userAgentFamily: row.user_agent_family || 'Other', + violationCount: Number(row.violation_count || 0), + firstSeenAt: row.first_seen_at || null, + lastSeenAt: row.last_seen_at || null + } +} + +/** + * 관리자 CSP 관찰용 최근 위반 집계 요약을 조회한다. + * @param {{ days?: number, limit?: number }} [options] - 조회 옵션 + * @returns {Promise<{ days: number, totalViolations: number, totalRows: number, recentViolations: number, latestSeenAt: string | null, topViolations: Array }>} CSP 위반 요약 + */ +export const getCspViolationSummary = async (options = {}) => { + const sql = getPostgresClient() + const days = Math.min(Math.max(Number(options.days || 30), 1), 90) + const limit = Math.min(Math.max(Number(options.limit || 8), 1), 20) + + if (!sql) { + return { + days, + totalViolations: 0, + totalRows: 0, + recentViolations: 0, + latestSeenAt: null, + topViolations: [] + } + } + + const [summary] = await sql` + SELECT + COALESCE(SUM(violation_count), 0)::bigint AS total_violations, + COUNT(*)::bigint AS total_rows, + COALESCE(SUM(violation_count) FILTER (WHERE last_seen_at >= now() - (${days}::int * interval '1 day')), 0)::bigint AS recent_violations, + MAX(last_seen_at) AS latest_seen_at + FROM csp_violation_aggregates + ` + + const topRows = await sql` + SELECT + policy_version, + document_path, + effective_directive, + blocked_origin, + user_agent_family, + violation_count, + first_seen_at, + last_seen_at + FROM csp_violation_aggregates + WHERE last_seen_at >= now() - (${days}::int * interval '1 day') + ORDER BY violation_count DESC, last_seen_at DESC + LIMIT ${limit} + ` + + return { + days, + totalViolations: Number(summary?.total_violations || 0), + totalRows: Number(summary?.total_rows || 0), + recentViolations: Number(summary?.recent_violations || 0), + latestSeenAt: summary?.latest_seen_at || null, + topViolations: topRows.map(normalizeCspViolationRow) + } +} diff --git a/server/routes/admin/api/security/csp-violations.get.js b/server/routes/admin/api/security/csp-violations.get.js new file mode 100644 index 0000000..59a5ba0 --- /dev/null +++ b/server/routes/admin/api/security/csp-violations.get.js @@ -0,0 +1,17 @@ +import { requireAdminSession } from '../../../../utils/admin-auth.js' +import { getCspViolationSummary } from '../../../../repositories/csp-report-repository.js' + +/** + * 관리자 CSP 위반 관찰 요약 API + * @param {import('h3').H3Event} event - 요청 이벤트 + * @returns {Promise} CSP 위반 집계 요약 + */ +export default defineEventHandler(async (event) => { + requireAdminSession(event) + + const query = getQuery(event) + return getCspViolationSummary({ + days: Number(query.days) || 30, + limit: Number(query.limit) || 8 + }) +})