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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# AGENTS Knowledge Base

## Mandatory Rule

- PR 제목과 본문은 반드시 한국어로 작성한다.

## Project Overview

- Monorepo managed with `pnpm` + `turbo`.
Expand Down
93 changes: 85 additions & 8 deletions apps/web/src/app/api/ai-inspector-requests/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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.");
Expand All @@ -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";
}
Comment on lines 116 to +118
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

1. /my 업스트림 장애가 403으로 눌립니다.

여기서는 401만 제외하고 나머지 non-OK를 전부 forbidden으로 보내서, /my가 500/502/503을 돌려도 최종 응답이 권한 부족처럼 보입니다. 403만 forbidden으로 남기고 나머지는 error로 돌려야 아래 503 분기가 살아납니다.

🔧 제안 수정
-    if (!response.ok) {
-      return "forbidden";
-    }
+    if (!response.ok) {
+      return response.status === 403 ? "forbidden" : "error";
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/api/ai-inspector-requests/route.ts` around lines 116 - 118,
The current handler in apps/web/src/app/api/ai-inspector-requests/route.ts
treats all non-ok upstream responses as "forbidden"; update the logic that
checks response (the response object in this function) so only response.status
=== 403 returns "forbidden" and any other non-OK status returns "error" (so
500/502/503 propagate as "error" instead of being masked as forbidden). Locate
the non-OK branch around the response.ok check and change the conditional to
distinguish 403 from other statuses.


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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Ignore token fallback when /my returns a non-admin role

In verifyAdminRole, the fallback to decodeTokenRole(accessToken) runs even after /my returned a parsed role that is explicitly non-admin. That means if token claims and server role ever diverge (for example, after role downgrade while an old token is still valid), a non-admin user can still be authorized for this admin-only endpoint. The token fallback should only apply when the /my role is missing/unparseable, not when a concrete non-admin role is returned.

Useful? React with 👍 / 👎.

}

const data = (await response.json()) as { role?: string };
return data.role === UserRole.ADMIN;
return "forbidden";
Comment on lines +120 to +131
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

2. 서버가 non-admin을 돌려줘도 토큰 role이 ADMIN이면 다시 승인됩니다.

이 fallback은 /my의 최신 권한 판단보다 JWT 페이로드를 더 신뢰해서, 관리자 권한이 회수된 직후에도 stale token으로 작업을 enqueue할 수 있습니다. fallback은 role 필드를 전혀 읽지 못한 경우에만 제한하고, JSON 파싱 실패는 error로 올리는 편이 안전합니다.

🔒 제안 수정
-    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";
     }
Based on learnings, "JWT 토큰의 페이로드에는 보안성이 없는 정보만 저장되므로" 이 값은 관리자 권한의 최종 근거로 쓰지 않는 편이 맞습니다.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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";
}
const data = (await response.json()) as { role?: string };
return data.role === UserRole.ADMIN;
return "forbidden";
let data: unknown;
try {
data = await response.json();
} catch {
return "error";
}
const responseRole = getRoleFromMyResponse(data);
if (responseRole !== "") {
return hasAdminRole(responseRole) ? "authorized" : "forbidden";
}
// Fallback: role 필드를 전혀 읽지 못한 경우에만 토큰을 참고합니다.
if (hasAdminRole(decodeTokenRole(accessToken))) {
return "authorized";
}
return "forbidden";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/api/ai-inspector-requests/route.ts` around lines 120 - 131,
The current fallback trusts JWT role even when the /my response explicitly
returns a non-admin role; change the logic so that you only consult
decodeTokenRole(accessToken) when getRoleFromMyResponse(data) yields no usable
role (null/undefined/invalid), and do not treat a parsed non-admin response as
eligible for fallback; also stop swallowing JSON parse errors from
response.json()—let parsing failures surface as errors (or return an error
result) instead of silently falling back to the token. Ensure you update the
block around response.json(), getRoleFromMyResponse, hasAdminRole and
decodeTokenRole accordingly so only missing responseRole triggers the token
check.

} catch {
return false;
return "error";
}
};

Expand Down Expand Up @@ -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 });
}

Expand Down
50 changes: 46 additions & 4 deletions apps/web/src/middleware.ts
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") {
Expand All @@ -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) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject expired refresh tokens in middleware guard

The middleware now allows protected pages whenever a refreshToken cookie exists, without checking whether that token is expired or malformed. This regresses the previous route guard behavior and lets stale sessions access /mentor, /my, and /community until downstream API calls fail, producing inconsistent auth flow and delayed rejection. The guard should continue validating token freshness (or an equivalent validity check) before permitting these routes.

Useful? React with 👍 / 👎.

Comment on lines 58 to +66
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

2. 로그인 게이트가 만료된 세션까지 통과시킵니다.

지금 분기는 refreshToken의 존재만 보므로, apps/web/src/utils/isServerStateLogin.ts:1-11이 이미 로그아웃으로 보는 만료 토큰도 protected page에 먼저 진입합니다. 같은 판정을 재사용하지 않으면 최초 진입과 이후 리다이렉트 판단이 서로 달라집니다.

🔧 제안 수정
+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
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/middleware.ts` around lines 58 - 66, The current gate only
checks presence of refreshToken (variable refreshToken) causing expired tokens
to pass; replace the simple existence check with the unified server-side login
check by calling the existing isServerStateLogin() helper from
apps/web/src/utils/isServerStateLogin.ts (or replicate its exact validation
logic) when computing needLogin and deciding redirection, i.e., use
isServerStateLogin(request.cookies / refreshToken) to determine authenticated
state and rely on that result in the if (needLogin && !...) branch so initial
entry and subsequent redirect use the same expiration logic.

url.pathname = "/login";
url.searchParams.delete("reason");
return NextResponse.redirect(url);
Expand Down
Loading