최근 Next.js 프로젝트에서 심각한 보안이슈가 발생하였습니다.

Next.js 보안 이슈

공격자가 요청에 x-middleware-subrequest 헤더를 포함시키면 Next.js 애플리케이션의 미들웨어에서 수행되는 인증 검사를 우회할 수 있습니다. 이로 인해 인증이나 권한 부여와 같은 중요한 보안 검사가 무시될 수 있습니다.

예를 들어, 관리자 권한만 접근할 수 있는 페이지를 middleware에서 검사하는 경우, 공격자가 이 헤더를 포함시키면 미들웨어가 이를 무시하고 관리자 권한이 없는 사용자도 접근할 수 있게 됩니다.

현재 진행하고 있는 프로젝트는 14.2.4 버전을 사용하고 있었습니다.

Next.js Github 에서는 아래와 같이 이슈가 fix 된 버전을 알려주고 업데이트를 권장하고 있습니다.

Patches
For Next.js 15.x, this issue is fixed in 15.2.3
For Next.js 14.x, this issue is fixed in 14.2.25
For Next.js 13.x, this issue is fixed in 13.5.9
For Next.js 12.x, this issue is fixed in 12.3.5
For Next.js 11.x, consult the below workaround.

14.2.25 버전 이상으로 업데이트를 해야하는 상황이였지만, 현재 latest 버전을 따라가기 위해, 미리 15.2.4 버전으로 업데이트를 진행하였습니다.



버전 업데이트 진행 14.2.4 > 15.2.4

npm install next@latest

어느정도 호환은 되는 상황이라 많이 바꿀건 없었지만, 브라우저 콘솔에 아래와 같은 Warning이 발생하였습니다.

Error: Route "some/route" used `cookies().getAll()`. `cookies()` should be awaited before using its value.

해당 Warning을 따라가보니, next-auth Provider에 접근하거나, 메서드를 사용할 때마다 발생하는 것을 확인할 수 있었습니다.

현재 사용하고 있는 next-auth 버전은 4.24.7 이며, 이 버전은 Next.js 15 버전과 호환이 되지 않는 버전입니다.

Next.js 14 버전 대에서는 cookie, headers, Page 컴포넌트에서의 params, searchParams 등을 사용할 때 await 키워드를 붙히지 않아도 되는 상황이였습니다.

const cookieStore = cookies()

그러나 Next.js 15 버전에서는 위와 같은 코드에서 await 키워드를 붙여야합니다.

const cookieStore = await cookies()

이러한 이유로 next-auth 버전을 15 버전과 호환되는 5.0.0-beta 버전으로 업데이트를 같이 진행하게 되었습니다.



next-auth 버전 업데이트 4.24.7 > 5.0.0-beta.25

npm install [email protected]

버전이 마이그레이션 되며 사용방법이 많이 변경 되었습니다.


AS-IS

기존에는 /src/app/api/auth/[...nextauth]/route.ts 경로의 route 파일에서 Provider, NextAuth, 콜백 등을 한 번에 설정하고 메서드들을 export 합니다.

import CredentialsProvider from "next-auth/providers/credentials"
import NextAuth, { NextAuthOptions } from "next-auth"
 
import { userLoginPostFetch } from "@/api/user/userLoginPostFetch"
import { userInfoGetFetch } from "@/api/user/userInfoGetFetch"
import { PATH } from "@/constants/paths"
 
export const authOptions: NextAuthOptions = {
  providers: [
    CredentialsProvider({
      name: "Credentials",
      credentials: {
        email: { label: "UserEmail", type: "text", placeholder: "이메일" },
        password: { label: "Password", type: "password" },
      },
 
      async authorize(credentials, req) {
        if (!credentials) {
          console.error("No credentials provided")
          return null
        }
 
        //.. 로직
 
        return (
          {
            //..유저정보 리턴
          } || null
        )
      },
    }),
    CredentialsProvider({
      id: "kakao",
      name: "kakao",
      credentials: {
        accessToken: { type: "text" },
        refreshToken: { type: "text" },
      },
      async authorize(credentials, req) {
        try {
          return (
            {
              //..카카오 로그인 유저정보 리턴
            } || null
          )
        } catch (error) {
          console.error("Kakao login error:", error)
          throw error
        }
      },
    }),
  ],
 
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        //.. 토큰 정보 업데이트
      }
 
      return token
    },
 
    async session({ session, token }) {
      //.. 세션 정보 업데이트
 
      return session
    },
  },
 
  session: {
    strategy: "jwt",
  },
 
  secret: process.env.AUTH_SECRET,
 
  pages: {
    signIn: PATH.root,
    signOut: PATH.root,
  },
}
 
const handler = NextAuth(authOptions)
 
export { handler as GET, handler as POST }

그리고 클라이언트 사이드에서는 아래와 같이 사용하고,

import { signIn, signOut, useSession } from "next-auth/react"
 
//...

