fix: 서버 전용 키가 브라우저 번들에 들어가지 않게 한다
- vite.config define 의 GEMINI_API_KEY 를 지운다 - API 대시보드의 import.meta.env 동적 조회를 공개 변수 목록으로 바꾼다. 동적 조회 때문에 VITE_ 변수 전체(VITE_CREATOMATE_API_KEY 포함)가 번들에 들어갔다 - 모든 배포 빌드 끝에 서버 전용 키 값이 번들에 있으면 빌드를 실패시킨다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0b729759c3
commit
e20a90dc35
@ -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, string>): 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<string>();
|
||||||
|
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<string, string>): 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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -147,7 +147,7 @@ const API_REGISTRY: ApiConfig[] = [
|
|||||||
name: 'Google Gemini',
|
name: 'Google Gemini',
|
||||||
description: 'AI 이미지 생성 + Vision 분석 (스크린샷 OCR)',
|
description: 'AI 이미지 생성 + Vision 분석 (스크린샷 OCR)',
|
||||||
envKeys: ['GEMINI_API_KEY'],
|
envKeys: ['GEMINI_API_KEY'],
|
||||||
layer: 'both',
|
layer: 'edge-function',
|
||||||
docsUrl: 'https://ai.google.dev/docs',
|
docsUrl: 'https://ai.google.dev/docs',
|
||||||
IconComponent: DatabaseFilled,
|
IconComponent: DatabaseFilled,
|
||||||
category: 'ai',
|
category: 'ai',
|
||||||
@ -161,8 +161,8 @@ const API_REGISTRY: ApiConfig[] = [
|
|||||||
id: 'creatomate',
|
id: 'creatomate',
|
||||||
name: 'Creatomate',
|
name: 'Creatomate',
|
||||||
description: '마케팅 영상 자동 생성 (템플릿 기반)',
|
description: '마케팅 영상 자동 생성 (템플릿 기반)',
|
||||||
envKeys: ['VITE_CREATOMATE_API_KEY'],
|
envKeys: ['CREATOMATE_API_KEY'],
|
||||||
layer: 'frontend',
|
layer: 'edge-function',
|
||||||
docsUrl: 'https://creatomate.com/docs/api/introduction',
|
docsUrl: 'https://creatomate.com/docs/api/introduction',
|
||||||
IconComponent: VideoFilled,
|
IconComponent: VideoFilled,
|
||||||
category: 'ai',
|
category: 'ai',
|
||||||
@ -176,12 +176,19 @@ const API_REGISTRY: ApiConfig[] = [
|
|||||||
|
|
||||||
/* ────────────────── Environment Variable Checker ─────────────── */
|
/* ────────────────── Environment Variable Checker ─────────────── */
|
||||||
|
|
||||||
|
// 브라우저에서 확인할 수 있는 값은 공개를 전제로 만든 변수뿐이다. import.meta.env 를 이름으로 동적 조회하면
|
||||||
|
// Vite 가 VITE_ 변수 전체를 번들에 넣으므로(2026-10-07 VITE_CREATOMATE_API_KEY 노출 원인), 여기 적은 것만 읽는다.
|
||||||
|
const PUBLIC_ENV: Record<string, string | undefined> = {
|
||||||
|
VITE_SUPABASE_URL: import.meta.env.VITE_SUPABASE_URL,
|
||||||
|
VITE_SUPABASE_ANON_KEY: import.meta.env.VITE_SUPABASE_ANON_KEY,
|
||||||
|
};
|
||||||
|
|
||||||
function checkEnvVars(): ApiStatusInfo[] {
|
function checkEnvVars(): ApiStatusInfo[] {
|
||||||
return API_REGISTRY.map((api) => {
|
return API_REGISTRY.map((api) => {
|
||||||
const keyPresent: Record<string, boolean> = {};
|
const keyPresent: Record<string, boolean> = {};
|
||||||
for (const key of api.envKeys) {
|
for (const key of api.envKeys) {
|
||||||
if (key.startsWith('VITE_')) {
|
if (key.startsWith('VITE_')) {
|
||||||
const val = (import.meta.env as Record<string, string | undefined>)[key];
|
const val = PUBLIC_ENV[key];
|
||||||
keyPresent[key] = !!val && val.length > 0;
|
keyPresent[key] = !!val && val.length > 0;
|
||||||
} else {
|
} else {
|
||||||
keyPresent[key] = true;
|
keyPresent[key] = true;
|
||||||
@ -269,12 +276,12 @@ function EnvKeyRow({ envKey, present, isServerSide }: { key?: string; envKey: st
|
|||||||
const value = isServerSide
|
const value = isServerSide
|
||||||
? '(Edge Function 환경변수 — 프론트엔드에서 확인 불가)'
|
? '(Edge Function 환경변수 — 프론트엔드에서 확인 불가)'
|
||||||
: showKey
|
: showKey
|
||||||
? (import.meta.env as Record<string, string | undefined>)[envKey] || ''
|
? PUBLIC_ENV[envKey] || ''
|
||||||
: '••••••••••••••••';
|
: '••••••••••••••••';
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
if (!isServerSide) {
|
if (!isServerSide) {
|
||||||
navigator.clipboard.writeText((import.meta.env as Record<string, string | undefined>)[envKey] || '');
|
navigator.clipboard.writeText(PUBLIC_ENV[envKey] || '');
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 1500);
|
setTimeout(() => setCopied(false), 1500);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import {defineConfig, loadEnv} from 'vite';
|
import {defineConfig, loadEnv} from 'vite';
|
||||||
import { supabaseSync } from './plugins/vite-plugin-supabase-sync';
|
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}) => {
|
export default defineConfig(({mode}) => {
|
||||||
const env = loadEnv(mode, '.', '');
|
const env = loadEnv(mode, '.', '');
|
||||||
@ -73,8 +73,10 @@ export default defineConfig(({mode}) => {
|
|||||||
name: 'infinith-root-favicon',
|
name: 'infinith-root-favicon',
|
||||||
apply: 'build' as const,
|
apply: 'build' as const,
|
||||||
writeBundle(options: {dir?: string}) {
|
writeBundle(options: {dir?: string}) {
|
||||||
if (!isDiscovery) return;
|
|
||||||
const out = options.dir ?? 'dist';
|
const out = options.dir ?? 'dist';
|
||||||
|
// 모든 배포 공통: 서버 전용 키 값이 번들에 있으면 빌드를 멈춘다.
|
||||||
|
assertNoSecretValues(out, env);
|
||||||
|
if (!isDiscovery) return;
|
||||||
// public/ 은 infinith-demo 와 공용이라 실제 병원 리포트 원본(reports/)과 병원 채널 스크린샷(assets/clients/)이 들어 있다.
|
// public/ 은 infinith-demo 와 공용이라 실제 병원 리포트 원본(reports/)과 병원 채널 스크린샷(assets/clients/)이 들어 있다.
|
||||||
// 공개 배포(webforai.kr)에서는 링크가 없어도 주소만 알면 열리므로 빌드 결과에서 지운다 (2026-10-07).
|
// 공개 배포(webforai.kr)에서는 링크가 없어도 주소만 알면 열리므로 빌드 결과에서 지운다 (2026-10-07).
|
||||||
for (const dir of ['reports', path.join('assets', 'clients')]) {
|
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 ?? '',
|
serviceRoleKey: env.SUPABASE_SERVICE_ROLE_KEY ?? env.VITE_SUPABASE_ANON_KEY ?? '',
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
define: {
|
// 서버 전용 키(Gemini·Creatomate 등)는 define·VITE_ 접두사로 번들에 넣지 않는다. 호출은 api/*.js 서버 함수에서 한다 (2026-10-07).
|
||||||
// 공개 배포(webforai.kr)에는 이미지 생성 기능이 없으므로 키를 번들에 넣지 않는다. 브라우저로 내려간 값은 공개다.
|
|
||||||
'process.env.GEMINI_API_KEY': JSON.stringify(isDiscovery ? '' : env.GEMINI_API_KEY),
|
|
||||||
},
|
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
treeshake: {
|
treeshake: {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user