본문 바로가기
Backend2026년 10월 4일17분 읽기

Prisma include가 공개 API로 흘린 것들 — 게스트 비밀번호 해시 유출과 IP 기록 다시 생각하기

YS
김영삼
조회 17
Prisma include가 공개 API로 흘린 것들 — 게스트 비밀번호 해시 유출과 IP 기록 다시 생각하기

Prisma의 include는 관계를 붙여 주는 동시에 본 모델의 스칼라 필드를 전부 돌려준다. 그래서 그 결과를 그대로 JSON으로 내보내는 공개 API는, 비밀번호 해시처럼 절대 나가면 안 되는 필드까지 응답에 싣게 된다.

내 사이트(Next.js + Prisma)에서 실제로 그랬다. 커뮤니티 공개 API가 게스트 작성자의 bcrypt 비밀번호 해시, IP 해시, 이메일을 응답에 섞어 내보내고 있었다. 이 글은 그 사고를 찾고 고친 과정, 그리고 같은 시기에 게스트 IP를 어떻게 기록할지 다시 정한 기록이다.

3줄 요약
  • 커뮤니티 글 목록·상세·댓글 목록 API가 include 결과를 그대로 내보내, 게스트 글의 비밀번호 해시·IP 해시·이메일이 공개 응답에 들어 있었다. Q&A 글 30건 중 12건이 해당됐다.
  • 당장은 라우트마다 비밀 필드 제거 함수를 거치게 했고, 원칙은 "공개 응답은 select allowlist로만"이다.
  • IP는 처음엔 솔트 해시만 저장했지만, 스팸 대응 때문에 평문 IP를 병행 저장하는 쪽으로 바꿨다. 관리자 화면에서만 보이고 공개 응답에서는 지운다.
삽질 기록 · 응답 JSON을 직접 열어 보기 전까진 몰랐다
사이트 보안 점검을 하면서 공개 API 응답을 하나씩 브라우저로 열어 봤다. 화면에는 제목, 본문, 작성자 이름만 나오니 문제될 게 없어 보였다. 그런데 커뮤니티 Q&A 글 목록 응답의 원본 JSON을 펼쳐 보니, 게스트로 쓴 글 객체 안에 guestPassword 필드가 그대로 있었다. 값은 $2로 시작하는 bcrypt 해시였다. 그 옆에는 IP 해시와 이메일도 있었다. 세어 보니 Q&A 글 30건 중 12건이 게스트 글이었고, 그 12건 모두 해당 필드가 응답에 실려 있었다. 글 상세 API와 댓글 목록 API도 같은 구조였다. 화면은 멀쩡했으니, 응답을 직접 열어 보지 않았다면 계속 몰랐을 것이다.

PART 01원인: include는 "관계 추가"가 아니라 "전부 + 관계"다

코드를 보니 이유는 단순했다. 글 목록을 가져오는 쿼리가 작성자 정보와 댓글 수를 붙이려고 include를 쓰고 있었고, 그 결과를 가공 없이 NextResponse.json()에 넘기고 있었다. 나는 include를 "관계를 추가로 가져오는 옵션"쯤으로 생각하고 있었다. 틀린 말은 아닌데, 중요한 절반을 잊고 있었다. include를 쓰면 본 모델의 스칼라 필드는 하나도 빠짐없이 같이 온다.

게스트 글 기능을 만들 때 Post 모델에 게스트용 필드를 추가했다. 작성자 이름, 수정·삭제용 비밀번호 해시, IP 해시, 연락용 이메일. 필드를 추가하는 순간, 그 모델을 include나 기본 findMany로 읽어서 내보내던 모든 공개 API가 자동으로 새 필드를 응답에 싣기 시작한 것이다. 아무 코드도 안 바꿨는데 노출 범위가 넓어졌다.

include로 전부 내보내던 쿼리와 select allowlist
// 수정 전: include 결과를 그대로 내보냈다
const posts = await prisma.post.findMany({
  where: { type: 'qna', published: true },
  include: { author: true, _count: { select: { comments: true } } },
  orderBy: { createdAt: 'desc' },
})
return NextResponse.json(posts) // guestPassword, guestIpHash, guestEmail까지 포함

// 바람직한 방향: 공개 응답은 select allowlist로만
const posts = await prisma.post.findMany({
  where: { type: 'qna', published: true },
  select: {
    id: true, title: true, slug: true, excerpt: true, createdAt: true,
    guestName: true,
    author: { select: { id: true, name: true } },   // author도 필요한 필드만
    _count: { select: { comments: true } },
  },
  orderBy: { createdAt: 'desc' },
})
return NextResponse.json(posts)

