diff --git a/app/api/analyze/route.ts b/app/api/analyze/route.ts index df43729..f5ce745 100644 --- a/app/api/analyze/route.ts +++ b/app/api/analyze/route.ts @@ -2,7 +2,12 @@ import { NextResponse } from "next/server"; import { analyzePublicTargetWithMeta } from "@/lib/analysis/analyzer"; import { createAnalysisError, toErrorPayload, toErrorStatus } from "@/lib/analysis/errors"; import { buildAnalyzeTargetMeta } from "@/lib/analysis/policy"; -import type { AnalyzeTargetResponse } from "@/lib/analysis/types"; +import { + checkRateLimit, + extractClientIp, + RATE_LIMIT_POLICY, +} from "@/lib/analysis/rate-limit"; +import type { AnalyzeRepoErrorPayload, AnalyzeTargetResponse } from "@/lib/analysis/types"; import { validateAnalyzeRepoRequest } from "@/lib/analysis/validators"; export const runtime = "nodejs"; @@ -12,6 +17,22 @@ const BASE_RESPONSE_HEADERS = { Vary: "Origin", }; +// 본문 크기 상한. 실제 유효한 요청은 repoUrl 하나라 수백 byte 수준. +// 큰 JSON body 반복 파싱을 막아 저비용 DoS 표면을 줄인다. +const MAX_REQUEST_BODY_BYTES = 4 * 1024; // 4 KiB +// repoUrl 자체의 최대 길이. GitHub URL은 현실적으로 400자를 넘지 않는다. +const MAX_REPO_URL_CHARS = 512; + +// 프론트/상태 패널이 소비하는 안전 필드만 클라이언트로 내려보낸다. +// GitHub 내부 path, upstream status, 기타 운영 힌트는 서버 로그에만 남기고 +// 사용자 응답에는 포함하지 않는다. +const PUBLIC_DETAIL_FIELDS: ReadonlyArray = [ + "retryAfterSeconds", + "resetAt", + "authenticated", + "githubAuthMode", +]; + function assertSameOriginBrowserRequest(request: Request) { const origin = request.headers.get("origin"); const fetchSite = request.headers.get("sec-fetch-site"); @@ -41,9 +62,80 @@ function assertSameOriginBrowserRequest(request: Request) { } } +function assertRequestBodySize(request: Request) { + const contentLength = request.headers.get("content-length"); + if (contentLength) { + const n = Number(contentLength); + if (Number.isFinite(n) && n > MAX_REQUEST_BODY_BYTES) { + throw createAnalysisError( + "INVALID_REQUEST", + "요청 본문이 너무 큽니다." + ); + } + } +} + +function assertRepoUrlLength(raw: unknown) { + if (typeof raw !== "object" || raw === null) return; + const url = (raw as { repoUrl?: unknown }).repoUrl; + if (typeof url === "string" && url.length > MAX_REPO_URL_CHARS) { + throw createAnalysisError( + "INVALID_URL", + "레포 URL이 너무 깁니다." + ); + } +} + +// 운영 중 외부에 내려갈 에러 payload를 축약한다. +// - details는 프론트 상태 패널이 소비하는 안전 필드만 whitelist로 남긴다. +// - 에러 message는 Analysis계층이 만든 카피를 그대로 쓴다(이미 사용자용 한국어 copy). +// - 예기치 않은 Error(스택/내부 경로 힌트 포함 가능)는 generic 메시지로 치환. +function sanitizeErrorPayload( + error: unknown, + payload: AnalyzeRepoErrorPayload +): AnalyzeRepoErrorPayload { + const details = payload.details; + let publicDetails: Record | undefined; + if (details) { + const filtered: Record = {}; + for (const key of PUBLIC_DETAIL_FIELDS) { + if (Object.prototype.hasOwnProperty.call(details, key)) { + filtered[key] = details[key]; + } + } + if (Object.keys(filtered).length > 0) publicDetails = filtered; + } + + const isKnownAnalysisError = + payload.code !== "ANALYSIS_FAILED" || !(error instanceof Error); + + return { + code: payload.code, + message: isKnownAnalysisError + ? payload.message + : "분석 중 알 수 없는 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.", + retryable: payload.retryable, + ...(publicDetails ? { details: publicDetails } : {}), + }; +} + +function rateLimitError( + bucket: "general" | "forceRefresh", + retryAfterSeconds: number +) { + return createAnalysisError( + "RATE_LIMITED", + bucket === "forceRefresh" + ? "강제 새로고침은 일정 시간당 2회까지만 가능합니다." + : "분석 요청이 일시적으로 제한되었습니다. 잠시 후 다시 시도해 주세요.", + { retryAfterSeconds, authenticated: true } + ); +} + export async function POST(request: Request) { try { assertSameOriginBrowserRequest(request); + assertRequestBodySize(request); let rawBody: unknown; @@ -52,7 +144,24 @@ export async function POST(request: Request) { } catch { throw createAnalysisError("INVALID_REQUEST", "요청 형식이 올바르지 않습니다."); } + assertRepoUrlLength(rawBody); const body = validateAnalyzeRepoRequest(rawBody); + + // Rate limit. + // forceRefresh=true는 더 타이트한 2번째 버킷을 함께 소모한다. forceRefresh는 + // 서버 캐시를 우회해 실제 GitHub fetch를 강제하므로 남용 비용이 크다. + const ip = extractClientIp(request); + const general = checkRateLimit(ip, RATE_LIMIT_POLICY.general); + if (!general.allowed) { + throw rateLimitError("general", general.retryAfterSeconds); + } + if (body.forceRefresh === true) { + const forceRefresh = checkRateLimit(ip, RATE_LIMIT_POLICY.forceRefresh); + if (!forceRefresh.allowed) { + throw rateLimitError("forceRefresh", forceRefresh.retryAfterSeconds); + } + } + const result = await analyzePublicTargetWithMeta(body.repoUrl, { forceRefresh: body.forceRefresh === true, }); @@ -65,13 +174,24 @@ export async function POST(request: Request) { headers: BASE_RESPONSE_HEADERS, }); } catch (error) { - const payload = toErrorPayload(error); + const rawPayload = toErrorPayload(error); + const payload = sanitizeErrorPayload(error, rawPayload); const status = toErrorStatus(error); const retryAfterSeconds = typeof payload.details?.retryAfterSeconds === "number" ? payload.details.retryAfterSeconds : null; + // 서버 로그에는 원본 에러 + 원본 details를 그대로 남겨 운영 관찰이 가능하게 한다. + // 외부로 응답할 때만 `sanitizeErrorPayload`로 축약. + if (status >= 500 || !payload.retryable) { + console.warn("[analyze] error", { + code: rawPayload.code, + status, + message: error instanceof Error ? error.message : String(error), + }); + } + return NextResponse.json( { ok: false, diff --git a/components/owner-workspace.tsx b/components/owner-workspace.tsx index 02c3aa9..b777779 100644 --- a/components/owner-workspace.tsx +++ b/components/owner-workspace.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { useRef } from "react"; +import { safeExternalHref } from "@/components/safe-href"; import { ThemeToggle } from "@/components/theme-toggle"; import type { OwnerAnalysis, @@ -411,17 +412,23 @@ function HeroFeaturedCard({ repo }: { repo: OwnerRepositorySummary }) { GitHub - {repo.homepage ? ( - - - 사이트 - - ) : null} + {/* homepage는 레포 소유자가 자유롭게 설정해서 javascript:/data: 같은 + 스킴이 들어올 수 있으므로 safeExternalHref로 한 번 더 검증. */} + {(() => { + const homepage = safeExternalHref(repo.homepage ?? null); + if (!homepage) return null; + return ( + + + 사이트 + + ); + })()} ); diff --git a/components/result-learning-panel.tsx b/components/result-learning-panel.tsx index 1cf7eb5..5eb5c4b 100644 --- a/components/result-learning-panel.tsx +++ b/components/result-learning-panel.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef, useState, type ReactNode } from "react"; +import { safeExternalHref, safeImageSrc } from "@/components/safe-href"; import { StackBadge } from "@/components/stack-badge"; import type { RepoAnalysis, @@ -586,21 +587,29 @@ function PreviewImageGallery({ images }: { images: RepoPreviewImage[] }) { // Beginner-first: first image takes a hero slot (full width, tall), remaining // fill a 2-col grid at a readable but compact size. object-contain prevents // cropping so screenshots read as "whole product", not a thumbnail. - const [hero, ...rest] = images.slice(0, 6); + // URL은 레포 README에서 왔으므로 사용자 컨텐츠. 위험 스킴은 safeImageSrc가 차단. + const safeImages = images + .map((image) => ({ + ...image, + safeHref: safeExternalHref(image.url), + safeSrc: safeImageSrc(image.url), + })) + .filter((image) => image.safeHref && image.safeSrc); + const [hero, ...rest] = safeImages.slice(0, 6); if (!hero) return null; return (
{/* eslint-disable-next-line @next/next/no-img-element */} {hero.alt {/* eslint-disable-next-line @next/next/no-img-element */} {image.alt = { }; function LinkChip({ link }: { link: RepoReadmeLink }) { + // 공격자가 README에 javascript:/data: 스킴 링크를 넣어도 href가 렌더되지 + // 않도록 safeExternalHref로 필터. 위험 스킴이면 비클릭 텍스트로 대체. + const href = safeExternalHref(link.url); + if (!href) { + return ( + + + {LINK_KIND_LABEL[link.kind]} + + {link.label} + + ); + } return ( {LINK_KIND_LABEL[link.kind]} diff --git a/components/safe-href.ts b/components/safe-href.ts new file mode 100644 index 0000000..38408d1 --- /dev/null +++ b/components/safe-href.ts @@ -0,0 +1,43 @@ +// 사용자(레포 소유자) 컨텐츠에서 뽑아온 URL을 `` / ``에 +// 그대로 꽂기 전에 통과시키는 안전 필터. +// +// 막고자 하는 것: +// - javascript: / data: / vbscript: / file: 같은 위험 스킴. +// - URL 파싱 실패(빈 문자열, 공백 등). +// +// 통과시키는 것: +// - https: / http: / mailto: +// +// 프로토콜이 위험하면 null을 리턴하고, 호출부는 링크를 아예 렌더하지 않거나 +// 텍스트로만 표시하도록 처리한다. + +const SAFE_LINK_PROTOCOLS = new Set(["https:", "http:", "mailto:"]); + +export function safeExternalHref(raw: string | null | undefined): string | null { + if (!raw) return null; + const trimmed = raw.trim(); + if (!trimmed) return null; + // mailto:는 URL 파서가 받아주지만 //가 없어서 href로만 유효. + // 그 외엔 https/http만 허용. + try { + const parsed = new URL(trimmed); + return SAFE_LINK_PROTOCOLS.has(parsed.protocol) ? trimmed : null; + } catch { + return null; + } +} + +// 이미지 소스 전용. mailto는 무의미하므로 https/http만 허용. +const SAFE_IMAGE_PROTOCOLS = new Set(["https:", "http:"]); + +export function safeImageSrc(raw: string | null | undefined): string | null { + if (!raw) return null; + const trimmed = raw.trim(); + if (!trimmed) return null; + try { + const parsed = new URL(trimmed); + return SAFE_IMAGE_PROTOCOLS.has(parsed.protocol) ? trimmed : null; + } catch { + return null; + } +} diff --git a/lib/analysis/rate-limit.ts b/lib/analysis/rate-limit.ts new file mode 100644 index 0000000..258e166 --- /dev/null +++ b/lib/analysis/rate-limit.ts @@ -0,0 +1,137 @@ +// 간단한 IP 기반 토큰버킷 스타일 슬라이딩 윈도우 rate limiter. +// +// 설계 목표 +// - 1차 방어선. 정교한 분산 limit은 Upstash/KV로 올리면 되지만, +// 현재 트래픽 규모에선 in-memory만으로도 개별 IP의 bot 연타를 크게 줄여준다. +// - Vercel serverless 특성상 인스턴스마다 메모리가 분리된다. 같은 IP가 다른 +// function 인스턴스에 번갈아 떨어지면 정확한 글로벌 카운트는 아니지만, +// bursting 억제에는 여전히 유효하다. +// - key prefix로 "general", "forceRefresh" 2개 버킷을 운용해 +// forceRefresh(캐시 우회) 요청을 더 타이트하게 막는다. +// - 메모리 누수를 막기 위해 만료된 버킷은 다음 접근 시 정리한다. +// +// Retry-After 계산 +// - 초과 시 window의 남은 시간을 초 단위로 반환. +// - 이를 응답 헤더 Retry-After 와 error.details.retryAfterSeconds 양쪽에 +// 같은 값으로 넣어 프론트 상태 패널이 분기 없이 소비할 수 있게 한다. + +export type RateLimitBucketConfig = { + /** 식별용 이름. "general" | "forceRefresh" 같은 값. */ + name: string; + /** 윈도우 길이 (밀리초). */ + windowMs: number; + /** 이 윈도우 안에서 허용되는 최대 요청 수. */ + max: number; +}; + +export type RateLimitCheckResult = + | { allowed: true; remaining: number; bucket: string } + | { + allowed: false; + bucket: string; + retryAfterSeconds: number; + limit: number; + windowMs: number; + }; + +type BucketState = { + /** 윈도우 시작 타임스탬프. 이 시각 + windowMs 되면 카운트가 0으로 리셋. */ + startedAt: number; + /** 이 윈도우 안 요청 카운트. */ + count: number; +}; + +// 프로세스 단위 store. Vercel serverless 함수 인스턴스 재사용 동안 유지된다. +const store = new Map(); + +// 너무 많은 unique IP가 쌓이면 메모리 누수 — 주기적으로 만료분 청소. +// 엄격한 LRU는 아니지만 윈도우 지난 것은 없애준다. +function gc(now: number) { + // 매 요청마다 전체 순회는 부담이라 확률적으로만 돌린다. + if (Math.random() > 0.02) return; + for (const [key, state] of store.entries()) { + // 윈도우 타입별 크게 잡고 60초 이상 방치된 항목 제거. + if (now - state.startedAt > 120_000) { + store.delete(key); + } + } +} + +export function checkRateLimit( + ip: string, + config: RateLimitBucketConfig +): RateLimitCheckResult { + const now = Date.now(); + gc(now); + + const key = `${config.name}::${ip}`; + const existing = store.get(key); + if (!existing || now - existing.startedAt >= config.windowMs) { + // 새 윈도우 시작. + store.set(key, { startedAt: now, count: 1 }); + return { allowed: true, remaining: config.max - 1, bucket: config.name }; + } + + if (existing.count < config.max) { + existing.count += 1; + return { + allowed: true, + remaining: config.max - existing.count, + bucket: config.name, + }; + } + + const elapsed = now - existing.startedAt; + const retryAfterSeconds = Math.max( + 1, + Math.ceil((config.windowMs - elapsed) / 1000) + ); + return { + allowed: false, + bucket: config.name, + retryAfterSeconds, + limit: config.max, + windowMs: config.windowMs, + }; +} + +/** 테스트용 — 프로덕션 코드에서는 호출하지 않는다. */ +export function __resetRateLimitStoreForTests() { + store.clear(); +} + +/** 운영 정책. 한 곳에서 조정할 수 있게 상수로 모아둔다. + * + * - general: IP당 60초에 6회 (=10초에 1회 평균, burst 6). + * - forceRefresh: IP당 10분에 2회. forceRefresh는 서버 캐시 우회라 비용 큼. + * + * 두 limiter는 AND 관계로 평가된다 — forceRefresh=true 요청은 general + * 카운터도 같이 소모하므로, 연타하면 general에서도 먼저 막힌다. */ +export const RATE_LIMIT_POLICY = { + general: { + name: "general", + windowMs: 60_000, + max: 6, + }, + forceRefresh: { + name: "forceRefresh", + windowMs: 10 * 60_000, + max: 2, + }, +} as const satisfies Record; + +/** Next.js Request에서 클라이언트 IP를 추출한다. + * Vercel/프록시 뒤에 있을 때는 x-forwarded-for 첫 번째 값을 쓴다. */ +export function extractClientIp(request: Request): string { + const xff = request.headers.get("x-forwarded-for"); + if (xff) { + const first = xff.split(",")[0]?.trim(); + if (first) return first; + } + const xRealIp = request.headers.get("x-real-ip"); + if (xRealIp) return xRealIp.trim(); + // Vercel 추가 헤더도 같이 본다. + const cfIp = request.headers.get("cf-connecting-ip"); + if (cfIp) return cfIp.trim(); + return "unknown"; +} diff --git a/next.config.ts b/next.config.ts index 31a9ee4..fdfde1c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -21,6 +21,17 @@ const SECURITY_HEADERS = [ key: "Content-Security-Policy", value: "frame-ancestors 'none'; base-uri 'self'; form-action 'self'", }, + // HTTPS만 쓰도록 브라우저에 힌트. Vercel은 기본 HTTPS고 사용자에게 강제. + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + // 크로스-오리진 창 공격 표면 축소. same-origin이라 분석 fetch/링크 열기에 + // 영향 없음. + { + key: "Cross-Origin-Opener-Policy", + value: "same-origin", + }, ]; const nextConfig: NextConfig = { diff --git a/tests/rate-limit.test.ts b/tests/rate-limit.test.ts new file mode 100644 index 0000000..7d285b7 --- /dev/null +++ b/tests/rate-limit.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + __resetRateLimitStoreForTests, + checkRateLimit, + extractClientIp, + RATE_LIMIT_POLICY, +} from "@/lib/analysis/rate-limit"; + +describe("checkRateLimit", () => { + beforeEach(() => { + __resetRateLimitStoreForTests(); + }); + + it("allows the first N requests within the window", () => { + const { max } = RATE_LIMIT_POLICY.general; + for (let i = 0; i < max; i++) { + const result = checkRateLimit("203.0.113.1", RATE_LIMIT_POLICY.general); + expect(result.allowed).toBe(true); + } + }); + + it("blocks the (N+1)-th request with retry-after seconds", () => { + const policy = RATE_LIMIT_POLICY.general; + for (let i = 0; i < policy.max; i++) { + checkRateLimit("203.0.113.2", policy); + } + const blocked = checkRateLimit("203.0.113.2", policy); + expect(blocked.allowed).toBe(false); + if (!blocked.allowed) { + expect(blocked.retryAfterSeconds).toBeGreaterThan(0); + expect(blocked.retryAfterSeconds).toBeLessThanOrEqual( + Math.ceil(policy.windowMs / 1000) + ); + expect(blocked.limit).toBe(policy.max); + } + }); + + it("keeps buckets isolated per IP", () => { + const policy = RATE_LIMIT_POLICY.general; + for (let i = 0; i < policy.max; i++) { + checkRateLimit("203.0.113.3", policy); + } + const blocked = checkRateLimit("203.0.113.3", policy); + expect(blocked.allowed).toBe(false); + + const allowed = checkRateLimit("203.0.113.4", policy); + expect(allowed.allowed).toBe(true); + }); + + it("keeps buckets isolated per bucket name", () => { + // forceRefresh 버킷을 소모해도 general은 여전히 통과해야 한다. + const ip = "203.0.113.5"; + for (let i = 0; i < RATE_LIMIT_POLICY.forceRefresh.max; i++) { + checkRateLimit(ip, RATE_LIMIT_POLICY.forceRefresh); + } + const blockedFR = checkRateLimit(ip, RATE_LIMIT_POLICY.forceRefresh); + expect(blockedFR.allowed).toBe(false); + + const general = checkRateLimit(ip, RATE_LIMIT_POLICY.general); + expect(general.allowed).toBe(true); + }); + + it("applies a tighter policy to forceRefresh than general", () => { + // 정책이 의도대로 더 타이트한지 — general.max > forceRefresh.max. + expect(RATE_LIMIT_POLICY.forceRefresh.max).toBeLessThan( + RATE_LIMIT_POLICY.general.max + ); + expect(RATE_LIMIT_POLICY.forceRefresh.windowMs).toBeGreaterThanOrEqual( + RATE_LIMIT_POLICY.general.windowMs + ); + }); +}); + +describe("extractClientIp", () => { + it("prefers the first value of x-forwarded-for", () => { + const request = new Request("http://localhost/", { + headers: { + "x-forwarded-for": "198.51.100.7, 10.0.0.1", + }, + }); + expect(extractClientIp(request)).toBe("198.51.100.7"); + }); + + it("falls back to x-real-ip", () => { + const request = new Request("http://localhost/", { + headers: { + "x-real-ip": "198.51.100.8", + }, + }); + expect(extractClientIp(request)).toBe("198.51.100.8"); + }); + + it("returns 'unknown' when no client headers are present", () => { + const request = new Request("http://localhost/"); + expect(extractClientIp(request)).toBe("unknown"); + }); +});