마이크 한 트랙에 여러 사람이 섞여 들어오는 대면 회의에서 화자를 나눈다. pyannote-segmentation-3.0 + 3D-Speaker CAM++ 를 ONNX Runtime으로 로컬 Next.js 프로세스에서 실행한다. GPU도 파이썬도 필요 없고 오디오가 외부로 나가지 않는다. 실측 (Apple Silicon, 2스레드, 4인 한국어 아닌 샘플 57초): - 처리 1.5초 → RTF 0.03, 실시간의 33배 - 자동 추정은 화자 5명(과다 계수), 참석자 수를 주면 4명(정확) → 참석자 수 입력을 1급 기능으로 노출 구성: - lib/diarization.ts — sherpa-onnx-node 래퍼, 모델 캐시, 참석자 수 힌트 - lib/audio-session.ts — 녹음 중 PCM 조각을 세션별로 누적 (메모리) - api/diarize/chunk — 4초짜리 Int16 PCM 조각 업로드 - api/diarize — 누적 오디오 전체 분석, GET은 모델 설치 여부 확인 - public/pcm-worklet.js — 16kHz Int16 PCM 추출 AudioWorklet - hooks/useDiarization.ts — 수집·분석 상태 관리 - transcript-formatter: assignSpeakers() — 시간 겹침으로 화자 배정. 겹치는 구간이 없으면 라벨을 비운다 (틀린 라벨보다 없는 편이 낫다) - LiveRecorder: 사용 안 함 / 원격 회의 / 대면 회의 3모드 + 참석자 수 - scripts/setup-diarization.mjs — 모델 34MB 내려받기 (npm run setup:diarization) 설계 변경: 롤링 재-diarization 대신 녹음 종료 시 1회 전체 분석. 구간을 잘라 반복하면 실행마다 화자 번호가 달라져 이어 붙일 수 없고, 누적분 재분석은 비용이 회의 길이에 비례해 커진다. 대신 실시간 화자 표시는 없다. 모델은 라이선스와 용량 때문에 저장소에 넣지 않고 gitignore 한다. 테스트 176 → 184. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 KiB
🔒 Security
이 문서는 Meeting Minutes의 위협 모델, 알려진 한계, 안전한 사용 방법을 정리합니다. 보안 취약점을 발견했다면 신고 방법을 참고해주세요.
📑 목차
🎯 위협 모델
이 프로젝트는 개인용 단일 인스턴스를 가정합니다. 위협 모델은 다음과 같습니다:
In-scope (방어 대상)
- 같은 LAN/Wifi에 있는 다른 사용자가 사용자의 노트북에 접근하는 시나리오
- 공유 PC에서 잠시 자리를 비웠을 때 다른 사람이 데이터에 접근하는 시나리오
- 마크다운에 삽입된 악성 HTML/스크립트로 인한 XSS
- 클라이언트가 보낸 비정상 입력 (큰 텍스트, 잘못된 MIME 등)
Out-of-scope (방어하지 않음)
- 다중 사용자 / 인증 — 단일 사용자 가정으로 인증 시스템 미구현
- 호스트 OS / 디스크 암호화 — 운영체제와 디스크 암호화 설정에 위임
- 클라우드 환경 배포 — localhost 전용으로 설계됨
- 물리적 접근 통제 — 사용자 PC에 대한 물리적 보안
🔐 신뢰 모델
| 신뢰 수준 | 항목 |
|---|---|
| 신뢰 | 호스트 OS, Docker, 사용자가 발급한 Gemini API 키, npm 패키지 (marked, DOMPurify, Prisma 등) |
| 반신뢰 | 사용자 입력 transcript / markdown (DOMPurify로 sanitize) |
| 신뢰 안 함 | 같은 네트워크의 다른 장치, 외부 HTTP 요청, Web Speech API 결과 |
📡 데이터 흐름과 외부 송출
| 데이터 | 저장 위치 | 외부 송출 |
|---|---|---|
| 회의 transcript / 회의록 | PostgreSQL (meetings.markdownMinutes, rawTranscript) |
AI 요약 활성화 시 transcript가 선택한 프로바이더로 전송 |
| 액션 아이템 | PostgreSQL (action_items) |
외부 송출 없음 |
| API 키 | 브라우저 LocalStorage 또는 .env |
요청 시 선택한 프로바이더에만 전송 |
| 음성 데이터 | 메모리 (실시간), /app/uploads (파일 업로드) |
Chrome Web Speech API → Google 서버 ⚠️ |
| 화자 분리용 PCM | 서버 프로세스 메모리 (녹음 중에만) | 외부 송출 없음 — 로컬에서 분석하고 분석 후 즉시 폐기 |
⚠️ 주의: Web Speech API의 음성 외부 전송
Chrome의 SpeechRecognition API는 음성 데이터를 Google 서버로 전송하여 처리합니다. 이는 Chrome 자체의 동작이며 우리 서버를 경유하지 않습니다. 다음의 경우 사용을 재고하세요:
- 회의가 회사 기밀, 인사 정보, 환자 정보 등을 포함
- 사내 컴플라이언스 정책이 외부 클라우드 음성 전송을 금지
- GDPR / HIPAA 등 규제 환경
이 경우 파일 업로드 탭도 같은 한계가 있으므로(현재 업로드 후 전사는 미구현, Phase 4에서 자체 STT 검토 예정), 이 도구의 사용을 보류하는 것을 권장합니다.
⚠️ 주의: AI 프로바이더 선택에 따른 전송 경로
/settings에서 고른 프로바이더에 따라 회의 전문이 지나가는 회사가 달라집니다.
| 선택 | transcript를 보게 되는 주체 |
|---|---|
| Google Gemini (기본) | |
| OpenAI | OpenAI |
| OrcaRouter 등 중계 라우터 | 라우터 운영사 + 라우터가 고른 실제 모델 제공사 (2단계) |
| 로컬 모델 (Ollama / LM Studio / vLLM) | 없음 — 요청이 내 머신 밖으로 나가지 않음 |
- 중계 라우터는 요청을 대신 전달하는 구조상 평문 프롬프트를 볼 수 있는 주체가 한 곳 늘어납니다. 로깅·보관 정책은 각 서비스 약관을 직접 확인하세요.
- 사내 컴플라이언스가 외부 전송을 제한한다면 로컬 모델 프리셋을 사용하세요.
화자 분리 오디오는 로컬에만 머뭅니다
대면 회의 화자 분리는 pyannote-segmentation-3.0 + CAM++ ONNX 모델을 로컬
Next.js 프로세스에서 실행합니다. 외부 API를 호출하지 않으므로 오디오가 네트워크로
나가지 않습니다.
- 녹음 중 PCM 조각이 서버 프로세스 메모리에 쌓입니다 (디스크에 쓰지 않음)
- 분석이 끝나면 즉시 폐기하며, 6시간 지난 세션은 자동 정리됩니다
- 세션 최대 길이는 3시간으로 제한됩니다
- 단일 사용자 로컬 실행 전제입니다. 여러 사용자가 붙는 환경이라면 세션 ID를 추측해 다른 사람의 오디오에 접근할 수 있으므로, 노출 배포 시 인증이 선행되어야 합니다
전사 텍스트는 여전히 Web Speech API를 거치므로 Google로 갑니다. 화자 분리만 로컬이라는 점에 유의하세요.
base URL은 서버가 대신 호출합니다 (SSRF 주의)
OpenAI 호환 프리셋의 base URL은 브라우저가 아니라 Next.js Route Handler(서버) 가 fetch 합니다.
http/https스킴만 허용합니다 (normalizeBaseUrl).- 그 외 호스트 제한은 없습니다. 이 앱은
127.0.0.1단일 사용자 실행을 전제로 하므로 의도된 설계이지만, 앱을 LAN이나 인터넷에 노출하면 요청자가 서버 내부망 주소를 base URL로 넣어 스캔할 수 있습니다. - 노출 배포가 필요하다면 리버스 프록시에서 egress를 제한하거나,
LLM_BASE_URL을 환경변수로 고정하고 요청 body의baseUrl을 무시하도록 수정하세요.
✅ 구현된 보안 조치
인프라
- Postgres 포트 미노출 —
db컨테이너는app만 내부 네트워크로 접근, 호스트 포트 안 열림 - App 포트 localhost 바인딩 —
127.0.0.1:3000(LAN의 다른 장치는 접근 불가) - DB 비밀번호 fallback 제거 —
.env에서 명시적으로 설정해야 docker-compose 시작됨 - Prisma adapter 사용 — Prisma 7
@prisma/adapter-pg로 안전한 PostgreSQL 연결
응답 헤더
next.config.ts에서 다음 헤더 적용:
X-Content-Type-Options: nosniff— MIME sniffing 차단X-Frame-Options: DENY— 클릭재킹 방지Referrer-Policy: strict-origin-when-cross-originPermissions-Policy: camera=(), microphone=(self), geolocation=(), interest-cohort=()
입력 검증
- 마크다운:
marked→isomorphic-dompurify통과USE_PROFILES: { html: true }FORBID_TAGS: ['style'],FORBID_ATTR: ['style']<script>,onerror=,javascript:URL 등 제거
- /api/summarize: transcript 100,000자 상한, 초과 시 413
- /api/summarize-live: transcript 40,000자 상한 (뒤쪽만 사용)
- 파일 업로드: 500MB 상한, MIME allowlist (단, 한계 참고)
API 키 보호
/api/settings/status: 키 존재 여부만 응답 (값 노출 X)/api/settings/test: 401/403/429 분류, 업스트림 응답 본문을 그대로 돌려주지 않음- 키는 GET 요청에 절대 포함 안 함 (body 전송만)
maskApiKey(): UI에 표시 시AIza••••XYZ12형태로 마스킹- Gemini는
x-goog-api-key헤더, OpenAI 호환은Authorization: Bearer헤더 — 키를 URL에 넣지 않음 (로그/리퍼러 유출 방지)
⚠️ 알려진 한계
MEDIUM — 인증 부재
현황: /api/* 엔드포인트에 인증이 없습니다. 127.0.0.1:3000 바인딩으로 LAN 노출은 차단되지만, 같은 머신의 다른 사용자나 잠금 해제된 PC에서는 접근 가능합니다.
완화:
- 1인 1머신 + 잠금 해제 시 PC를 떠나지 않음
- 추가 보호가 필요하면 reverse proxy(nginx, Caddy)에 Basic Auth 추가
- 회사 환경에서는 SSO 통합이 필요하지만 현재 비목표
MEDIUM — 파일 업로드 매직 바이트 미검증
현황: audio-validation.ts는 file.type(클라이언트 제공)과 파일명 확장자만 검사. 매직 바이트 검증 없음.
완화: 업로드된 파일은 현재 어디에도 서빙되지 않으며 Phase 4까지 활용되지 않음. 디스크에 저장만 됨.
MEDIUM — 서버 측 레이트 리미팅 부재
현황: 클라이언트 측에 백오프/쿨다운은 있으나 서버는 IP당 요청 수 제한 없음.
완화:
- localhost 바인딩으로 외부 abuse 차단
- Gemini 호출은 사용자 본인 키로 본인 quota 소진 → 본인 비용
LOW — npm audit moderate 6건
현황:
@hono/node-server,hono,postcss(모두 dev 의존성)- 프로덕션 번들 미포함, 실제 공격 가능성 낮음
완화: 정기적으로 npm audit fix 실행
LOW — LocalStorage 키 노출
현황: 브라우저 개발자 도구로 localStorage.getItem('meeting-minutes:gemini-api-key') 실행 시 키 노출.
완화: 동일 출처 정책으로 다른 사이트에서 접근 불가. 사용 후 🗑️ 삭제 버튼으로 즉시 제거.
🚀 배포 권장 사항
✅ 권장 (개인용)
# 강력한 비밀번호로 .env 작성
echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
# 로컬 머신에서만 접근
docker compose up -d
# → 127.0.0.1:3000 (LAN 노출 X)
⚠️ 비권장 (현재 미구현 보안 통제 필요)
- 클라우드 인스턴스(EC2, GCP 등) 배포
- 회사 LAN의 공용 서버 배포
- HTTPS 없이 외부 도메인에 노출
🔧 추가 보호 (선택)
Reverse proxy + Basic Auth (예: Caddy)
meeting.example.com {
basic_auth {
user $2a$14$... # bcrypt 해시
}
reverse_proxy 127.0.0.1:3000
}
VPN 뒤에서만 접근: 회사 VPN/Tailscale 등 + 호스트 IP 제한
디스크 암호화: macOS FileVault, Linux LUKS, Windows BitLocker
🚨 취약점 신고
보안 취약점을 발견하면:
- 공개 이슈 X — GitHub Issues에 공개로 올리지 마세요
- 메인테이너에게 직접 연락 또는 GitHub Security Advisory 사용
- 다음을 포함:
- 영향받는 버전 / 컴포넌트
- 재현 방법
- 영향 범위
- 가능한 완화책
응답 시간 목표: 72시간 이내 1차 응답.
📚 참고
최종 업데이트: 2026-04-28