author: true도 같은 문제를 품고 있다는 걸 이때 같이 깨달았다. 관계 모델 역시 스칼라 필드 전부가 붙는다. 회원 모델에 민감한 필드가 있다면 그것도 같이 나간다. 그래서 관계도 select로 필요한 필드만 고르는 게 맞다. 위 예시는 원리를 보여 주는 단순화된 코드이고, 필드 이름은 이해를 돕기 위한 것이다.

영삼
내 생각
진짜 원인은 특정 코드 한 줄이 아니라 "기본값이 전부 내보내기"인 ORM을 아무 생각 없이 쓰는 습관이라고 본다. ORM은 편하자고 모든 필드를 돌려주고, 나는 편하자고 그걸 그대로 응답에 넣었다. 둘 다 편한 쪽을 택한 결과가 유출이다. 공개 응답만큼은 편의가 아니라 의도로 필드를 골라야 한다.

PART 02당장의 수정: 비밀 필드 제거 함수

원칙적으로는 모든 공개 쿼리를 select로 다시 쓰는 게 맞다. 하지만 글 목록·상세·댓글처럼 화면 여러 곳이 이 응답 모양에 기대고 있어서, 한 번에 다 바꾸면 다른 곳이 깨질 위험이 있었다. 그래서 먼저 출혈부터 막았다. 각 라우트에서 응답을 내보내기 직전에 비밀 필드를 지우는 함수 stripGuestSecrets를 거치게 했다.

공개 응답 직전에 거치는 비밀 필드 제거 함수(예시)
const GUEST_SECRET_KEYS = ['guestPassword', 'guestIpHash', 'guestIp', 'guestEmail'] as const

// 관리자 세션이 아니면 게스트 비밀 필드를 지운다. 중첩된 comments 배열도 처리한다.
export function stripGuestSecrets<T>(data: T, isAdmin: boolean): T {
  if (isAdmin || data == null) return data
  if (Array.isArray(data)) return data.map((x) => stripGuestSecrets(x, false)) as T
  if (typeof data !== 'object' || data instanceof Date) return data
  const out: Record<string, unknown> = {}
  for (const [k, v] of Object.entries(data as Record<string, unknown>)) {
    if ((GUEST_SECRET_KEYS as readonly string[]).includes(k)) continue
    out[k] = typeof v === 'object' ? stripGuestSecrets(v, false) : v
  }
  return out as T
}

// 사용: return NextResponse.json(stripGuestSecrets(posts, session?.role === 'admin'))

그리고 규칙을 하나 정했다. 앞으로 include로 Post, Comment, Review 모델을 공개 응답에 내보낼 때는 반드시 이 함수를 거친다. 관리자 세션일 때만 원본을 그대로 돌려준다. 관리자 화면에서는 게스트 글을 관리하려면 이 정보가 필요하기 때문이다.

솔직히 이건 denylist라서 마음이 편하지는 않다. 나중에 누가 게스트용 필드를 하나 더 추가하고 목록에 넣는 걸 잊으면 또 샌다. 그래서 이 함수는 응급 처치로 보고, 공개 API를 하나씩 select allowlist로 옮기는 중이다. Prisma에는 특정 필드를 빼는 omit 옵션도 있는데, Prisma 6 무렵 정식 기능이 된 것으로 알려져 있다. 전역으로 비밀 필드를 기본 제외하는 데는 유용하지만, 이것도 결국 "뺄 것을 적는" 방식이라 새 필드에는 무력하다는 점은 같다.

denylist (strip · omit)응급 처치
  • 기존 코드를 거의 안 바꾸고 바로 적용
  • 알고 있는 비밀 필드만 막는다
  • 새 필드가 생기면 목록 갱신을 잊기 쉽다
  • 중첩 관계까지 챙겨야 한다
allowlist (select · DTO)원칙
  • 내보낼 필드를 명시적으로 고른다
  • 새 필드는 기본적으로 안 나간다
  • 응답 모양이 코드에 드러나 리뷰가 쉽다
  • 쿼리를 다시 써야 해서 처음 손이 많이 간다
bcrypt 해시라서 괜찮지 않나? 해시는 비밀번호 원문이 아니지만, 유출되면 공격자가 서버와 상관없이 오프라인에서 마음껏 대입 공격을 할 수 있는 재료가 된다. bcrypt는 느리게 설계되어 있어 시간이 걸릴 뿐, 짧거나 흔한 비밀번호는 결국 뚫린다. 게스트 비밀번호는 사람들이 다른 곳에서 쓰는 비밀번호를 그대로 넣는 경우가 많아서 더 위험하다.

