AI 면접 플랫폼 AILego에는 "답변 재처리" 기능이 있다. 면접 영상 청크 업로드나 후처리(STT, 채점)가 실패한 답변을 관리자가 버튼 한 번으로 다시 돌리는 기능이다. 응시자는 이미 면접을 마쳤으니 영상을 다시 받을 수 없고, 서버에 남은 청크로 파이프라인만 다시 태우면 된다.

기능 자체는 단순하다. 문제는 이 엔드포인트가 멀티테넌트 환경에서 다른 회사 데이터를 건드릴 수 있는 구조라는 점이다. 구현 후 AI에게 보안 리뷰를 시켰고, 요청이 들어와 DB·큐·스토리지에 닿기까지의 흐름을 끝까지 추적한 결과를 정리한다. 중심 주제는 하나다. 테넌트 식별자는 절대 클라이언트가 말하게 두지 않는다.

엔드포인트

 
POST /api/v1/admin/answers/{id}/reprocess
 

failed 상태의 답변을 uploaded로 되돌리고 ProcessAnswerJob을 큐에 다시 넣는다. 경로에는 답변 id만 있다. 회사 정보는 어디에도 없다.

필터 체인: 위에서 아래로 한 겹씩

라우트에 걸린 필터는 순서대로 이렇다.

필터 역할
rate_limit:60 (그룹) 관리자 API 전체에 대한 분당 상한
admin_auth:company_admin,hr_manager 역할 게이트. 이 두 역할만 통과
tenant JWT에서 company_id를 꺼내 요청 컨텍스트에 고정
rate_limit:10 (라우트) 재처리 경로만의 더 좁은 상한

리뷰에서 먼저 확인한 것은 CI4 라우트 그룹의 필터가 덮어쓰기가 아니라 병합된다는 점이다. 그룹의 rate_limit:60과 라우트의 rate_limit:10이 둘 다 적용되는지, 한쪽이 다른 쪽을 지워버리는지는 프레임워크 동작이라 코드만 봐서는 안심할 수 없다. RouteCollection::create가 둘을 합친다는 것을 확인하고 나서야 "경로별 레이트리밋"이 실제로 걸려 있다고 말할 수 있었다. 형제 엔드포인트인 re-evaluate와 같은 10/분 + 60/분 구성이라는 것도 같이 맞춰봤다.

핵심: company_id는 JWT에서 나온다

서비스 호출부는 이렇게 생겼다.

 
$this->service->reprocessForAdmin($answerId, TenantContext::requireCompanyId());
 

TenantContext는 tenant 필터가 JWT 클레임에서 채운 값이다. 요청 본문, 쿼리스트링, 경로 어디에서도 회사 ID를 받지 않는다.

이유는 간단하다. 클라이언트가 보낸 값은 공격자가 고를 수 있는 값이기 때문이다. POST /answers/123/reprocess?company_id=7처럼 받으면, 로그인한 A사 관리자가 company_id=B사를 넣어 보는 순간 모든 방어가 서비스 내부의 "이 값을 믿어도 되나?"라는 질문에 달린다. 반면 JWT는 서버가 서명했으므로 클라이언트가 바꿀 수 없다. 테넌트를 입력이 아니라 인증의 결과로 취급하면, "남의 테넌트를 지정한다"는 공격 자체가 성립하지 않는다.

여기에 한 겹이 더 있다. 서비스는 답변의 소유 회사를 요청이 아니라 DB에서 가져온다. mediaContext()가 answers → candidates를 조인해 company_id를 읽고, 이를 JWT의 값과 비교한다.

 
 
if ($context['company_id'] !== $companyId) {
return 'not_found'; // 404로 매핑
}
 

JWT의 회사와 답변이 속한 회사가 다르면 not_found를 돌려준다. 이 부분에서 리뷰가 짚은 세부 사항이 있다.

  • 403이 아니라 404다. 403을 주면 "그 ID의 답변은 존재하지만 네 것이 아니다"라고 알려주는 셈이라 다른 테넌트의 리소스 존재 여부가 샌다. 진짜로 없는 ID와 남의 ID가 같은 404로 보이게 만들어 존재 여부 탐지(enumeration)를 막는다.
  • 회사 검사가 상태 검사보다 먼저다. 남의 답변에 대해 "이미 처리됨", "재처리 불가 상태" 같은 다른 메시지가 나오면 그 차이가 곧 정보 유출이다. 테넌트 확인을 가장 앞에 둬서 남의 데이터에는 어떤 상태 정보도 닿지 않게 했다.
  • HR 매니저의 세션 범위 검사는 그 뒤에 온다. SessionAccess::allows($sessionId)는 테넌트 검사를 통과한 뒤에만 호출된다. 순서가 반대였다면, 권한 부여 여부의 응답 차이로 다른 테넌트의 세션 ID를 탐색할 수 있다.
  • 게이트와 액션이 같은 필드를 본다. 권한 판단에 쓰는 company_id/session_id와 실제 스토리지 키를 만드는 MediaKeys::answerChunk(...)에 쓰는 값이 같은 컨텍스트에서 나온다. 검사는 A를 보고 실행은 B를 하는 불일치가 없다.

