Full output
Claude Sonnet 5 원문 결과물
세션 인증 PR 코드 리뷰 / Anthropic / Claude Sonnet 5 / 실행일 2026-08-01
요약
현재 상태로는 머지할 수 없습니다. middleware.ts가 access_token 쿠키의 존재만 확인하고 서명·만료를 전혀 검증하지 않아 보호 경로 전체가 우회되며, AUTH_SECRET 미설정 시 하드코딩된 기본 시크릿으로 서명하고 verifyAccessToken이 exp를 검사하지 않아 토큰 위조와 무기한 세션이 동시에 가능합니다. 여기에 리프레시 회전이 이전 토큰을 폐기하지 않아 PR의 핵심 기능인 "회전"이 실질적으로 동작하지 않습니다.
결함
-
[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.ts에export const runtime = "nodejs"를 명시해 Node 런타임을 사용하도록 맞춥니다.
- 위치:
-
[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로 분리합니다.
- 위치:
-
[Critical]
verifyAccessToken이exp를 검증하지 않아 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;을 추가합니다.
- 위치:
-
[Critical] 리프레시 토큰 회전이 이전 토큰을 폐기하지 않아 탈취된 토큰이 계속 유효하다
- 위치:
app/api/auth/refresh/route.ts·POST - 문제 코드:
const record = await readRefreshToken(token); ... const nextRefreshToken = issueRefreshToken(); await saveRefreshToken(nextRefreshToken, record.userId); - 영향: 이 파일은
tokenStore.ts의deleteRefreshToken을 import조차 하지 않습니다. 새 refresh token을 저장할 뿐 사용된token을 지우지 않으므로, 공격자가 refresh token 하나를 탈취하면 정상 사용자가 그 뒤로 몇 번을 갱신하든 탈취된 토큰은 발급 후 14일 내내 별도로 유효합니다. PR 설명이 내세운 "회전"이 실제로는 토큰을 계속 추가만 하는 동작이라 재사용 탐지도 불가능하고,refreshToken테이블 행도 갱신마다 무한히 늘어납니다. - 수정 방향: 새 토큰 저장과 기존 토큰 삭제를 하나의 트랜잭션으로 묶습니다(
db.$transaction([...])). 가능하면 즉시 삭제 대신 사용 이력을 남겨, 이미 사용된 토큰이 재요청되면 해당 사용자의 모든 refresh token을 폐기하는 재사용 탐지 로직을 추가합니다.
- 위치:
-
[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를 호출해 응답 시간을 맞춥니다.
- 위치:
-
[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.ts의const 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으로 계산하고, 쿠키 설정 로직을 공통 헬퍼로 묶어 로그인·갱신이 같은 값을 공유하게 합니다.
- 위치:
-
[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-Control도Vary: 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를 붙여 방어선을 이중으로 둡니다.
- 위치:
-
[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바이트 값이므로 느린 해시나 솔트는 불필요합니다.
- 위치:
-
[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.user1회 +db.roleN회, 총 N+1번의 순차 쿼리가 발생합니다. 이 응답에 캐시가 없다는 점(7번 항목)까지 겹치면 페이지 진입마다 매번 DB로 직행하므로, 역할이 많은 사용자나 트래픽이 몰릴 때 지연과 커넥션 점유가 불필요하게 커집니다. - 수정 방향:
db.role.findMany({ where: { id: { in: user.roleIds } } })한 번으로 대체하거나, 스키마에 관계가 정의돼 있다면include로 단일 쿼리에 합칩니다.
- 위치:
-
[Minor] 서명 비교가 상수 시간이 아니다
- 위치:
lib/auth/tokens.ts·verifyAccessToken - 문제 코드:
if (sign(body) !== signature) { - 영향: 문자열
!==비교는 첫 불일치 문자에서 즉시 종료되어 실행 시간이 일치 길이에 비례합니다. 네트워크 지터로 실제 악용 난도는 높지만, 인증 경로에서 굳이 남길 필요가 없는 타이밍 부채입니다. - 수정 방향:
crypto.timingSafeEqual로 비교하되, 두 값의 길이가 다르면 먼저false를 반환해 예외를 방지합니다.
- 위치:
-
[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로 반환합니다.
- 위치:
-
[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을 반환합니다.
- 위치:
-
[Minor] 로그인 실패 시 이메일을 평문으로 로그에 남긴다
- 위치:
app/api/auth/login/route.ts·POST - 문제 코드:
console.warn(`login failed for ${email}`); - 영향: 개인정보인 이메일이 중앙 로그 수집기에 30일간 평문 그대로 축적됩니다. 사용자가 이메일 입력란에 비밀번호를 잘못 입력하는 경우, 그 값이 자격증명으로서 그대로 로그에 남을 위험도 있습니다.
- 수정 방향:
user.id나 이메일 해시만 기록하거나, 반드시 원문이 필요하면 마스킹(a***@example.com) 후 구조화된 필드로 남깁니다.
- 위치:
확인 필요
refreshToken모델의expiresAt컬럼 타입:saveRefreshToken은Date.now()(밀리초 숫자)를 넣고readRefreshToken은found.expiresAt < Date.now()로 숫자 비교합니다. Prisma 스키마가 diff에 없어 컬럼 타입이DateTime인지Int인지 알 수 없습니다.DateTime이라면 이 비교 자체가 깨집니다.refreshToken.token컬럼의 유니크 인덱스 여부:db.refreshToken.findUnique({ where: { token } })가 성립하려면 유니크 제약이 필요합니다. 이번 PR에 마이그레이션 파일이 포함돼 있는지 확인이 필요합니다.ADMIN_ROLES에"billing"이 포함된 것이 의도인지: 현재 정의상 결제 담당자에게isAdmin: true가 내려갑니다. 프런트엔드가 이 값으로 관리자 메뉴 노출 여부를 결정한다면 권한 확대로 이어질 수 있지만, 제품 정책을 알 수 없어 결함으로 단정하지 않습니다.createSessionToken→issueAccessToken이름 변경과 시그니처 변경(단일userId인자 →{ sub, email }객체) 이후, diff에 포함되지 않은 다른 호출부가 모두 정리되었는지 확인이 필요합니다.- access token 만료(15분) 이후의 갱신 흐름: 클라이언트가 만료 전에
/api/auth/refresh를 선제적으로 호출하는 로직이 이번 PR 범위 밖에 있는지, 없다면 15분마다 사용자가 강제 로그아웃되는 것은 아닌지 확인이 필요합니다. - 로그인·갱신 엔드포인트에 대한 요청 빈도 제한(rate limit)이 CDN이나 별도 계층에 이미 있는지 확인이 필요합니다. 5번 항목을 고쳐 응답을 통일해도 무차별 대입 자체는 막지 못합니다.
secure: true고정 설정: 로컬 개발이http://localhost(비 HTTPS)라면 쿠키가 설정되지 않을 수 있습니다. 개발 환경에서 별도 분기나 HTTPS 프록시를 쓰는지 확인이 필요합니다.
종합 판단
반려 — 보호 경로 인증 우회(미들웨어), 기본 시크릿 폴백에 의한 토큰 위조, exp 미검증, 리프레시 토큰 미폐기까지 인증 체계의 핵심 경로(발급·검증·회전) 자체가 성립하지 않으므로, 부분 수정이 아니라 이 네 가지를 포함해 전체를 다시 구현하고 재리뷰해야 합니다.