Skip to content

Commit bdb97a3

Browse files
authored
fix(web): AI 인스펙터 권한 검증 강화 및 스캐너 요청 차단 (#496)
* fix(web): harden admin verify and block probe paths * docs: add pr korean rule to agents
1 parent 9fae5a9 commit bdb97a3

3 files changed

Lines changed: 135 additions & 12 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# AGENTS Knowledge Base
22

3+
## Mandatory Rule
4+
5+
- PR 제목과 본문은 반드시 한국어로 작성한다.
6+
37
## Project Overview
48

59
- Monorepo managed with `pnpm` + `turbo`.

apps/web/src/app/api/ai-inspector-requests/route.ts

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ const requestBodySchema = z.object({
2222
selection: selectionSchema,
2323
});
2424

25+
type AdminVerificationStatus = "authorized" | "unauthorized" | "forbidden" | "error";
26+
2527
const decodeTokenUserId = (accessToken: string): string | null => {
2628
try {
2729
const payloadSegment = accessToken.split(".")[1];
@@ -43,7 +45,57 @@ const decodeTokenUserId = (accessToken: string): string | null => {
4345
}
4446
};
4547

46-
const verifyAdminRole = async (accessToken: string): Promise<boolean> => {
48+
const parseRoleValue = (rawValue: unknown): string => {
49+
if (typeof rawValue !== "string") {
50+
return "";
51+
}
52+
53+
return rawValue.trim().toUpperCase();
54+
};
55+
56+
const hasAdminRole = (rawValue: unknown): boolean => {
57+
const normalizedRole = parseRoleValue(rawValue);
58+
return normalizedRole === UserRole.ADMIN || normalizedRole === "ROLE_ADMIN";
59+
};
60+
61+
const getRoleFromMyResponse = (data: unknown): unknown => {
62+
if (!data || typeof data !== "object") {
63+
return "";
64+
}
65+
66+
const root = data as Record<string, unknown>;
67+
if (typeof root.role === "string") {
68+
return root.role;
69+
}
70+
71+
const nestedData = root.data;
72+
if (nestedData && typeof nestedData === "object") {
73+
const nested = nestedData as Record<string, unknown>;
74+
if (typeof nested.role === "string") {
75+
return nested.role;
76+
}
77+
}
78+
79+
return "";
80+
};
81+
82+
const decodeTokenRole = (accessToken: string): unknown => {
83+
try {
84+
const payloadSegment = accessToken.split(".")[1];
85+
if (!payloadSegment) {
86+
return "";
87+
}
88+
89+
const normalized = payloadSegment.replace(/-/g, "+").replace(/_/g, "/");
90+
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
91+
const payload = JSON.parse(Buffer.from(padded, "base64").toString("utf8")) as { role?: string };
92+
return payload.role ?? "";
93+
} catch {
94+
return "";
95+
}
96+
};
97+
98+
const verifyAdminRole = async (accessToken: string): Promise<AdminVerificationStatus> => {
4799
const apiServerUrl = process.env.NEXT_PUBLIC_API_SERVER_URL?.trim();
48100
if (!apiServerUrl) {
49101
throw new Error("NEXT_PUBLIC_API_SERVER_URL is not configured.");
@@ -57,14 +109,28 @@ const verifyAdminRole = async (accessToken: string): Promise<boolean> => {
57109
cache: "no-store",
58110
});
59111

112+
if (response.status === 401) {
113+
return "unauthorized";
114+
}
115+
60116
if (!response.ok) {
61-
return false;
117+
return "forbidden";
118+
}
119+
120+
const data = (await response.json().catch(() => null)) as unknown;
121+
const responseRole = getRoleFromMyResponse(data);
122+
if (hasAdminRole(responseRole)) {
123+
return "authorized";
124+
}
125+
126+
// Fallback: /my 인증(200)은 통과했는데 응답 role 스키마가 다른 경우를 대비.
127+
if (hasAdminRole(decodeTokenRole(accessToken))) {
128+
return "authorized";
62129
}
63130

64-
const data = (await response.json()) as { role?: string };
65-
return data.role === UserRole.ADMIN;
131+
return "forbidden";
66132
} catch {
67-
return false;
133+
return "error";
68134
}
69135
};
70136

@@ -96,14 +162,25 @@ async function POST(request: NextRequest) {
96162
return NextResponse.json({ message: "요청 본문을 읽을 수 없습니다." }, { status: 400 });
97163
}
98164

99-
let isAdmin = false;
165+
let verificationStatus: AdminVerificationStatus = "forbidden";
100166
try {
101-
isAdmin = await verifyAdminRole(accessToken);
167+
verificationStatus = await verifyAdminRole(accessToken);
102168
} catch {
103169
return NextResponse.json({ message: "서버 인증 설정 오류입니다." }, { status: 500 });
104170
}
105171

106-
if (!isAdmin) {
172+
if (verificationStatus === "unauthorized") {
173+
return NextResponse.json({ message: "로그인 세션이 만료되었습니다. 다시 로그인해주세요." }, { status: 401 });
174+
}
175+
176+
if (verificationStatus === "error") {
177+
return NextResponse.json(
178+
{ message: "관리자 권한 확인에 실패했습니다. 잠시 후 다시 시도해주세요." },
179+
{ status: 503 },
180+
);
181+
}
182+
183+
if (verificationStatus !== "authorized") {
107184
return NextResponse.json({ message: "관리자 권한이 필요합니다." }, { status: 403 });
108185
}
109186

apps/web/src/middleware.ts

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,54 @@
11
import type { NextRequest } from "next/server";
22
import { NextResponse } from "next/server";
3-
import { isTokenExpired } from "@/utils/jwtUtils";
43

54
const loginNeedPages = ["/mentor", "/my", "/community"]; // 로그인 필요페이지
5+
const blockedExactPaths = new Set([
6+
"/database.php",
7+
"/db.php",
8+
"/config.php",
9+
"/phpinfo.php",
10+
"/xmlrpc.php",
11+
"/wp-login.php",
12+
]);
13+
const blockedPathPrefixes = ["/wp-admin", "/phpmyadmin", "/pma", "/.env", "/.git", "/vendor"];
14+
15+
const isStageHostname = (hostname: string) => hostname.includes("stage");
16+
17+
const isProbePath = (pathname: string) => {
18+
if (blockedExactPaths.has(pathname)) {
19+
return true;
20+
}
21+
22+
if (pathname.endsWith(".php")) {
23+
return true;
24+
}
25+
26+
return blockedPathPrefixes.some((prefix) => pathname.startsWith(prefix));
27+
};
628

729
export function middleware(request: NextRequest) {
830
const url = request.nextUrl.clone();
31+
const pathname = url.pathname;
32+
33+
if (pathname === "/robots.txt" && isStageHostname(url.hostname)) {
34+
return new NextResponse("User-agent: *\nDisallow: /\n", {
35+
status: 200,
36+
headers: {
37+
"Content-Type": "text/plain; charset=utf-8",
38+
"Cache-Control": "public, max-age=600",
39+
"X-Robots-Tag": "noindex, nofollow, noarchive",
40+
},
41+
});
42+
}
43+
44+
if (isProbePath(pathname)) {
45+
return new NextResponse("Not Found", {
46+
status: 404,
47+
headers: {
48+
"Cache-Control": "no-store",
49+
},
50+
});
51+
}
952

1053
// localhost 환경에서는 미들웨어 적용 X
1154
// if (url.hostname === "localhost") {
@@ -14,14 +57,13 @@ export function middleware(request: NextRequest) {
1457

1558
// HTTP-only 쿠키의 refreshToken 확인
1659
const refreshToken = request.cookies.get("refreshToken")?.value;
17-
const hasValidRefreshToken = Boolean(refreshToken && !isTokenExpired(refreshToken));
1860

1961
// 정확한 경로 매칭
2062
const needLogin = loginNeedPages.some((path) => {
21-
return url.pathname === path || url.pathname.startsWith(`${path}/`);
63+
return pathname === path || pathname.startsWith(`${path}/`);
2264
});
2365

24-
if (needLogin && !hasValidRefreshToken) {
66+
if (needLogin && !refreshToken) {
2567
url.pathname = "/login";
2668
url.searchParams.delete("reason");
2769
return NextResponse.redirect(url);

0 commit comments

Comments
 (0)