Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 122 additions & 2 deletions app/api/analyze/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<string> = [
"retryAfterSeconds",
"resetAt",
"authenticated",
"githubAuthMode",
];

function assertSameOriginBrowserRequest(request: Request) {
const origin = request.headers.get("origin");
const fetchSite = request.headers.get("sec-fetch-site");
Expand Down Expand Up @@ -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<string, string | number | boolean | null> | undefined;
if (details) {
const filtered: Record<string, string | number | boolean | null> = {};
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;

Expand All @@ -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,
});
Expand All @@ -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<AnalyzeTargetResponse>(
{
ok: false,
Expand Down
29 changes: 18 additions & 11 deletions components/owner-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -411,17 +412,23 @@ function HeroFeaturedCard({ repo }: { repo: OwnerRepositorySummary }) {
<ExternalIcon />
GitHub
</a>
{repo.homepage ? (
<a
href={repo.homepage}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2.5 py-1.5 text-[11.5px] text-[var(--fg-muted)] hover:border-[var(--border-strong)] hover:bg-[var(--surface-hover)] hover:text-[var(--fg)]"
>
<ExternalIcon />
사이트
</a>
) : null}
{/* homepage는 레포 소유자가 자유롭게 설정해서 javascript:/data: 같은
스킴이 들어올 수 있으므로 safeExternalHref로 한 번 더 검증. */}
{(() => {
const homepage = safeExternalHref(repo.homepage ?? null);
if (!homepage) return null;
return (
<a
href={homepage}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2.5 py-1.5 text-[11.5px] text-[var(--fg-muted)] hover:border-[var(--border-strong)] hover:bg-[var(--surface-hover)] hover:text-[var(--fg)]"
>
<ExternalIcon />
사이트
</a>
);
})()}
</div>
</article>
);
Expand Down
23 changes: 16 additions & 7 deletions components/result-learning-panel.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 (
<div className="space-y-2">
<a
href={hero.url}
href={hero.safeHref as string}
target="_blank"
rel="noreferrer noopener"
title={hero.alt || hero.url}
title={hero.alt || hero.safeHref || undefined}
className="block overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface)]"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={hero.url}
src={hero.safeSrc as string}
alt={hero.alt || "preview"}
loading="lazy"
className="block max-h-[460px] w-full object-contain"
Expand All @@ -614,15 +623,15 @@ function PreviewImageGallery({ images }: { images: RepoPreviewImage[] }) {
className="overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface)]"
>
<a
href={image.url}
href={image.safeHref as string}
target="_blank"
rel="noreferrer noopener"
title={image.alt || image.url}
title={image.alt || image.safeHref || undefined}
className="block"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={image.url}
src={image.safeSrc as string}
alt={image.alt || "preview"}
loading="lazy"
className="block max-h-[200px] w-full object-contain"
Expand Down
21 changes: 19 additions & 2 deletions components/result-readme-core-tab.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { safeExternalHref } from "@/components/safe-href";
import type { RepoAnalysis, RepoReadmeLink } from "@/lib/analysis/types";

// README Core 탭 — `analysis.learning.readmeCore`를 "README 원문 재생"이 아닌
Expand Down Expand Up @@ -194,13 +195,29 @@ const LINK_KIND_LABEL: Record<RepoReadmeLink["kind"], string> = {
};

function LinkChip({ link }: { link: RepoReadmeLink }) {
// 공격자가 README에 javascript:/data: 스킴 링크를 넣어도 href가 렌더되지
// 않도록 safeExternalHref로 필터. 위험 스킴이면 비클릭 텍스트로 대체.
const href = safeExternalHref(link.url);
if (!href) {
return (
<span
title={link.url}
className="inline-flex items-center gap-1.5 rounded-sm border border-dashed border-[var(--border)] bg-[var(--surface-strong)] px-2 py-1 text-[11.5px] text-[var(--fg-dim)]"
>
<span className="text-[10px] uppercase tracking-[0.04em]">
{LINK_KIND_LABEL[link.kind]}
</span>
<span className="truncate">{link.label}</span>
</span>
);
}
return (
<a
href={link.url}
href={href}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1.5 rounded-sm border border-[var(--border)] bg-[var(--surface-strong)] px-2 py-1 text-[11.5px] text-[var(--fg)] hover:border-[var(--border-strong)] hover:bg-[var(--surface-hover)]"
title={link.url}
title={href}
>
<span className="text-[10px] uppercase tracking-[0.04em] text-[var(--fg-dim)]">
{LINK_KIND_LABEL[link.kind]}
Expand Down
43 changes: 43 additions & 0 deletions components/safe-href.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// 사용자(레포 소유자) 컨텐츠에서 뽑아온 URL을 `<a href>` / `<img src>`에
// 그대로 꽂기 전에 통과시키는 안전 필터.
//
// 막고자 하는 것:
// - 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;
}
}
Loading
Loading