PART 03재발 방지: 응답에 금지 키가 없는지 검사한다

이번 사고의 교훈은 "화면은 멀쩡해도 응답은 아닐 수 있다"였다. 그러니 검사도 화면이 아니라 응답 JSON을 대상으로 해야 한다. 공개 API 몇 개를 호출해 JSON 전체를 재귀로 훑고, 금지된 키 이름이 하나라도 나오면 실패하는 짧은 스크립트를 만들어 두면 배포 전에 돌려 볼 수 있다.

공개 응답 금지 키 검사 스크립트(경로는 예시)
// 공개 API 응답에 금지 키가 섞여 있는지 검사 (로그인하지 않은 상태로 호출)
const BASE = process.env.BASE_URL || 'http://localhost:3000'
const ENDPOINTS = ['/api/community?type=qna', '/api/community/some-post', '/api/comments?postId=1']
const FORBIDDEN = /^(guestPassword|guestIpHash|guestIp|guestEmail|password|passwordHash|email)$/

function findKeys(node, path = '$', hits = []) {
  if (Array.isArray(node)) node.forEach((v, i) => findKeys(v, path + '[' + i + ']', hits))
  else if (node && typeof node === 'object')
    for (const [k, v] of Object.entries(node)) {
      if (FORBIDDEN.test(k)) hits.push(path + '.' + k)
      findKeys(v, path + '.' + k, hits)
    }
  return hits
}

;(async () => {
  let bad = 0
  for (const ep of ENDPOINTS) {
    const hits = findKeys(await (await fetch(BASE + ep)).json())
    console.log(hits.length ? 'LEAK' : 'ok  ', ep, hits.slice(0, 5).join(', '))
    bad += hits.length
  }
  process.exit(bad ? 1 : 0)
})()

키 이름으로만 잡으니 완벽하진 않다. 값 쪽에서 $2a$, $2b$로 시작하는 bcrypt 형식이나 이메일 형식을 같이 찾으면 이름을 바꿔 단 필드도 잡을 수 있다. 중요한 건 이걸 사람 눈이 아니라 반복 가능한 검사로 만들어 두는 것이다. 나는 한 번 눈으로 열어 보고서야 문제를 알았지만, 다음 번엔 스크립트가 먼저 알려 주길 바란다.

영삼
내 생각
이런 사고는 응답을 직접 눈으로 열어 보기 전엔 절대 안 보인다. 테스트는 보통 "원하는 값이 있는가"만 확인하지 "있으면 안 되는 값이 없는가"는 확인하지 않는다. 공개 API를 만드는 사람이라면 한 번쯤은 로그아웃한 상태에서 원본 JSON을 끝까지 펼쳐 보길 권한다. 생각보다 많은 게 들어 있다.

PART 04게스트 IP, 해시만 둘 것인가 평문도 둘 것인가

같은 점검에서 IP 처리도 다시 봤다. 처음 설계는 개인정보 최소 수집에 충실했다. 평문 IP는 저장하지 않고, 솔트를 붙인 IP를 SHA-256으로 해시해 앞 16자만 남겼다. 이 값은 "같은 사람이 연달아 쓰는가"를 판단하고 요청 횟수를 제한하는 데만 썼다. 원래 IP로 되돌릴 수 없으니 유출돼도 피해가 작다.

IP를 어디서 읽느냐도 중요했다. 내 서버는 nginx 뒤에서 돌고, nginx가 실제 접속 IP로 x-real-ip 헤더를 덮어써서 앱에 넘긴다. 그래서 앱은 이 헤더 하나만 믿고, 클라이언트가 보낼 수 있는 다른 헤더는 보지 않는다. 실제로 curl로 X-Real-IP에 엉뚱한 값을 넣어 요청해 봤고, 저장된 값은 위조한 값이 아니라 실제 접속 IP 기준이었다. 이 헤더를 신뢰할 수 있는 건 프록시가 덮어쓰기 때문이지, 헤더 자체가 믿을 만해서가 아니다.

문제는 운영이었다. 스팸과 악성 글이 들어오기 시작하자 차단을 해야 했는데, 해시 16자만으로는 차단 판단이 어려웠다. 같은 대역에서 오는지, 알려진 프록시인지, 다른 글과 어떤 관계인지 볼 수가 없다. 결국 리뷰, 댓글, 게스트 글에 평문 IP를 해시와 병행 저장하기로 바꿨다. 평문 IP는 관리자 화면에서만 보이고, 공개 응답에서는 앞서 만든 제거 함수가 지운다. 바꾸기 전에 쓰인 옛 글은 해시만 있어서 IP를 복원할 방법이 없다. 그건 그대로 둔다.

