관리자 페이지에서만 보이는 버튼인데 남이 그 동작을 실행할 수 있었던 이유
문제 발생
관리자 페이지에서 세션을 검사하고, 그 안에 삭제 버튼을 뒀습니다.
export default async function AdminPage() {
const session = await auth();
if (!session?.user?.isAdmin) redirect("/login");
return <form action={deleteAllRecords}><button>전체 삭제</button></form>;
}로그인하지 않은 상태에서 이 페이지를 열면 로그인 화면으로 갑니다. 그런데 액션 자체는 직접 POST 요청으로 호출할 수 있었습니다.
원인 분석
Server Action은 UI가 아니라 엔드포인트입니다. Next.js 문서가 그대로 적습니다 — Server Action이 만들어져 export되면 애플리케이션 UI를 통해서만이 아니라 직접 POST 요청으로도 도달할 수 있습니다. 코드 어디서도 import되지 않은 액션조차 외부에서 호출될 수 있습니다.
Next.js가 기본으로 제공하는 보호(암호화된 non-deterministic 액션 ID, 사용되지 않는 액션의 dead code 제거)가 있지만, 문서는 그 한계를 명확히 합니다 — 이 개선은 인증 계층이 없는 경우의 위험을 줄여줄 뿐이며, Server Action은 여전히 직접 POST로 도달 가능한 것으로 취급하고 각 액션 안에서 인증과 인가를 검증해야 합니다.
그리고 이 문서의 문장 하나가 이번 문제의 핵심입니다 — 페이지 수준의 인증 검사는 그 안에 정의된 Server Action으로 이어지지 않습니다. 항상 액션 안에서 다시 검증하십시오.
해결 방안
- 액션 안에서 세션을 다시 확인합니다.
"use server";
export async function deletePost(postId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
...
}- 인증과 인가를 구분합니다. 문서가 짚는 그대로 — 로그인 여부(authentication)를 넘어 **이 사용자가 이 리소스에 대해 권한이 있는가(authorization)**를 확인해야 IDOR을 막습니다.
const post = await db.post.findUnique({ where: { id: postId } });
if (post.authorId !== session.user.id) throw new Error("Forbidden");MEOKKO는 여기서 한 걸음 더 갑니다 — 소유권을 조회로 확인한 뒤 다시 확인하는 대신, UPDATE ... WHERE id = ? AND author_id = ?처럼 WHERE 절에서 강제합니다(community.md). 조회와 변경 사이의 TOCTOU 창이 없습니다.
-
입력을 검증합니다. 문서가 예로 드는 것처럼
searchParams의isAdmin=true같은 값을 신뢰하면 안 됩니다. 폼 데이터·쿼리·헤더는 전부 조작 가능합니다. -
반환값을 좁힙니다. 액션의 반환값은 직렬화되어 클라이언트로 갑니다. DB row를 그대로 돌려주면 내부 필드가 함께 나갑니다 —
{ success: true }면 충분한 경우가 많습니다. -
인가 로직을 한 곳에 모읍니다. 문서가 권하는 Data Access Layer 패턴대로
server-only모듈에 인증·인가·DB 접근을 두고"use server"액션은 얇게 유지합니다. 새 액션을 추가할 때 검사를 빠뜨릴 여지가 줄어듭니다.
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.