-
Notifications
You must be signed in to change notification settings - Fork 3
fix(web): AI 인스펙터 권한 검증 강화 및 스캐너 요청 차단 #496
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -22,6 +22,8 @@ const requestBodySchema = z.object({ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| selection: selectionSchema, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type AdminVerificationStatus = "authorized" | "unauthorized" | "forbidden" | "error"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const decodeTokenUserId = (accessToken: string): string | null => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const payloadSegment = accessToken.split(".")[1]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -43,7 +45,57 @@ const decodeTokenUserId = (accessToken: string): string | null => { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const verifyAdminRole = async (accessToken: string): Promise<boolean> => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const parseRoleValue = (rawValue: unknown): string => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (typeof rawValue !== "string") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return ""; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return rawValue.trim().toUpperCase(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const hasAdminRole = (rawValue: unknown): boolean => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const normalizedRole = parseRoleValue(rawValue); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return normalizedRole === UserRole.ADMIN || normalizedRole === "ROLE_ADMIN"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const getRoleFromMyResponse = (data: unknown): unknown => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!data || typeof data !== "object") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return ""; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const root = data as Record<string, unknown>; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (typeof root.role === "string") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return root.role; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const nestedData = root.data; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (nestedData && typeof nestedData === "object") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const nested = nestedData as Record<string, unknown>; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (typeof nested.role === "string") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return nested.role; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return ""; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const decodeTokenRole = (accessToken: string): unknown => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const payloadSegment = accessToken.split(".")[1]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!payloadSegment) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return ""; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const normalized = payloadSegment.replace(/-/g, "+").replace(/_/g, "/"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const payload = JSON.parse(Buffer.from(padded, "base64").toString("utf8")) as { role?: string }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return payload.role ?? ""; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return ""; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const verifyAdminRole = async (accessToken: string): Promise<AdminVerificationStatus> => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const apiServerUrl = process.env.NEXT_PUBLIC_API_SERVER_URL?.trim(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!apiServerUrl) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new Error("NEXT_PUBLIC_API_SERVER_URL is not configured."); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -57,14 +109,28 @@ const verifyAdminRole = async (accessToken: string): Promise<boolean> => { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cache: "no-store", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (response.status === 401) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return "unauthorized"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!response.ok) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return "forbidden"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const data = (await response.json().catch(() => null)) as unknown; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const responseRole = getRoleFromMyResponse(data); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (hasAdminRole(responseRole)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return "authorized"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Fallback: /my 인증(200)은 통과했는데 응답 role 스키마가 다른 경우를 대비. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (hasAdminRole(decodeTokenRole(accessToken))) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return "authorized"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+126
to
+128
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In Useful? React with 👍 / 👎. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const data = (await response.json()) as { role?: string }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return data.role === UserRole.ADMIN; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return "forbidden"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+120
to
+131
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 2. 서버가 non-admin을 돌려줘도 토큰 role이 이 fallback은 🔒 제안 수정- const data = (await response.json().catch(() => null)) as unknown;
+ let data: unknown;
+ try {
+ data = await response.json();
+ } catch {
+ return "error";
+ }
const responseRole = getRoleFromMyResponse(data);
- if (hasAdminRole(responseRole)) {
- return "authorized";
- }
-
- // Fallback: /my 인증(200)은 통과했는데 응답 role 스키마가 다른 경우를 대비.
- if (hasAdminRole(decodeTokenRole(accessToken))) {
+ if (responseRole !== "") {
+ return hasAdminRole(responseRole) ? "authorized" : "forbidden";
+ }
+
+ // Fallback: role 필드를 전혀 읽지 못한 경우에만 토큰을 참고합니다.
+ if (hasAdminRole(decodeTokenRole(accessToken))) {
return "authorized";
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return "error"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -96,14 +162,25 @@ async function POST(request: NextRequest) { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ message: "요청 본문을 읽을 수 없습니다." }, { status: 400 }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let isAdmin = false; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let verificationStatus: AdminVerificationStatus = "forbidden"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isAdmin = await verifyAdminRole(accessToken); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| verificationStatus = await verifyAdminRole(accessToken); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ message: "서버 인증 설정 오류입니다." }, { status: 500 }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!isAdmin) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (verificationStatus === "unauthorized") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ message: "로그인 세션이 만료되었습니다. 다시 로그인해주세요." }, { status: 401 }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (verificationStatus === "error") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { message: "관리자 권한 확인에 실패했습니다. 잠시 후 다시 시도해주세요." }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { status: 503 }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (verificationStatus !== "authorized") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ message: "관리자 권한이 필요합니다." }, { status: 403 }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,54 @@ | ||
| import type { NextRequest } from "next/server"; | ||
| import { NextResponse } from "next/server"; | ||
| import { isTokenExpired } from "@/utils/jwtUtils"; | ||
|
|
||
| const loginNeedPages = ["/mentor", "/my", "/community"]; // 로그인 필요페이지 | ||
| const blockedExactPaths = new Set([ | ||
| "/database.php", | ||
| "/db.php", | ||
| "/config.php", | ||
| "/phpinfo.php", | ||
| "/xmlrpc.php", | ||
| "/wp-login.php", | ||
| ]); | ||
| const blockedPathPrefixes = ["/wp-admin", "/phpmyadmin", "/pma", "/.env", "/.git", "/vendor"]; | ||
|
|
||
| const isStageHostname = (hostname: string) => hostname.includes("stage"); | ||
|
|
||
| const isProbePath = (pathname: string) => { | ||
| if (blockedExactPaths.has(pathname)) { | ||
| return true; | ||
| } | ||
|
|
||
| if (pathname.endsWith(".php")) { | ||
| return true; | ||
| } | ||
|
|
||
| return blockedPathPrefixes.some((prefix) => pathname.startsWith(prefix)); | ||
| }; | ||
|
|
||
| export function middleware(request: NextRequest) { | ||
| const url = request.nextUrl.clone(); | ||
| const pathname = url.pathname; | ||
|
|
||
| if (pathname === "/robots.txt" && isStageHostname(url.hostname)) { | ||
| return new NextResponse("User-agent: *\nDisallow: /\n", { | ||
| status: 200, | ||
| headers: { | ||
| "Content-Type": "text/plain; charset=utf-8", | ||
| "Cache-Control": "public, max-age=600", | ||
| "X-Robots-Tag": "noindex, nofollow, noarchive", | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| if (isProbePath(pathname)) { | ||
| return new NextResponse("Not Found", { | ||
| status: 404, | ||
| headers: { | ||
| "Cache-Control": "no-store", | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| // localhost 환경에서는 미들웨어 적용 X | ||
| // if (url.hostname === "localhost") { | ||
|
|
@@ -14,14 +57,13 @@ export function middleware(request: NextRequest) { | |
|
|
||
| // HTTP-only 쿠키의 refreshToken 확인 | ||
| const refreshToken = request.cookies.get("refreshToken")?.value; | ||
| const hasValidRefreshToken = Boolean(refreshToken && !isTokenExpired(refreshToken)); | ||
|
|
||
| // 정확한 경로 매칭 | ||
| const needLogin = loginNeedPages.some((path) => { | ||
| return url.pathname === path || url.pathname.startsWith(`${path}/`); | ||
| return pathname === path || pathname.startsWith(`${path}/`); | ||
| }); | ||
|
|
||
| if (needLogin && !hasValidRefreshToken) { | ||
| if (needLogin && !refreshToken) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The middleware now allows protected pages whenever a Useful? React with 👍 / 👎.
Comment on lines
58
to
+66
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 2. 로그인 게이트가 만료된 세션까지 통과시킵니다. 지금 분기는 🔧 제안 수정+import { isTokenExpired } from "@/utils/jwtUtils";
+
// HTTP-only 쿠키의 refreshToken 확인
const refreshToken = request.cookies.get("refreshToken")?.value;
@@
- if (needLogin && !refreshToken) {
+ if (needLogin && (!refreshToken || isTokenExpired(refreshToken))) {
url.pathname = "/login";
url.searchParams.delete("reason");
return NextResponse.redirect(url);
}🤖 Prompt for AI Agents |
||
| url.pathname = "/login"; | ||
| url.searchParams.delete("reason"); | ||
| return NextResponse.redirect(url); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
1.
/my업스트림 장애가 403으로 눌립니다.여기서는 401만 제외하고 나머지 non-OK를 전부
forbidden으로 보내서,/my가 500/502/503을 돌려도 최종 응답이 권한 부족처럼 보입니다. 403만forbidden으로 남기고 나머지는error로 돌려야 아래 503 분기가 살아납니다.🔧 제안 수정
🤖 Prompt for AI Agents