싱크(sink)를 하나씩 확인

입력이 닿는 끝점들도 따로 봤다.

  • 스토리지: storage->size($key)의 키는 전부 DB에서 읽은 정수로 조립된다. 사용자 문자열이 경로에 섞이지 않으므로 경로 조작이 불가능하다.

  • 상태 전이: reopenForReprocess는 조건부 UPDATE다.

     
    UPDATE answers SET status = 'uploaded'
    WHERE id = ? AND status = 'failed' AND chunk_count >= 1
     

    "읽고 나서 검사하고 쓰기"가 아니라 조건을 쿼리 안에 넣었기 때문에, 버튼을 연타하거나 두 관리자가 동시에 눌러도 한 건만 전이되고 큐에도 한 번만 들어간다. 동시성 문제(TOCTOU)를 락 없이 DB에 맡긴 형태다.

  • 감사 로그: 메타에는 {candidate_id, chunk_count}만 남는다. 개인정보도 비밀값도, 공격자가 임의로 넣을 수 있는 문자열도 없다.

  • 응답: 답변 ID(정수)와 리터럴 'uploaded'뿐이라 입력을 되돌려 반사하는 부분이 없다. 뷰·JS도 Alpine x-text만 쓰므로 XSS 싱크가 없고, Bearer 토큰 방식이라 쿠키 기반 CSRF 면도 없다.

  • 에러 메시지: 운영자용 한국어 문구일 뿐 자격 증명이나 모델의 자유 텍스트가 섞이지 않는다.

슈퍼 관리자는 왜 막혔나

슈퍼 관리자는 company_id가 NULL이다. 이 엔드포인트의 역할 게이트가 company_admin,hr_manager만 허용하므로 슈퍼 관리자는 필터 단계에서 걸러진다. 혹시 뚫리더라도 TenantContext::requireCompanyId()가 회사 ID 없이는 예외를 던지고, AuditLogService::record()도 역할이 없으면 LogicException을 던진다. 한 겹이 뚫려도 다음 겹이 막는 구조(defense in depth)다.

리뷰가 남긴 것

이번 리뷰에서 새로 도입된 취약점은 없었다. 대신 두 가지가 기록으로 남았다.

  1. 기존 동작 확인: 세션 권한이 하나도 없는 HR 매니저는 모든 세션에 접근 가능하다(allowedSessionIds가 []일 때 null을 반환). 이 PR이 만든 문제는 아니고 SessionAccess에 의도된 동작으로 문서화돼 있다. 다만 "권한 없음 = 전체 허용"은 의도라 해도 가장 먼저 의심해 볼 지점이다.
  2. 호출부 안전성: 생성자 파라미터를 $queue 앞에 끼워 넣었는데, 모든 호출부가 이름 있는 인자(named args)를 쓰고 있어 위치 인자가 어긋나는 사고가 없었다. 생성자 시그니처를 바꿀 때 자주 놓치는 부분이다.

정리

  • 테넌트 ID는 입력이 아니라 인증의 결과로 다룬다. JWT 서명이 곧 신뢰의 근거다.
  • 그 위에 DB에서 읽은 소유 회사와 대조하는 한 겹을 더 둔다. 둘이 어긋나면 404.
  • 남의 리소스는 "없음"과 구별되지 않게 응답한다. 존재 여부도 정보다.
  • 검사 순서가 곧 정보 노출 순서다. 테넌트 확인 → 세부 권한 → 상태 검사 순으로 둔다.
  • 상태 전이는 조건부 UPDATE 하나로 끝내 중복 실행을 막는다.
  • 레이트리밋은 그룹과 라우트 단위로 병합되는지 프레임워크 동작까지 확인한다.

AI 리뷰의 가치는 "취약점을 찾았다"보다 흐름을 끝까지 따라갔다는 것에 있었다. 필터 병합 방식, 검사 순서, 게이트와 액션의 필드 일치처럼 사람이 코드 리뷰에서 "대충 맞겠지" 하고 넘어가기 쉬운 지점을 하나씩 근거와 함께 확인해 주었다. 다만 결론은 사람이 한 번 더 읽고 책임진다. 특히 위의 "권한 0개면 전체 허용" 같은 항목은 AI가 의도라고 판단한 것을 팀이 정말 의도한 것인지 다시 묻는 일이 남는다.