Cheondi
개발 · 보안

OWASP API 보안으로 다시 본 계좌 전환

계좌를 바꾸며 API를 다시 호출하는 화면을 OWASP API 보안 관점에서 보며 인증과 객체 단위 권한 검사의 경계를 정리했습니다.

  • #owasp
  • #api-security
  • #authorization
  • #bola
  • #account

화면에서 계좌를 선택하면 주문 목록이나 잔액을 다시 불러오는 기능은 구현만 보면 어렵지 않다. 선택된 accountId를 URL이나 요청 본문에 넣고 API를 호출하면 된다. 처음에는 프런트에서 로그인 사용자의 계좌만 선택지에 넣었으니 충분히 안전해 보였다.

OWASP API Security Top 10을 읽으면서 이 생각을 다시 보게 됐다. 2023 목록의 첫 항목인 BOLA(Broken Object Level Authorization)는 사용자가 전달한 객체 ID에 대해 서버가 실제 소유권을 확인하지 않을 때 생긴다. 화면에 다른 ID가 보이지 않는 것과 서버가 접근을 거부하는 것은 전혀 다른 이야기였다.

인증 뒤에 남는 질문

인증은 “누구인지”를 확인한다. 객체 단위 인가는 “이 사용자가 이 계좌를 볼 수 있는지”를 매 요청마다 확인한다.

GET /accounts/example-account-02/orders HTTP/1.1
Host: api.example.com
Authorization: Bearer <access-token>

토큰이 유효해도 example-account-02가 그 사용자 소유라는 보장은 없다. 브라우저 개발자 도구나 직접 만든 요청으로 경로 값을 바꿀 수 있기 때문에, 드롭다운을 비활성화하거나 ID를 숨겨도 보안 경계가 되지 않는다.

취약한 형태를 단순화하면 다음과 같다.

[HttpGet("/accounts/{accountId}/orders")]
public async Task<IResult> GetOrders(string accountId)
{
    var orders = await orderStore.FindByAccount(accountId);
    return Results.Ok(orders);
}

로그인 미들웨어가 앞에 있어도 이 핸들러는 요청한 계좌가 누구 것인지 확인하지 않는다.

객체 조회와 권한 검사를 한 경계에 두기

한 가지 방법은 사용자 ID와 계좌 ID를 함께 조건으로 조회하는 것이다.

[Authorize]
[HttpGet("/accounts/{accountId}/orders")]
public async Task<IResult> GetOrders(
    string accountId,
    ClaimsPrincipal principal)
{
    var userId = principal.FindFirstValue("sub");
    var account = await accountStore.FindOwnedAccount(userId, accountId);

    if (account is null)
        return Results.NotFound();

    return Results.Ok(await orderStore.FindByAccount(account.Id));
}

NotFoundForbidden 중 무엇을 쓸지는 API 정책에 따라 다르다. 존재 여부 자체를 숨겨야 한다면 같은 응답을 주는 편이 낫다. 중요한 것은 응답 코드 선택보다, 요청에 들어온 ID를 신뢰하지 않고 인증 주체와 함께 검증하는 것이다.

확인 항목 클라이언트 역할 서버 역할
선택 가능한 계좌 표시 사용 편의 제공 목록도 권한 범위로 조회
accountId 전송 현재 선택 전달 소유권·역할 검증
버튼 숨김 잘못된 조작 방지 작업 권한을 다시 판정
오류 문구 이해 가능한 안내 정보 노출이 없는 상태 코드
로그 요청 추적 ID 전달 거부 사유와 대상 식별자를 안전하게 기록

목록 API도 같은 문제

상세 조회만 막으면 끝이라고 생각하기 쉽다. 검색, 내보내기, 일괄 처리도 필터를 빠뜨리면 다른 객체가 섞일 수 있다.

SELECT order_id, state, created_at
FROM orders
WHERE account_id = @account_id
  AND account_id IN (
    SELECT account_id
    FROM user_accounts
    WHERE user_id = @user_id
  )
ORDER BY created_at DESC;

실제 구조에서는 역할, 조직, 상품 같은 조건이 더 붙을 수 있다. 이런 조건이 핸들러마다 복사되면 한 군데에서 빠질 가능성이 커진다. 저장소 메서드나 정책 계층이 “권한이 확인된 객체”만 반환하도록 만들면 검토 지점이 줄어든다.

테스트에서 ID를 바꿔 보기

정상 흐름만 테스트하면 BOLA를 찾기 어렵다. 최소한 아래 경우를 따로 확인해야 한다.

  • 내 계좌 ID로 조회하면 성공한다.
  • 존재하지만 내 소유가 아닌 ID는 거부된다.
  • 존재하지 않는 ID도 정책에 맞는 동일한 형태로 실패한다.
  • 목록·상세·수정·삭제가 모두 같은 권한 규칙을 사용한다.
  • 캐시 키가 사용자 권한 범위를 포함한다.
  • 로그에는 전체 토큰이나 민감한 계좌 정보가 남지 않는다.

계좌 전환 UI는 상태 관리 문제이면서 동시에 API 권한 경계를 드러내는 화면이었다. 프런트에서 올바른 ID를 보내는 것은 정상 사용자를 위한 약속이고, 서버에서 소유권을 확인하는 것은 잘못된 요청까지 포함한 보안 규칙이다. 둘을 분리해서 생각한 뒤부터 API를 볼 때 “로그인했는가” 다음에 “이 객체를 볼 권한이 있는가”를 꼭 묻게 됐다.

코드 리뷰에서도 URL에 ID가 있다는 사실 자체를 문제로 보지는 않게 됐다. 식별자는 요청에 필요할 수 있다. 확인할 것은 그 값을 바꿨을 때 서버가 어떤 조건으로 객체를 찾는지, 다른 조회 경로와 캐시에서도 같은 조건이 유지되는지다. 화면 캡처보다 권한이 다른 두 테스트 주체로 요청을 바꿔 보는 검증이 훨씬 직접적인 증거가 됐다.

참고: OWASP API1:2023 Broken Object Level Authorization