접근 제어 결함은 프레임워크를 아무리 업그레이드해도 살아남는 버그 유형입니다. ORM이 SQL을 이스케이프하고 템플릿 엔진이 HTML을 이스케이프하지만, 인보이스 4812가 Bob이 아니라 Alice의 것이라는 사실은 스택의 어떤 부분도 알지 못합니다. 그 지식은 여러분의 코드 안에만 있으며, 핸들러 하나가 그것을 적용하지 않는 순간 로그인한 아무 사용자나 다른 사람의 데이터를 읽거나 바꿀 수 있습니다.
가장 흔한 형태는 안전하지 않은 직접 객체 참조, 즉 IDOR입니다. 클라이언트가 식별자를 보내면 서버가 그 식별자로 레코드를 조회하고, 호출자가 그것을 볼 권한이 있는지는 아무도 확인하지 않습니다. 악용하는 데 특별한 도구가 필요하지도 않습니다. 브라우저와 두 번째 계정, 그리고 URL에서 숫자 하나를 바꾸는 것으로 충분합니다. 위험한 함수 호출을 찾는 스캐너는 이 문제를 거의 잡아내지 못합니다. 취약한 코드에 위험한 요소가 전혀 없기 때문입니다. 지극히 평범한 데이터베이스 조회에서 조건 하나가 빠져 있을 뿐입니다.
마주치게 될 세 가지 CWE
- CWE-639, 사용자가 제어하는 키를 통한 인가 우회: 전형적인 IDOR입니다. 공격자가 제어하는 ID로 레코드를 선택하고, 소유권은 전혀 확인하지 않습니다.
- CWE-862, 인가 누락: 핸들러가 인가 검사를 아예 수행하지 않습니다. 외부에서 접근할 수 없다고 가정한 관리자용 또는 내부용 엔드포인트인 경우가 많습니다.
- CWE-285, 부적절한 인가: 검사가 있기는 하지만 잘못되었습니다. 엉뚱한 필드를 확인하거나, 쓰기 작업에 읽기 권한을 확인하거나, 클라이언트가 보낸 역할을 신뢰합니다.
이 구분은 버그를 고칠 때 의미가 있습니다. 검사가 없다면 하나 추가하면 되지만, 검사가 잘못되었다면 누가 무엇을 할 수 있는지에 대한 모델 자체가 틀린 것이고, 같은 실수가 다른 곳에도 반복되어 있을 가능성이 높습니다. 둘 중 무엇을 발견하든 티켓을 닫기 전에 같은 형태의 코드를 먼저 찾아보세요. 접근 제어 버그는 좀처럼 일회성으로 끝나지 않습니다. 팀이 핸들러에서 핸들러로 복사해 온 패턴을 그대로 따라갑니다.
Next.js 라우트 핸들러에서 벌어지는 일
가장 흔한 형태의 패턴을 보겠습니다. 핸들러는 사용자를 인증하는데, 이것이 보안처럼 느껴집니다. 그러고는 ID만으로 레코드를 조회합니다. 세션 검사는 누가 호출하는지에 답할 뿐, 이 호출자가 이 인보이스를 볼 수 있는지에는 아무도 답하지 않습니다.
// app/api/invoices/[id]/route.ts (취약)
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
// 로그인한 사용자라면 누구나 ID만 바꿔 모든 인보이스를 읽을 수 있습니다
const invoice = await db.invoice.findUnique({ where: { id } });
return NextResponse.json(invoice);
}해결책은 소유권을 잊어버릴 수 있는 별도의 단계가 아니라 쿼리 자체의 일부로 만드는 것입니다. 레코드가 호출자의 것이 아니면 데이터베이스는 아무것도 반환하지 않고, 핸들러는 404로 응답합니다.
// app/api/invoices/[id]/route.ts (수정)
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const invoice = await db.invoice.findFirst({
where: { id, userId: session.user.id },
});
if (!invoice) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(invoice);
}403이 아니라 404를 반환하는 것은 의도적입니다. 403은 레코드가 존재한다는 사실을 확인해 주므로, 공격자가 읽지 못하더라도 유효한 ID를 열거할 수 있게 됩니다. 응답 시간과 오류 메시지도 마찬가지입니다. 다른 사람의 레코드에 대한 응답은 애초에 존재하지 않는 레코드에 대한 응답과 구분되지 않아야 합니다.
REST: 사람들이 잊어버리는 엔드포인트
팀들은 보통 눈에 잘 띄는 ID 기반 GET은 잘 보호합니다. 버그는 다른 메서드와 API의 가장자리에 숨어 있습니다.
- 소유권 검사가 추가되기 전의 GET 핸들러를 복사해 만든 PATCH와 DELETE 핸들러.
- /projects/:projectId/tasks/:taskId 같은 중첩 라우트로, 프로젝트는 확인하지만 태스크는 taskId만으로 조회해 다른 프로젝트에 속할 수도 있는 경우.
- ID 배열을 받으면서 첫 번째 항목만 확인하는 일괄 처리 엔드포인트.
- 파일 다운로드와 내보내기 작업으로, 자체적인 더 느슨한 검사를 가진 별도 서비스를 거치는 경우가 많습니다.
- 요청 본문에서 ownerId, organizationId, role을 받아 그대로 데이터베이스에 기록하는 업데이트 페이로드(매스 어사인먼트).
GraphQL은 공격면을 더 넓힙니다
GraphQL에서는 같은 객체에 여러 경로로 도달할 수 있습니다. invoice(id)에 쿼리 수준 검사를 걸어 두어도, 같은 인보이스가 customer { invoices }나 node(id) 조회, 뮤테이션의 반환 타입으로도 도달 가능하다면 소용이 없습니다. 객체를 반환하는 모든 리졸버가 진입점입니다. DataLoader 같은 배칭 계층은 또 다른 함정입니다. ID만으로 키를 만드는 로더는 어떤 조회자에게든 레코드를 그대로 돌려주며, 요청 간에 공유되면 캐시가 한 사용자의 데이터를 이후 요청에 제공할 수 있습니다.
확실한 접근법은 리졸버 자체가 아니라 리졸버가 호출하는 데이터 계층에서 인가를 수행하는 것입니다. 인보이스로 가는 모든 경로가 조회자를 받아 쿼리 범위를 한정하는 하나의 함수를 거친다면, 새 필드나 관계를 추가해도 그것을 우회할 수 없습니다. 뮤테이션 입력도 확인하세요. 입력 타입의 ownerId 같은 필드는 레코드를 재할당해 달라는 초대장이나 다름없습니다. 마지막으로, 인트로스펙션과 오류 메시지가 스키마를 드러낸다는 점을 기억하고, 공격자가 여러분이 노출한 모든 필드와 관계를 알고 있다고 가정하세요.
Python에서의 같은 버그
SQLAlchemy를 쓰는 FastAPI에서도 형태는 동일합니다. 취약한 버전은 db.get(Document, doc_id)를 호출하고, 수정된 버전은 같은 구문 안에서 소유자로 필터링합니다.
from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
app = FastAPI()
@app.get("/documents/{doc_id}")
def get_document(
doc_id: int,
user: User = Depends(current_user),
db: Session = Depends(get_db),
):
# 취약: doc = db.get(Document, doc_id)
doc = db.scalar(
select(Document).where(
Document.id == doc_id,
Document.owner_id == user.id,
)
)
if doc is None:
raise HTTPException(status_code=404, detail="Not found")
return doc오래가는 수정
모든 쿼리를 소유자나 테넌트로 한정하세요
모든 읽기와 쓰기의 WHERE 절에 사용자 ID나 조직 ID를 넣으세요. 이렇게 하면 인가가 쿼리의 속성이 되어 리뷰에서 쉽게 눈에 띕니다. 멀티테넌트 앱이라면 Postgres의 행 수준 보안으로 테넌트 경계를 두 번째 계층에서 강제할 수 있어, 필터를 빠뜨려도 다른 고객의 행 대신 아무것도 반환되지 않습니다. 같은 범위 한정은 쓰기에도 적용됩니다. 업데이트는 읽기와 검사, 별도의 쓰기로 나뉘어 경쟁 상태를 만드는 대신, ID와 소유자 두 조건으로 필터링한 단일 구문이어야 합니다. 예를 들어 두 조건을 모두 건 updateMany를 실행한 뒤 정확히 한 행이 바뀌었는지 확인하는 식입니다.
판단을 한곳에 모으세요
여기저기 흩어진 if 문은 시간이 지나면서 서로 어긋납니다. 리소스별로 하나씩 만든 작은 헬퍼 모음은 규칙을 한곳에 유지해 주고, 헬퍼를 호출하지 않는 핸들러가 눈에 띄게 해 줍니다.
// lib/authz.ts
type Role = "owner" | "member" | "viewer";
type Action = "read" | "update" | "delete";
const policy: Record<Role, ReadonlySet<Action>> = {
owner: new Set<Action>(["read", "update", "delete"]),
member: new Set<Action>(["read", "update"]),
viewer: new Set<Action>(["read"]),
};
export class NotFoundError extends Error {}
export async function requireProject(
userId: string,
projectId: string,
action: Action
) {
const membership = await db.membership.findFirst({
where: { userId, projectId },
include: { project: true },
});
// 기본 거부: 멤버십이 없는 경우와 권한이 없는 경우가 똑같이 보입니다
if (!membership || !policy[membership.role as Role]?.has(action)) {
throw new NotFoundError();
}
return membership.project;
}기본값은 거부로
알 수 없는 역할, 존재하지 않는 멤버십, 예상치 못한 동작은 모두 거부로 떨어져야 합니다. 미들웨어가 있는 프레임워크라면 모든 것에 인증을 요구하고 공개 라우트만 명시적으로 표시하세요. 그 반대가 아닙니다. 새 라우트는 누군가 달리 결정하기 전까지 잠겨 있어야 합니다. 역할과 테넌트, 사용자 ID를 요청 본문이나 클라이언트가 설정한 헤더에서 가져오지 마세요. 매번 서버에서 검증된 세션으로부터 도출하세요.
UUID 같은 무작위 식별자는 쓸 만하지만 해결책은 아닙니다. ID는 URL과 로그, 공유 링크, 리퍼러 헤더를 통해 새어 나갑니다. 열거를 느리게 만든다는 의미에서만 추측하기 어렵다고 보고, 결코 접근 검사로 삼지 마세요.
테스트하는 방법
IDOR 테스트는 단순하고 반복적이며, 그래서 한 번 손으로 해 본 뒤에는 자동화할 가치가 있습니다. 목록 작성부터 시작하세요. 식별자를 받는 모든 라우트와 리졸버, 백그라운드 작업을 나열하되 요청 본문과 쿼리 문자열, 헤더에 숨어 있는 ID도 포함하세요.
- 계정 A와 B를 만드세요. 가능하면 서로 다른 조직에 두 계정을 만듭니다. A로 레코드를 생성하고 그 ID를 기록하세요.
- 그 ID를 참조하는 모든 요청을 B의 세션으로 다시 보내 보세요. GET, PATCH, DELETE, 다운로드, 내보내기, 그리고 해당 레코드를 건드리는 모든 GraphQL 쿼리와 뮤테이션이 대상입니다.
- 전부 404가 나와야 합니다. 200은 물론이고 존재를 확인해 주는 403도 모두 발견 사항입니다.
- 수동 검사를 리소스별 통합 테스트로 옮겨, 범위가 한정되지 않은 쿼리를 쓰는 새 핸들러가 CI에서 실패하도록 하세요.
- findUnique({ where: { id } })나 db.get(Model, id)처럼 기본 키만으로 조회하는 코드를 검색해 찾고, 각각에 대해 타당한 이유를 대 보세요.
코드 리뷰는 테스트가 놓치는 것을 잡아냅니다. 그 라우트에 대한 테스트를 아무도 작성하지 않았더라도 빠진 검사는 소스에 그대로 드러나기 때문입니다. CodeAuditAgent는 공개 GitHub 저장소나 붙여넣은 스니펫을 읽고 접근 제어 공백을 CWE, 인용한 코드 줄, 공격 시나리오, 제안 패치와 함께 보고하므로, 모든 핸들러를 한 번에 다시 훑어보는 빠른 방법이 됩니다.
짧은 체크리스트
- 클라이언트가 제공한 ID를 받는 모든 쿼리가 호출자의 사용자 또는 테넌트로도 필터링한다.
- 인가는 복사해 붙인 if 문이 아니라 공용 헬퍼나 데이터 계층에 있다.
- 알 수 없는 경우는 거부하고, 공개 라우트는 명시적인 예외로 둔다.
- 일괄 처리와 중첩 라우트를 포함해 쓰기 작업도 읽기만큼 꼼꼼히 검사한다.
- 리소스마다 두 계정 테스트가 있고 CI에서 실행된다.