멀쩡해 보이던 API 라우트에 숨어 있던 mass assignment
경험을 토대로 AI를 활용하여 작성한 글 입니다.
취업 준비 플래너 앱을 프로덕션 수준으로 끌어올리기 위해 전체 코드 감사를 돌렸다. 인증 가드도 전 라우트에 걸려 있고, Supabase RLS도 모든 테이블에 적용돼 있어서 보안은 꽤 탄탄하다고 생각했는데, 감사 결과 가장 높은 우선순위로 지목된 건 의외로 이 한 줄이었다.
const body = await request.json()
const { data, error } = await supabase
.from('tasks')
.insert({ ...body, user_id: user.id })
.select()
.single()모든 API 라우트가 이 패턴이었다. 요청 본문을 파싱해서 user_id만 덮어쓰고 그대로 DB에 넣는다. 겉보기엔 문제없다. user_id는 스프레드 뒤에 오니까 클라이언트가 타인의 user_id를 보내도 덮어써지고, RLS가 한 번 더 막아준다.
문제는 user_id가 아닌 나머지 모든 컬럼이다.
클라이언트는 폼이 보내는 것만 보내지 않는다
앱의 태스크 생성 폼은 title, scope, target_date, category, priority만 보낸다. 하지만 API 입장에서 클라이언트는 폼이 아니라 curl을 든 누군가일 수도 있다.
curl -X POST https://…/api/tasks \
-H "Cookie: <내 세션>" \
-d '{
"title": "주입 시도",
"scope": "daily",
"target_date": "2026-07-10",
"is_completed": true,
"completed_at": "2020-01-01T00:00:00Z",
"created_at": "2020-01-01T00:00:00Z"
}'수정 전 코드에서는 이 요청이 그대로 통과한다. 생성과 동시에 완료 처리된 태스크, 6년 전에 만들어진 것처럼 보이는 레코드가 만들어진다. 이 앱에서는 "완료 기록 통계"가 조작되는 정도지만, 패턴 자체가 위험하다. 이런 걸 mass assignment라고 부른다 — 클라이언트가 쓸 수 있어야 하는 필드의 범위를 서버가 정의하지 않아서, 테이블의 모든 컬럼이 쓰기 가능해지는 문제.
"RLS가 있으니 괜찮지 않나?"라고 생각했는데, RLS는 행 단위 방어다. "이 행이 네 것이냐"는 막아주지만, "네 행의 이 컬럼에 이 값을 넣어도 되느냐"는 관심 밖이다. 방어선이 서로 다른 층위를 담당하고 있었고, 컬럼 층위는 비어 있었다.
덤으로 발견한 것: 에러 메시지가 너무 솔직했다
같은 감사에서 이 패턴도 전 라우트에 있었다.
if (error) return Response.json({ error: error.message }, { status: 500 })Postgres/PostgREST의 원본 에러 메시지를 그대로 응답에 싣는다. 잘못된 값을 보내면 클라이언트가 이런 걸 받는다.
new row for relation "tasks" violates check constraint "tasks_priority_check"
테이블명, 컬럼명, 제약 이름까지 — 공격자에게는 스키마 지도나 다름없다. 게다가 유효성 문제든 중복이든 미존재든 전부 500이라, 클라이언트는 에러 종류를 구분할 방법도 없었다.
해결: 스키마가 곧 화이트리스트
라우트가 11개 파일이라 각각 고치는 대신 공통 계층을 만들었다. 핵심은 세 가지다.
1. withAuth — 반복되던 인증 가드를 흡수
export function withAuth<Ctx = unknown>(
handler: (request: NextRequest, auth: AuthContext, ctx: Ctx) => Promise<Response>
) {
return async (request: NextRequest, ctx: Ctx): Promise<Response> => {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 })
return handler(request, { supabase, user }, ctx)
}
}2. parseBody + zod 스키마 — 정의된 키만 통과
export const createTaskSchema = z.object({
title: z.string().trim().min(1).max(200),
description: z.string().max(2000).nullish(),
scope: z.enum(['daily', 'weekly', 'monthly']),
target_date: z.iso.date(),
category: taskCategory.optional(),
priority: z.union([z.literal(1), z.literal(2), z.literal(3)]),
})zod의 z.object()는 기본 동작이 스키마에 없는 키를 제거(strip) 하는 것이다. 즉 스키마가 곧 "쓰기 가능한 컬럼 화이트리스트"가 된다. is_completed: true를 몰래 끼워 보내도 파싱 결과에서 사라진다. 값 범위도 DB의 CHECK 제약과 일치시켜서, 잘못된 값이 DB까지 가기 전에 400으로 끊긴다.
3. dbError — 에러 코드만 보고 매핑, 원본 메시지는 서버 로그로
const DB_ERROR_MAP: Record<string, { status: number; message: string }> = {
'23505': { status: 409, message: '이미 존재하는 데이터입니다.' },
'23503': { status: 400, message: '참조하는 데이터가 존재하지 않습니다.' },
'23514': { status: 400, message: '허용되지 않는 값입니다.' },
PGRST116: { status: 404, message: '데이터를 찾을 수 없습니다.' },
}
export function dbError(error: PostgrestError): Response {
console.error('[api] database error:', error.code, error.message)
const mapped = DB_ERROR_MAP[error.code]
return Response.json(
{ error: mapped?.message ?? '서버 오류가 발생했습니다.' },
{ status: mapped?.status ?? 500 }
)
}세 조각을 합치면 라우트 하나가 이렇게 줄어든다.
export const POST = withAuth(async (request, { supabase, user }) => {
const parsed = await parseBody(request, createTaskSchema)
if (!parsed.ok) return parsed.response
const { data, error } = await supabase
.from('tasks')
.insert({ ...parsed.data, user_id: user.id })
.select()
.single()
if (error) return dbError(error)
return Response.json(data, { status: 201 })
})...parsed.data 스프레드는 그대로지만, 이제 스프레드되는 건 raw body가 아니라 스키마를 통과한 값뿐이다.
검증: 코드를 믿지 말고 요청을 날려보기
빌드가 통과했다고 끝이 아니라서, dev 서버를 띄우고 e2e 테스트 계정 세션으로 실제 요청을 날려 확인했다.
# user_id·is_completed·미지의 컬럼을 주입해본다
curl -X POST http://localhost:3101/api/tasks -H "Cookie: $COOKIE" \
-d '{"title":"주입 시도","scope":"daily","target_date":"2026-07-10",
"user_id":"00000000-…","is_completed":true,"evil_column":"x"}'
# → 201이지만 응답을 보면:
# user_id: 내 것, is_completed: false, evil_column: 없음 — 전부 제거됨- 잘못된 enum(
scope: "yearly") → 400 + 어떤 필드가 왜 틀렸는지 상세 - 깨진 JSON → 400 (전에는 500)
- 존재하지 않는 id PATCH → 404 (전에는 500 + 원본 PostgREST 메시지)
- 기존 클라이언트가 보내는 payload(토글, 프로필, 목표)는 전부 그대로 동작
기존 동작을 깨지 않았는지가 사실 제일 걱정이었는데, 스키마를 만들기 전에 클라이언트 훅들이 실제로 보내는 payload를 전부 grep해서 목록화해둔 게 도움이 됐다. 예를 들어 토글 mutation은 is_completed와 함께 completed_at을 직접 보내고 있어서, update 스키마에 completed_at: z.iso.datetime().nullish()를 빠뜨렸다면 체크박스가 전부 400으로 터졌을 것이다.
남은 이야기
이번 작업에 몇 가지를 같이 태웠다.
- OAuth 콜백의
next파라미터를 내부 경로만 허용하도록 수정 (open redirect 방어) - 미들웨어 보호 경로에 빠져 있던 페이지 추가
- 채용공고 URL은
http(s)스킴만 허용 —javascript:alert(1)같은 값이 DB에 들어가는 것 자체를 서버에서 차단
그리고 프로젝트 컨벤션 문서의 Route Handler 규칙을 "인증 가드로 시작할 것"에서 "withAuth로 감싸고 본문은 스키마로 검증할 것"으로 갱신했다. 규칙이 코드보다 낡으면 다음 라우트는 다시 옛 패턴으로 작성되기 때문이다.
돌아보면 이 문제의 본질은 "검증 코드를 안 짰다"가 아니라 **"클라이언트가 쓸 수 있는 필드의 경계를 서버 어디에도 선언해두지 않았다"**는 것이었다. zod 스키마는 검증기이기 전에 그 경계의 선언이고, 덕분에 이제 각 테이블에 대해 "클라이언트가 건드릴 수 있는 것"이 코드 한 곳에 문서처럼 남아 있다.