fix(security): 보안 검토 지적 일괄 조치 (쿼터 RPC 권한·open redirect·XSS·크리덴셜 로깅) · chrisryugj/lexdiff@f15d400 · GitHub
Skip to content

Commit f15d400

Browse files
chrisryugjclaude
andcommitted
fix(security): 보안 검토 지적 일괄 조치 (쿼터 RPC 권한·open redirect·XSS·크리덴셜 로깅)
인증·권한 - supabase/013: 쿼터 RPC(increment/decrement_quota) EXECUTE 를 anon·authenticated 에서 회수. REVOKE 가 없어 anon 키만으로 PostgREST rpc 직접 호출이 가능했고, decrement 반복 호출로 사전차감을 무한 환불(쿼터 우회)할 수 있었다. - 신규 가입 admin 자동승격 제거(이메일 문자열 일치 기반) → free 고정, 수동 UPDATE. - auth/callback: next 파라미터를 내부 경로로 제한 (open redirect, CWE-601). - fc-rag: 대화 스토어 키를 userId 로 스코프 (conversationId 만으로 접근되던 IDOR). 크리덴셜·로그 - debug-logger: 프로덕션에서 error/warning 만 콘솔 출력 + URL 의 OC·key 마스킹. 법제처 OC 가 포함된 완성 URL 이 Vercel 로그에 평문 기록되고 있었다 (CWE-532). - fc-rag 에러·트레이스 기록에 sanitizeCredentials 적용 (BYOK 키 누출 차단). - scripts: 하드코딩된 법제처 OC 를 process.env.LAW_OC 로 이전. XSS·주입 - virtualized-full-article-view: 조문 본문에 sanitizeForRender 적용. 같은 파일의 전문·부칙 경로는 정화하는데 조문만 건너뛰고 있었다. 캐시 적재 전 1회만 정화. - annex-pdf: filename 에서 제어문자 제거 (CRLF 응답 헤더 주입, CWE-113). - preEvidence 를 델리미터로 격리 + 펜스 무력화 (프롬프트 인젝션). 노출·남용 - ai-feedback: 무인증 쓰기에 IP 기준 분당 5회 제한 + getClientIP 사용. - relation-graph/stats·privacy/consent: 내부 에러 메시지 클라이언트 노출 제거. - 공개 프록시(precedent/interpretation/tax-tribunal/search-all) 입력 상한·포맷 검증. - RELAY_TOKEN 필수화 — 미설정 시 릴레이 스킵하고 Gemini 폴백. - article-title: 내부 origin 폴백을 고정 base URL env 우선으로 (Host 헤더 SSRF). - log-viewer: 127.0.0.1 바인딩 + CORS 와일드카드 제거. 의존성·잔재 - protobufjs override(>=7.5.5)·vitest 4.1 — CVSS critical 2건 해소 (audit critical 0). - 커밋돼 있던 잔재 제거: Windows 에이전트 로그, .backup_*, 기관 프록시가 발급한 *.law.go.kr 위조 체인 인증서(코드 미참조). CSP nonce 는 기본 on 으로 바꿨다가 되돌렸다 — 프로덕션 빌드 실측에서 정적 프리렌더 HTML 의 script 36개 중 nonce 부착이 0개였고, strict-dynamic 이 전 스크립트를 차단한다. 전 페이지 동적 렌더링 전환이 선행돼야 해서 opt-in 유지하고 이유를 주석에 남김. 검증: 755 테스트 · 빌드 · 린트 통과. sanitize 회귀는 법령·조례·표 3케이스로 태그·속성·가시 텍스트 무손실 실측. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 79b36ea commit f15d400

33 files changed

Lines changed: 252 additions & 601 deletions

File tree

‎%programdata%/Geni/Insights/logs/cmon.cmd.exe.log‎

Lines changed: 0 additions & 31 deletions
This file was deleted.
-2.07 KB
Binary file not shown.

‎.backup_20251102224016/logs/dev.log)‎

Lines changed: 0 additions & 1 deletion
This file was deleted.

‎.backup_20251102224016/patch-repeat.js‎

Lines changed: 0 additions & 11 deletions
This file was deleted.