서버사이드에서는 아래와 같이 사용하였습니다.

import { getServerSession } from "next-auth"
import { authOptions } from "./api/auth/[...nextauth]/route"
 
//...
 
const session = await getServerSession(authOptions)

TO-BE

AUTH_SECRET 키를 기존에는 임의로 사용했지만 이제는 다음과 같은 커맨드를 입력해줍니다.

npx auth secret
 
 Overwrite existing `AUTH_SECRET`? … yes

그럼 환경 변수에 AUTH_SECRET 키가 자동으로 generate 되어 추가됩니다.

AUTH_SECRET="123412341234" # Added by `npx auth`. Read more: https://cli.authjs.dev

후에 사용될 JWT SECRET 키 입니다.

다음으로, 버전이 업그레이드 되며 기존에 사용하던 메서드들의 모듈 import 경로가 변경되었습니다.

먼저 프로젝트 최상위에 auth.config.ts 파일을 생성하여 기존 Provider 로직을 옮겨줍니다.

한 가지 변경점이 있다면 사용자에게 에러메시지를 보여주기 위해 CredentialsSignin를 확장하여 커스텀 에러 클래스를 만들어주고 타입을 상속받아 새로운 클래스를 생성해줍니다.

import type { NextAuthConfig, User, CredentialsSignin } from "next-auth"
import { userLoginPostFetch } from "@/api/user/userLoginPostFetch"
import { userInfoGetFetch } from "@/api/user/userInfoGetFetch"
import Credentials from "next-auth/providers/credentials"
 
class InvalidLoginError extends CredentialsSignin {
  code: string
 
  constructor(code: string) {
    super()
 
    this.code = code
  }
}
 
export default {
  providers: [
    Credentials({
      name: "credentials",
      credentials: {
        email: { label: "email", type: "text", placeholder: "이메일" },
        password: { label: "password", type: "password" },
      },
 
      async authorize(credentials): Promise<User | null> {
        if (!credentials) {
          console.error("No credentials provided")
          return null
        }
 
        try {
          //.. 유저 로그인 로직
 
          if (error) {
            throw new InvalidLoginError(error.message)
          }
 
          return {
            //..유저 정보 리턴
          }
        } catch (error: unknown) {
          if (error instanceof Error) {
            throw new InvalidLoginError(error.message)
          }
 
          throw new CredentialsSignin("UNKNOWN_ERROR", {
            type: "UnknownAction",
            code: "UNKNOWN_ERROR",
          })
        }
      },
    }),
    Credentials({
      id: "kakao",
      name: "kakao",
      credentials: {
        accessToken: { type: "text" },
        refreshToken: { type: "text" },
      },
      async authorize(credentials): Promise<User | null> {
        if (!credentials) {
          console.error("No credentials provided")
          return null
        }
 
        try {
          if (credentials) {
            //..카카오 로그인 로직
 
            return //..카카오 유저 정보 리턴
          }
 
          return null
        } catch (error) {
          console.error("Kakao login error:", error)
          throw error
        }
      },
    }),
  ],
} satisfies NextAuthConfig

그 다음 auth.ts를 생성하고 해당 파일에서 auth.config를 가져와 NextAuth 인스턴스를 생성할 때 사용합니다. config에서는 Provider 로직만 작성하였기 때문에, 이 후 callbacks 로직을 이어서 작성해주겠습니다.

import NextAuth from "next-auth"
import authConfig from "./auth.config"
import { PATH } from "@/constants/paths"
 
export const { handlers, auth } = NextAuth({
  ...authConfig,
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        //.. 토큰 정보 업데이트
      }
 
      return token
    },
 
    async session({ session, token }) {
      //.. 세션 정보 업데이트
 
      return session
    },
  },
 
  session: {
    strategy: "jwt",
  },
 
  secret: process.env.AUTH_SECRET,
 
  pages: {
    signIn: PATH.root,
    signOut: PATH.root,
  },
})

그리고 서버사이드에서 세션에 접근할 떄는 다음과 같이 사용합니다.

import { auth } from "@/auth"
 
//...
 
const session = await auth()

로그인 페이지에서 성공적으로 커스텀 에러를 반환 받은 모습입니다.

{
  "error": "CredentialsSignin",
  "code": "존재하지 않는 이메일입니다.",
  "status": 200,
  "ok": true,
  "url": null
}

마이그레이션을 진행하고 난 후, 더 이상 위에서 발생한 Warning이 발생하지 않았습니다.



앞으로의 계획

onsquad

실제 유저가 사용하는 서비스를 만들어보자 라는 버킷리스트 중 하나를 실현하기위해 2024.02 시작한 프로젝트 OnSquad 를 이번년도 겨울 전까지 완성하는 것이 목표입니다.

점점 웹 앱으로써의 모양새를 갖춰가고 있는 모습을 보니 뿌듯해짐과 동시에 빨리 릴리즈 해보고 싶은 욕구가 생기는 것 같습니다 😀