비교 요약으로 돌아가기

Full output

Claude Sonnet 5 원문 결과물

세션 인증 PR 코드 리뷰 / Anthropic / Claude Sonnet 5 / 실행일 2026-08-01

요약

현재 상태로는 머지할 수 없습니다. middleware.tsaccess_token 쿠키의 존재만 확인하고 서명·만료를 전혀 검증하지 않아 보호 경로 전체가 우회되며, AUTH_SECRET 미설정 시 하드코딩된 기본 시크릿으로 서명하고 verifyAccessTokenexp를 검사하지 않아 토큰 위조와 무기한 세션이 동시에 가능합니다. 여기에 리프레시 회전이 이전 토큰을 폐기하지 않아 PR의 핵심 기능인 "회전"이 실질적으로 동작하지 않습니다.

결함

  1. [Critical] 미들웨어가 쿠키 존재만 확인해 보호 경로 인증을 완전히 우회할 수 있다

    • 위치: middleware.ts · middleware
    • 문제 코드:
      const token = req.cookies.get("access_token")?.value;
      
      if (!token) {
        return NextResponse.redirect(new URL("/login", req.url));
      }
      
      return NextResponse.next();
      
    • 영향: verifyAccessToken을 호출하지 않고 값이 비어 있지 않기만 하면 통과시킵니다. 로그인한 적 없는 사용자가 브라우저에서 document.cookie = "access_token=anything; path=/"를 실행하거나 curl --cookie "access_token=x" https://app/dashboard를 보내면 /dashboard, /settings 하위 전 경로가 그대로 렌더링됩니다. 이 PR이 도입한 유일한 페이지 보호 장치가 아무 역할을 하지 못합니다.
    • 수정 방향: 미들웨어에서 verifyAccessToken(token)을 호출해 서명·exp 검증 결과가 null이면 /login으로 리다이렉트합니다. 미들웨어는 기본적으로 Edge 런타임에서 실행되어 node:crypto 기반 createHmac을 그대로 쓸 수 없을 가능성이 있으므로, Web Crypto(crypto.subtle) 기반으로 서명 함수를 재작성하거나 middleware.tsexport const runtime = "nodejs"를 명시해 Node 런타임을 사용하도록 맞춥니다.
  2. [Critical] AUTH_SECRET 미설정 시 하드코딩된 기본 시크릿으로 서명해 임의 토큰을 위조할 수 있다

    • 위치: lib/auth/tokens.ts · SECRET
    • 문제 코드: const SECRET = process.env.AUTH_SECRET ?? "dev-secret";
    • 영향: 다중 인스턴스 배포 환경에서 신규 인스턴스나 환경 변수 주입이 누락된 배포본이 있으면 서버는 정상 기동한 채 "dev-secret"으로 서명·검증합니다. 이 값은 소스코드에 그대로 노출되어 있으므로 공격자가 {"sub":"<임의 userId>","email":"x@x.com","exp":<미래 시각>}을 base64url 인코딩하고 HMAC-SHA256("dev-secret")로 서명만 붙이면 유효한 access token이 되어 /api/auth/me를 포함한 모든 인증 경로를 임의 사용자로 통과합니다. 기동 실패가 없으므로 배포 사고로도 감지되지 않습니다.
    • 수정 방향: 폴백을 제거하고 모듈 로드 시점에 if (!process.env.AUTH_SECRET) throw new Error(...)로 즉시 기동을 실패시킵니다. 로컬 개발 값은 .env.local로 분리합니다.
  3. [Critical] verifyAccessTokenexp를 검증하지 않아 access token이 사실상 만료되지 않는다

    • 위치: lib/auth/tokens.ts · verifyAccessToken
    • 문제 코드:
      if (sign(body) !== signature) {
        return null;
      }
      
      const payload = JSON.parse(
        Buffer.from(body, "base64url").toString(),
      ) as any;
      
      return payload;
      
    • 영향: 페이로드에 exp를 담아 발급하지만 검증 함수 어디에서도 현재 시각과 비교하지 않습니다. 쿠키의 maxAge: 60 * 15는 브라우저가 쿠키를 지우는 시점일 뿐이므로, XSS나 프록시 로그 등으로 access token 문자열 자체가 유출되면 공격자는 그 값을 직접 Cookie 헤더에 넣어 15분이 아니라 시크릿이 바뀔 때까지 무기한 /api/auth/me에 접근할 수 있습니다.
    • 수정 방향: 서명 검증 통과 직후 if (typeof payload?.exp !== "number" || payload.exp < Date.now()) return null;을 추가합니다.
  4. [Critical] 리프레시 토큰 회전이 이전 토큰을 폐기하지 않아 탈취된 토큰이 계속 유효하다

    • 위치: app/api/auth/refresh/route.ts · POST
    • 문제 코드:
      const record = await readRefreshToken(token);
      ...
      const nextRefreshToken = issueRefreshToken();
      await saveRefreshToken(nextRefreshToken, record.userId);
      
    • 영향: 이 파일은 tokenStore.tsdeleteRefreshToken을 import조차 하지 않습니다. 새 refresh token을 저장할 뿐 사용된 token을 지우지 않으므로, 공격자가 refresh token 하나를 탈취하면 정상 사용자가 그 뒤로 몇 번을 갱신하든 탈취된 토큰은 발급 후 14일 내내 별도로 유효합니다. PR 설명이 내세운 "회전"이 실제로는 토큰을 계속 추가만 하는 동작이라 재사용 탐지도 불가능하고, refreshToken 테이블 행도 갱신마다 무한히 늘어납니다.
    • 수정 방향: 새 토큰 저장과 기존 토큰 삭제를 하나의 트랜잭션으로 묶습니다(db.$transaction([...])). 가능하면 즉시 삭제 대신 사용 이력을 남겨, 이미 사용된 토큰이 재요청되면 해당 사용자의 모든 refresh token을 폐기하는 재사용 탐지 로직을 추가합니다.
  5. [Major] 로그인 실패 응답을 이메일 존재 여부로 분리해 계정 목록을 열거할 수 있다

    • 위치: app/api/auth/login/route.ts · POST
    • 문제 코드:
      if (!user) {
        return NextResponse.json(
          { error: "등록되지 않은 이메일입니다." },
          { status: 404 },
        );
      }
      
      if (!(await verifyPassword(password, user.passwordHash))) {
        console.warn(`login failed for ${email}`);
        return NextResponse.json(
          { error: "비밀번호가 올바르지 않습니다." },
          { status: 401 },
        );
      }
      
    • 영향: 기존에는 단일 401로 합쳐져 있던 응답을 이 PR이 404/401로 나눴습니다. 공격자가 유출된 이메일 목록을 임의 비밀번호로 순회하면 상태 코드만으로 가입 여부를 판별해 유효 계정 목록을 추려낼 수 있습니다. 미가입 이메일 경로는 verifyPassword(bcrypt) 호출을 건너뛰므로 응답 시간도 눈에 띄게 짧아, 상태 코드를 나중에 통일하더라도 타이밍만으로 구분이 가능한 부가 문제도 있습니다.
    • 수정 방향: 두 경우 모두 동일한 401과 동일한 메시지("이메일 또는 비밀번호가 올바르지 않습니다.")로 응답합니다. 사용자가 없을 때도 더미 해시로 verifyPassword를 호출해 응답 시간을 맞춥니다.
  6. [Major] refresh 쿠키 수명(7일)이 서버 저장소 TTL·PR 설명(14일)과 어긋난다

    • 위치: app/api/auth/login/route.ts, app/api/auth/refresh/route.ts · res.cookies.set("refresh_token", ...)
    • 문제 코드:
      res.cookies.set("refresh_token", refreshToken, {
        ...
        maxAge: 60 * 60 * 24 * 7,
      });
      
      대비 lib/auth/tokenStore.tsconst REFRESH_TTL_MS = 14 * 24 * 60 * 60 * 1000;
    • 영향: 60 * 60 * 24 * 7은 7일인데 DB의 expiresAt은 14일 뒤로 설정됩니다. 8일 만에 재방문한 사용자는 서버에 유효한 refresh 레코드가 남아 있는데도 브라우저 쿠키가 먼저 사라져 강제로 재로그인해야 하고, 반대로 서버에는 아무도 제시할 수 없는 레코드가 7일 더 남아 4번 문제의 행 누적을 더 키웁니다. 로그인·갱신 두 곳 모두 같은 상수를 반복 입력하고 있어 값이 어긋난 채로 방치되기 쉽습니다.
    • 수정 방향: REFRESH_TTL_MS를 단일 출처로 삼아 maxAge: REFRESH_TTL_MS / 1000으로 계산하고, 쿠키 설정 로직을 공통 헬퍼로 묶어 로그인·갱신이 같은 값을 공유하게 합니다.
  7. [Major] /api/auth/me 응답에 캐시 금지 헤더가 없어 CDN이 사용자별 개인정보를 공유 캐시에 담을 수 있다

    • 위치: app/api/auth/me/route.ts · GET
    • 문제 코드:
      return NextResponse.json({
        id: user.id,
        email: user.email,
        roles: roleNames,
        isAdmin: hasAdminAccess(roleNames),
      });
      
    • 영향: 이 서비스는 앞단에 CDN을 두고 있다는 전제가 명시돼 있는데, 이 GET 응답은 Cache-ControlVary: Cookie도 지정하지 않습니다. CDN이 경로 기준으로 200 GET 응답을 기본 캐시하는 설정이면, 쿠키(요청자)와 무관하게 같은 URL에 대해 A 사용자의 이메일·역할·isAdmin이 캐시되어 뒤이어 같은 경로를 요청한 B 사용자에게 그대로 내려갈 수 있습니다.
    • 수정 방향: res.headers.set("Cache-Control", "no-store")res.headers.set("Vary", "Cookie")를 추가합니다. 로그인·갱신 라우트(POST)도 응답에 Set-Cookie가 실리므로 동일하게 no-store를 붙여 방어선을 이중으로 둡니다.
  8. [Major] refresh token을 평문으로 저장해 DB 열람만으로 세션을 탈취할 수 있다

    • 위치: lib/auth/tokenStore.ts · saveRefreshToken
    • 문제 코드:
      export async function saveRefreshToken(token: string, userId: string) {
        await db.refreshToken.create({
          data: { token, userId, expiresAt: Date.now() + REFRESH_TTL_MS },
        });
      }
      
    • 영향: refresh token은 비밀번호 없이 계속 새 access token을 받아낼 수 있는 베어러 자격증명인데, 별도 해시 없이 원문 그대로 저장됩니다. 읽기 전용 DB 계정, 운영 백업의 스테이징 복제, 관리 콘솔 조회 등 테이블을 읽을 수 있는 모든 경로가 곧바로 해당 사용자의 세션 탈취로 이어집니다.
    • 수정 방향: 원문 토큰은 쿠키로만 내려보내고, DB에는 sha256(token) 해시만 저장·조회합니다. 랜덤 32바이트 값이므로 느린 해시나 솔트는 불필요합니다.
  9. [Minor] getUserWithRoles가 역할 수만큼 쿼리를 순차 반복한다 (N+1)

    • 위치: lib/auth/users.ts · getUserWithRoles
    • 문제 코드:
      const roles = [];
      for (const roleId of user.roleIds) {
        const role = await db.role.findUnique({ where: { id: roleId } });
        if (role) {
          roles.push(role);
        }
      }
      
    • 영향: 역할이 N개인 사용자가 /api/auth/me를 호출할 때마다 db.user 1회 + db.role N회, 총 N+1번의 순차 쿼리가 발생합니다. 이 응답에 캐시가 없다는 점(7번 항목)까지 겹치면 페이지 진입마다 매번 DB로 직행하므로, 역할이 많은 사용자나 트래픽이 몰릴 때 지연과 커넥션 점유가 불필요하게 커집니다.
    • 수정 방향: db.role.findMany({ where: { id: { in: user.roleIds } } }) 한 번으로 대체하거나, 스키마에 관계가 정의돼 있다면 include로 단일 쿼리에 합칩니다.
  10. [Minor] 서명 비교가 상수 시간이 아니다

    • 위치: lib/auth/tokens.ts · verifyAccessToken
    • 문제 코드: if (sign(body) !== signature) {
    • 영향: 문자열 !== 비교는 첫 불일치 문자에서 즉시 종료되어 실행 시간이 일치 길이에 비례합니다. 네트워크 지터로 실제 악용 난도는 높지만, 인증 경로에서 굳이 남길 필요가 없는 타이밍 부채입니다.
    • 수정 방향: crypto.timingSafeEqual로 비교하되, 두 값의 길이가 다르면 먼저 false를 반환해 예외를 방지합니다.
  11. [Minor] 토큰 페이로드를 검증 없이 as any로 반환하고 파싱 예외를 처리하지 않는다

    • 위치: lib/auth/tokens.ts · verifyAccessToken
    • 문제 코드:
      const payload = JSON.parse(
        Buffer.from(body, "base64url").toString(),
      ) as any;
      
      return payload;
      
    • 영향: 반환 타입은 AccessPayload | null이지만 실제 값은 any를 거쳐 검증 없이 통과하므로, sub가 없거나 타입이 다른 값도 그대로 getUserWithRoles에 전달될 수 있습니다. 서명은 맞지만 본문이 손상된 토큰이 들어오면 JSON.parse가 던지는 예외가 잡히지 않아 401 대신 500이 나가고, 그 스택 트레이스가 중앙 로그에 30일간 남습니다.
    • 수정 방향: try/catch로 감싸 파싱 실패 시 null을 반환하고, sub·email이 문자열이고 exp가 숫자인지 확인한 뒤에만 AccessPayload로 반환합니다.
  12. [Minor] 로그인 요청 본문을 검증하지 않아 잘못된 입력이 500으로 이어진다

    • 위치: app/api/auth/login/route.ts · POST
    • 문제 코드: const { email, password } = await req.json();
    • 영향: 본문이 유효한 JSON이 아니면 req.json()이 던지는 예외가 잡히지 않아 400이어야 할 요청이 500이 됩니다. email이 누락되거나 객체 형태({"contains": "@"})로 오면 findUserByEmail이 Prisma 계층에서 예상치 못한 방식으로 처리되거나 예외를 던질 수 있습니다.
    • 수정 방향: req.json() 호출을 try/catch로 감싸고, email·password가 비어 있지 않은 문자열인지 스키마 검증(zod 등) 또는 명시적 typeof 확인 후 실패 시 400을 반환합니다.
  13. [Minor] 로그인 실패 시 이메일을 평문으로 로그에 남긴다

    • 위치: app/api/auth/login/route.ts · POST
    • 문제 코드: console.warn(`login failed for ${email}`);
    • 영향: 개인정보인 이메일이 중앙 로그 수집기에 30일간 평문 그대로 축적됩니다. 사용자가 이메일 입력란에 비밀번호를 잘못 입력하는 경우, 그 값이 자격증명으로서 그대로 로그에 남을 위험도 있습니다.
    • 수정 방향: user.id나 이메일 해시만 기록하거나, 반드시 원문이 필요하면 마스킹(a***@example.com) 후 구조화된 필드로 남깁니다.

확인 필요

  • refreshToken 모델의 expiresAt 컬럼 타입: saveRefreshTokenDate.now()(밀리초 숫자)를 넣고 readRefreshTokenfound.expiresAt < Date.now()로 숫자 비교합니다. Prisma 스키마가 diff에 없어 컬럼 타입이 DateTime인지 Int인지 알 수 없습니다. DateTime이라면 이 비교 자체가 깨집니다.
  • refreshToken.token 컬럼의 유니크 인덱스 여부: db.refreshToken.findUnique({ where: { token } })가 성립하려면 유니크 제약이 필요합니다. 이번 PR에 마이그레이션 파일이 포함돼 있는지 확인이 필요합니다.
  • ADMIN_ROLES"billing"이 포함된 것이 의도인지: 현재 정의상 결제 담당자에게 isAdmin: true가 내려갑니다. 프런트엔드가 이 값으로 관리자 메뉴 노출 여부를 결정한다면 권한 확대로 이어질 수 있지만, 제품 정책을 알 수 없어 결함으로 단정하지 않습니다.
  • createSessionTokenissueAccessToken 이름 변경과 시그니처 변경(단일 userId 인자 → { sub, email } 객체) 이후, diff에 포함되지 않은 다른 호출부가 모두 정리되었는지 확인이 필요합니다.
  • access token 만료(15분) 이후의 갱신 흐름: 클라이언트가 만료 전에 /api/auth/refresh를 선제적으로 호출하는 로직이 이번 PR 범위 밖에 있는지, 없다면 15분마다 사용자가 강제 로그아웃되는 것은 아닌지 확인이 필요합니다.
  • 로그인·갱신 엔드포인트에 대한 요청 빈도 제한(rate limit)이 CDN이나 별도 계층에 이미 있는지 확인이 필요합니다. 5번 항목을 고쳐 응답을 통일해도 무차별 대입 자체는 막지 못합니다.
  • secure: true 고정 설정: 로컬 개발이 http://localhost(비 HTTPS)라면 쿠키가 설정되지 않을 수 있습니다. 개발 환경에서 별도 분기나 HTTPS 프록시를 쓰는지 확인이 필요합니다.

종합 판단

반려 — 보호 경로 인증 우회(미들웨어), 기본 시크릿 폴백에 의한 토큰 위조, exp 미검증, 리프레시 토큰 미폐기까지 인증 체계의 핵심 경로(발급·검증·회전) 자체가 성립하지 않으므로, 부분 수정이 아니라 이 네 가지를 포함해 전체를 다시 구현하고 재리뷰해야 합니다.