‎.backup_20251102224016/ref.json‎

Lines changed: 0 additions & 279 deletions
This file was deleted.

‎.backup_20251102224016/run-dev-persistent.bat‎

Lines changed: 0 additions & 71 deletions
This file was deleted.

‎__tests__/middleware-csp.test.ts‎

Lines changed: 4 additions & 3 deletions

‎app/api/ai-feedback/route.ts‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,39 @@ import { NextResponse, type NextRequest } from "next/server"
1111
import { createSupabaseServiceClient } from "@/lib/supabase/server"
1212
import { sessionAnonHash, classifyUa } from "@/lib/ai-telemetry"
1313
import { debugLogger } from "@/lib/debug-logger"
14+
import { getClientIP } from "@/lib/get-client-ip"
1415

1516
const VALID_TYPES = new Set(["good", "bad", "improve"])
1617
const MAX_BODY_LEN = 8000
1718

19+
// 무인증 쓰기 엔드포인트 남용 방지: IP 기준 분당 5회 (search-suggest 패턴)
20+
const RATE_LIMIT_WINDOW_MS = 60_000
21+
const RATE_LIMIT_MAX = 5
22+
const rateLimitMap = new Map<string, number[]>()
23+
function checkRateLimit(ip: string): boolean {
24+
const now = Date.now()
25+
const recent = (rateLimitMap.get(ip) || []).filter((t) => now - t < RATE_LIMIT_WINDOW_MS)
26+
if (recent.length >= RATE_LIMIT_MAX) {
27+
rateLimitMap.set(ip, recent)
28+
return false
29+
}
30+
recent.push(now)
31+
rateLimitMap.set(ip, recent)
32+
if (rateLimitMap.size > 1000) {
33+
const entries = Array.from(rateLimitMap.entries())
34+
entries.sort((a, b) => Math.max(...a[1]) - Math.max(...b[1]))
35+
for (const [k] of entries.slice(0, 500)) rateLimitMap.delete(k)
36+
}
37+
return true
38+
}
39+
1840
export async function POST(request: NextRequest) {
1941
try {
42+
const ip = getClientIP(request)
43+
if (!checkRateLimit(ip || "unknown")) {
44+
return NextResponse.json({ error: "rate limit exceeded" }, { status: 429 })
45+
}
46+
2047
const body = await request.json().catch(() => ({}))
2148
const feedbackType = String(body?.feedbackType || "")
2249

@@ -30,7 +57,6 @@ export async function POST(request: NextRequest) {
3057
keepBody && typeof v === "string" && v.trim() ? v.slice(0, MAX_BODY_LEN) : null
3158

3259
const ua = request.headers.get("user-agent")
33-
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || null
3460

3561
const svc = createSupabaseServiceClient()
3662
await svc.from("ai_answer_feedback").insert({
@@ -39,7 +65,7 @@ export async function POST(request: NextRequest) {
3965
query_type: typeof body?.queryType === "string" ? body.queryType.slice(0, 32) : null,
4066
answer_id: typeof body?.answerId === "string" ? body.answerId.slice(0, 64) : null,
4167
conversation_id: typeof body?.conversationId === "string" ? body.conversationId.slice(0, 64) : null,
42-
session_anon: sessionAnonHash(null, ip),
68+
session_anon: sessionAnonHash(null, ip === "127.0.0.1" || ip === "anonymous" ? null : ip),
4369
is_byok: Boolean(body?.isByok),
4470
ua_class: classifyUa(ua),
4571
query: clip(body?.query),

‎app/api/annex-pdf/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export async function GET(request: Request) {
112112

113113
// 파일명: 클라이언트 지정 > 기본값
114114
let filename = customFilename
115-
? customFilename.replace(/[\\/:*?"<>|]/g, "") // 금지 문자 제거
115+
? customFilename.replace(/[\x00-\x1f\x7f\\/:*?"<>|]/g, "") // 금지 문자 + 제어문자(CRLF 헤더 주입) 제거
116116
: `annex-${flSeq}`
117117

118118
// 확장자가 없으면 추가

‎app/api/article-title/route.ts‎

Lines changed: 4 additions & 1 deletion

0 commit comments

Comments
 (0)