diff --git a/plugins/vite-plugin-public-bundle-guard.ts b/plugins/vite-plugin-public-bundle-guard.ts index 02d4567..5f8d9fe 100644 --- a/plugins/vite-plugin-public-bundle-guard.ts +++ b/plugins/vite-plugin-public-bundle-guard.ts @@ -71,3 +71,35 @@ export function assertPublicBundleClean(outDir: string): void { ); } } + +// ─── 모든 배포 공통: 서버 전용 비밀값이 번들에 들어갔는지 ───────────────────────────── +// 이름에 KEY·SECRET·TOKEN·PASSWORD·SERVICE_ROLE 이 들어간 환경변수 값을 서버 전용으로 본다. +// 공개를 전제로 만든 값만 예외로 둔다. 값 자체를 대조하므로 키 형식이 바뀌어도 잡는다. +const SECRET_NAME = /(KEY|SECRET|TOKEN|PASSWORD|SERVICE_ROLE)/; +const PUBLIC_BY_DESIGN = new Set(['VITE_SUPABASE_ANON_KEY']); + +/** 위반한 환경변수 이름 목록을 돌려준다. 값은 출력하지 않는다. */ +export function findSecretValueLeaks(outDir: string, env: Record): string[] { + const secrets = Object.entries(env).filter( + ([name, value]) => SECRET_NAME.test(name) && !PUBLIC_BY_DESIGN.has(name) && typeof value === 'string' && value.length >= 16, + ); + if (!secrets.length) return []; + const leaks = new Set(); + for (const file of listFiles(outDir)) { + if (!TEXT_EXT.has(path.extname(file).toLowerCase())) continue; + const text = fs.readFileSync(path.join(outDir, file), 'utf8'); + for (const [name, value] of secrets) if (text.includes(value)) leaks.add(`${name} (${file})`); + } + return [...leaks]; +} + +/** 서버 전용 비밀값이 번들에 있으면 빌드를 멈춘다. 모든 배포(webforai.kr·infinith-demo)에 건다. */ +export function assertNoSecretValues(outDir: string, env: Record): void { + const leaks = findSecretValueLeaks(outDir, env); + if (leaks.length) { + throw new Error( + `번들 비밀값 검사 실패 (${leaks.length}건). 서버 전용 키가 브라우저 번들에 들어갔습니다. VITE_ 접두사나 vite.config define 을 확인하세요.\n` + + leaks.map((v) => ` - ${v}`).join('\n'), + ); + } +} diff --git a/src/pages/ApiDashboardPage.tsx b/src/pages/ApiDashboardPage.tsx index 5921a5e..2b69811 100644 --- a/src/pages/ApiDashboardPage.tsx +++ b/src/pages/ApiDashboardPage.tsx @@ -147,7 +147,7 @@ const API_REGISTRY: ApiConfig[] = [ name: 'Google Gemini', description: 'AI 이미지 생성 + Vision 분석 (스크린샷 OCR)', envKeys: ['GEMINI_API_KEY'], - layer: 'both', + layer: 'edge-function', docsUrl: 'https://ai.google.dev/docs', IconComponent: DatabaseFilled, category: 'ai', @@ -161,8 +161,8 @@ const API_REGISTRY: ApiConfig[] = [ id: 'creatomate', name: 'Creatomate', description: '마케팅 영상 자동 생성 (템플릿 기반)', - envKeys: ['VITE_CREATOMATE_API_KEY'], - layer: 'frontend', + envKeys: ['CREATOMATE_API_KEY'], + layer: 'edge-function', docsUrl: 'https://creatomate.com/docs/api/introduction', IconComponent: VideoFilled, category: 'ai', @@ -176,12 +176,19 @@ const API_REGISTRY: ApiConfig[] = [ /* ────────────────── Environment Variable Checker ─────────────── */ +// 브라우저에서 확인할 수 있는 값은 공개를 전제로 만든 변수뿐이다. import.meta.env 를 이름으로 동적 조회하면 +// Vite 가 VITE_ 변수 전체를 번들에 넣으므로(2026-10-07 VITE_CREATOMATE_API_KEY 노출 원인), 여기 적은 것만 읽는다. +const PUBLIC_ENV: Record = { + VITE_SUPABASE_URL: import.meta.env.VITE_SUPABASE_URL, + VITE_SUPABASE_ANON_KEY: import.meta.env.VITE_SUPABASE_ANON_KEY, +}; + function checkEnvVars(): ApiStatusInfo[] { return API_REGISTRY.map((api) => { const keyPresent: Record = {}; for (const key of api.envKeys) { if (key.startsWith('VITE_')) { - const val = (import.meta.env as Record)[key]; + const val = PUBLIC_ENV[key]; keyPresent[key] = !!val && val.length > 0; } else { keyPresent[key] = true; @@ -269,12 +276,12 @@ function EnvKeyRow({ envKey, present, isServerSide }: { key?: string; envKey: st const value = isServerSide ? '(Edge Function 환경변수 — 프론트엔드에서 확인 불가)' : showKey - ? (import.meta.env as Record)[envKey] || '' + ? PUBLIC_ENV[envKey] || '' : '••••••••••••••••'; const handleCopy = () => { if (!isServerSide) { - navigator.clipboard.writeText((import.meta.env as Record)[envKey] || ''); + navigator.clipboard.writeText(PUBLIC_ENV[envKey] || ''); setCopied(true); setTimeout(() => setCopied(false), 1500); } diff --git a/vite.config.ts b/vite.config.ts index 8dd9b6e..1d1374d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,7 +4,7 @@ import fs from 'fs'; import path from 'path'; import {defineConfig, loadEnv} from 'vite'; import { supabaseSync } from './plugins/vite-plugin-supabase-sync'; -import { assertPublicBundleClean } from './plugins/vite-plugin-public-bundle-guard'; +import { assertNoSecretValues, assertPublicBundleClean } from './plugins/vite-plugin-public-bundle-guard'; export default defineConfig(({mode}) => { const env = loadEnv(mode, '.', ''); @@ -73,8 +73,10 @@ export default defineConfig(({mode}) => { name: 'infinith-root-favicon', apply: 'build' as const, writeBundle(options: {dir?: string}) { - if (!isDiscovery) return; const out = options.dir ?? 'dist'; + // 모든 배포 공통: 서버 전용 키 값이 번들에 있으면 빌드를 멈춘다. + assertNoSecretValues(out, env); + if (!isDiscovery) return; // public/ 은 infinith-demo 와 공용이라 실제 병원 리포트 원본(reports/)과 병원 채널 스크린샷(assets/clients/)이 들어 있다. // 공개 배포(webforai.kr)에서는 링크가 없어도 주소만 알면 열리므로 빌드 결과에서 지운다 (2026-10-07). for (const dir of ['reports', path.join('assets', 'clients')]) { @@ -108,10 +110,7 @@ export default defineConfig(({mode}) => { serviceRoleKey: env.SUPABASE_SERVICE_ROLE_KEY ?? env.VITE_SUPABASE_ANON_KEY ?? '', }), ], - define: { - // 공개 배포(webforai.kr)에는 이미지 생성 기능이 없으므로 키를 번들에 넣지 않는다. 브라우저로 내려간 값은 공개다. - 'process.env.GEMINI_API_KEY': JSON.stringify(isDiscovery ? '' : env.GEMINI_API_KEY), - }, + // 서버 전용 키(Gemini·Creatomate 등)는 define·VITE_ 접두사로 번들에 넣지 않는다. 호출은 api/*.js 서버 함수에서 한다 (2026-10-07). build: { rollupOptions: { treeshake: {