게스트 IP 저장 방식 비교
저장 방식장점단점내 판단
해시만유출돼도 원래 IP를 알 수 없음, 최소 수집 원칙에 충실차단·신고 대응 때 대역이나 출처를 볼 수 없음처음엔 이걸로 충분하다고 봤다
평문만운영 대응이 쉽고 직관적유출 시 바로 개인정보 피해, 공개 응답 실수가 치명적해시 기반 요청 제한까지 포기할 이유는 없었다
둘 다해시는 동일인 판단·요청 제한, 평문은 관리자 차단 판단저장 항목이 늘고 노출 통제를 더 신경 써야 함지금 쓰는 방식. 대신 노출 범위를 관리자 화면으로 한정
영삼
내 생각
1인 운영자에게 평문 IP는 필요악이라고 본다. 혼자서 스팸을 막으려면 결국 IP를 봐야 한다. 다만 필요하다고 해서 넓게 두면 안 된다. 보이는 곳은 관리자 화면 하나로 좁히고, 공개 응답에서는 반드시 지우고, 오래된 기록은 일정 기간이 지나면 지우는 쪽으로 보관 기간을 줄여 가는 게 맞다. 보관 기간을 정하는 건 아직 내 숙제로 남아 있다.
이번 사고에서 바꾼 것
  1. Prisma include와 기본 조회는 모든 스칼라 필드를 돌려준다는 걸 전제로 코드를 읽는다.
  2. 공개 응답은 select allowlist로 만들고, 그 전까지는 비밀 필드 제거 함수를 반드시 거친다.
  3. 로그아웃 상태로 공개 API 원본 JSON을 열어 보고, 금지 키 검사 스크립트로 반복 확인한다.
  4. IP는 프록시가 덮어쓰는 헤더 하나만 믿고, 해시와 평문을 목적별로 나눠 쓴다.
  5. 평문 IP는 관리자 화면에만, 보관 기간은 점점 좁혀 간다.

자주 묻는 질문

Prisma include와 select의 차이는 무엇인가요?

include는 본 모델의 모든 스칼라 필드에 지정한 관계를 추가로 붙여 돌려주고, select는 지정한 필드와 관계만 돌려준다. 그래서 include 결과를 그대로 API 응답에 쓰면 모델에 있는 민감한 필드까지 나갈 수 있다. 외부에 공개하는 응답에는 select로 필요한 필드만 고르는 게 안전하다.

Prisma에서 비밀번호 같은 필드를 응답에서 빼려면 어떻게 하나요?

select로 필요한 필드만 고르는 방법이 가장 확실하다. 특정 필드만 빼고 싶다면 omit 옵션을 쓸 수 있는데, Prisma 6 무렵 정식 기능이 된 것으로 알려져 있다. 다만 omit이나 응답 후처리로 지우는 방식은 새 필드가 추가될 때 놓치기 쉬우니 allowlist가 원칙이다.

bcrypt 비밀번호 해시가 유출되면 위험한가요?

위험하다. 해시가 있으면 공격자가 서버 접속 없이 오프라인에서 대입 공격을 반복할 수 있다. bcrypt는 계산이 느려 시간을 벌어 줄 뿐이고, 짧거나 흔한 비밀번호는 결국 알아낼 수 있다. 유출을 알게 되면 응답을 고치는 것과 함께 해당 비밀번호를 바꾸도록 하는 조치도 고려해야 한다.

Next.js에서 클라이언트 IP는 어떤 헤더를 믿어야 하나요?

리버스 프록시가 실제 접속 IP로 덮어쓰는 헤더 하나만 믿어야 한다. 예를 들어 nginx가 X-Real-IP를 접속 주소로 설정해 넘긴다면 그 헤더만 읽는다. 클라이언트가 임의로 보낼 수 있는 헤더를 그대로 믿으면 IP를 위조할 수 있으니, curl로 위조 헤더를 보내 실제로 무시되는지 확인해 보는 게 좋다.

게스트 작성자 IP를 저장해도 되나요?

스팸 차단 같은 운영 목적이라면 저장할 수 있지만 최소한으로 다뤄야 한다. 동일인 판단과 요청 제한에는 솔트를 붙인 해시로도 충분하고, 평문이 필요하다면 관리자만 보게 하고 공개 응답에서는 반드시 제거한다. 보관 기간을 정해 오래된 기록을 지우는 것도 필요하다.

참고 자료

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록