이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • 보호 자산, actor, entry point를 기능 설계에서 어떻게 분리하는가?
  • 고객센터 조회, export, webhook 같은 정상 기능이 왜 공격 경로가 되는가?
  • Spring 백엔드에서 자산 접근 정책과 감사 로그를 어디에 두어야 하는가?

개요

위협 모델링의 첫 문장은 “이 기능은 안전해야 한다”가 아니다. “어떤 자산을, 어떤 actor로부터, 어떤 진입점에서 보호해야 하는가”가 먼저다. Asset은 DB table 하나가 아니다. 회원 이메일, 휴대폰 번호, 비밀번호 재설정 token, 관리자 메모, 결제 상태, 권한 변경 이력, 로그 export 파일, 운영 secret처럼 유출되거나 조작되면 피해가 생기는 모든 대상이다. Actor는 사람의 직책만 뜻하지 않는다. 고객, 고객센터 직원, 보안 운영자, 관리자, 파트너 서버, batch job, 공격자가 탈취한 세션처럼 권한과 의도가 다른 실행 주체를 나누는 말이다.

Entry Point는 HTTP URL만 뜻하지 않는다. 브라우저 화면, 모바일 API, 관리자 API, OAuth callback, webhook, file upload, message queue, 배치 입력 파일, 운영 콘솔이 모두 진입점이 될 수 있다.

공격 시나리오

고객센터 조회 기능을 만든다고 가정한다.

정상 흐름은 상담원이 상담 티켓을 열고, 회원의 연락처와 주문 상태를 확인한 뒤 상담 기록을 남기는 것이다.

공격자는 이 정상 흐름을 이용한다.

  • 상담원 계정을 탈취하거나 권한이 낮은 내부 계정으로 로그인한다.
  • 브라우저 개발자 도구에서 memberIdincludeSecurityNote 값을 확인한다.
  • 자신에게 배정되지 않은 회원 ID로 요청을 바꾼다.
  • UI에는 보이지 않는 includeSecurityNote=trueexport=true 필드를 추가한다.
  • 서버가 actor의 범위와 entry point별 허용 action을 나누지 않으면 민감 메모와 대량 export가 노출된다.

이 공격은 이상한 패킷이 아니다. 인증된 사용자가 정상 URL에 정상 JSON을 보내는 모양이다.

취약한 요청/응답 예시

POST /admin/members/9182/profile/export HTTP/1.1
Host: admin.example.com
Cookie: ADMIN_SESSION=valid-support-session
Content-Type: application/json
 
{
  "requestedBy": "support-agent-17",
  "includePhone": true,
  "includeSecurityNote": true,
  "includePasswordResetToken": true,
  "format": "csv"
}
HTTP/1.1 200 OK
Content-Type: text/csv
 
memberId,email,phone,securityNote,passwordResetToken
9182,user@example.com,010-1234-5678,"fraud suspected",pr-raw-token-value

문제는 인증 여부가 아니다. 서버가 actor를 세분화하지 않고, entry point별 허용 자산을 구분하지 않으며, export action을 별도 위험 행위로 보지 않는 점이다.

주제별 핵심 판단

자산, actor, entry point를 나누지 않으면 권한 검사가 “로그인했는가” 수준에서 멈춘다.

예를 들어 고객센터 직원은 회원의 배송지 일부를 볼 수 있어도 비밀번호 재설정 token, 보안 메모, 결제 수단 원문, 전체 주민등록번호를 볼 수 없어야 한다. 같은 관리자 화면이라도 조회, export, 수정, 권한 부여는 서로 다른 action이다.

좋은 모델은 다음처럼 적힌다.

asset: member_security_note
actor: support_agent
entry_point: /admin/members/{memberId}/security-note
action: read
allow: assigned_ticket_exists AND mfa_verified AND reason_code_present
deny: self_lookup OR no_ticket OR bulk_export
log: actor, asset_id, action, reason_code, ticket_id, result

이 정도로 적어야 Controller, Service, Repository, 로그, 테스트가 같은 정책을 향해 움직인다.

개선된 요청/응답 예시

자산 목록은 기능 목록보다 먼저 작성한다.

  • 개선된 흐름은 actor를 인증 컨텍스트에서 확정하고, 요청 body의 userId는 조회 후보로만 취급한다.
  • entry point별로 보호 자산과 실패 응답을 정해 두면 같은 기능도 공개 API, 관리자 API, batch에서 다른 통제를 적용할 수 있다.

방어 설계

member_profile_public: 이름, 닉네임
member_contact: 이메일, 휴대폰 번호
member_security_note: 보안 메모
password_reset_token: 재설정 token
member_export_file: 대량 다운로드 결과물

actor는 역할 이름만으로 끝내지 않는다.

customer: 자기 계정만 조회
support_agent: 배정된 티켓의 제한된 정보 조회
fraud_analyst: 보안 메모 조회 가능, export 제한
admin: 권한 변경 가능, MFA와 승인 필요
partner_webhook: 정해진 서명과 event type만 허용
batch_job: 사전 등록된 job identity와 입력 경로만 허용

entry point별로 action을 제한한다.

/me/profile                     customer read/update own profile
/admin/members/{id}/summary      support_agent read limited summary
/admin/members/{id}/security     fraud_analyst read security note
/admin/members/export            admin export with approval
/webhooks/partner/orders         partner_webhook create event only

이 구분이 있어야 “관리자는 다 된다” 같은 설계를 피할 수 있다.

Spring/HTTP 예시

Controller는 클라이언트가 보낸 actor 값을 믿지 않는다. actor는 인증 결과와 서버 내부 권한 조회에서 만들어야 한다.

@PostMapping("/admin/members/{memberId}/profile/export")
public ResponseEntity<MemberExportResponse> exportMember(
        @AuthenticationPrincipal AdminPrincipal principal,
        @PathVariable long memberId,
        @Valid @RequestBody MemberExportRequest request) {
 
    MemberExportCommand command = new MemberExportCommand(
        principal.adminId(),
        memberId,
        request.fields(),
        request.reasonCode(),
        request.ticketId());
 
    MemberExportResponse response = memberExportService.export(command);
    return ResponseEntity.accepted().body(response);
}

Service는 자산과 action 기준으로 정책을 판단한다.

@Transactional
public MemberExportResponse export(MemberExportCommand command) {
    ActorContext actor = actorResolver.resolveAdmin(command.adminId());
    AssetRef asset = AssetRef.member(command.memberId());
    EntryPoint entryPoint = EntryPoint.ADMIN_MEMBER_EXPORT;
 
    AccessDecision decision = assetPolicy.decide(
        actor,
        asset,
        Action.EXPORT_MEMBER_PROFILE,
        entryPoint,
        command.requestedFields(),
        command.reasonCode(),
        command.ticketId());
 
    audit.logMemberAccess(actor, asset, entryPoint, decision);
 
    if (decision.denied()) {
        throw new AccessDeniedException("member export denied");
    }
 
    return exportRepository.createMaskedExport(command.memberId(), decision.allowedFields());
}

Repository도 정책을 보완한다. 보안 메모와 token 같은 필드는 기본 조회 모델에 섞지 않고, 지원 업무용 조회는 티켓 배정 조건과 마스킹 view를 함께 사용한다.

운영 로그와 감사 지점

level=INFO event=asset_access_decision result=denied actor_type=support_agent actor_id=17 asset_type=member_security_note asset_id=9182 entry_point=admin_member_export action=export reason=no_ticket_assignment ticket_id=T-4421 trace_id=6b4f2a11

로그는 “관리자 API 호출”보다 더 구체적이어야 한다.

  • actor type과 actor id를 분리한다.
  • asset type과 asset id를 분리한다.
  • entry point와 action을 남긴다.
  • 허용과 거부 이유를 남긴다.
  • ticket id, approval id, reason code처럼 업무 근거를 남긴다.
  • token 원문, 보안 메모 본문, 전체 개인정보는 남기지 않는다.

감사 로그가 없으면 나중에 “누가 어떤 자산을 왜 봤는가”를 복원할 수 없다.

실전 판단 기준

  • 고객센터 직원이 자신에게 배정되지 않은 회원을 조회하면 403이 나는가?
  • asset, actor, entry point 중 하나라도 빠지면 위협 모델은 구현 리뷰로 이어지지 않는다.
  • 정상 사용자, 공격자, 운영자, 외부 시스템 actor를 같은 표에 두어야 예외 경로가 보인다.

테스트 포인트

  • includeSecurityNote, includePasswordResetToken, export 같은 예상 밖 필드를 보내면 무시하거나 거부하는가?
  • 같은 member id라도 요약 조회와 export가 다른 정책을 타는가?
  • 관리자 자신 또는 동료 계정 조회 같은 내부 오남용을 막는 규칙이 있는가?
  • 허용 이벤트와 거부 이벤트가 같은 필드 구조로 남는가?
  • 테스트 데이터에 customer, support_agent, fraud_analyst, admin, partner_webhook을 모두 포함했는가?

위험 신호!

  • 자산 목록 없이 API 목록만으로 보안 리뷰를 한다.
  • actor를 USER, ADMIN 두 개로만 나누고 업무 예외를 모두 role 안에 숨긴다.
  • requestedBy, actorType, admin=true 같은 클라이언트 입력을 권한 판단에 사용한다.
  • 관리자 화면에 보이지 않는 필드를 API가 그대로 처리한다.
  • export, bulk read, security note 조회를 일반 조회와 같은 로그로 남긴다.
  • webhook과 batch job을 “사람이 아니므로 안전하다”고 본다.

확인 질문

확인 질문

  • Asset, Actor, Entry Point를 먼저 정해야 하는 이유는 무엇인가?
    • 보호 대상, 접근 주체, 검증 위치가 정해져야 권한 정책과 로그 필드를 구체적으로 설계할 수 있기 때문이다.
  • 고객센터 조회 API에서 가장 위험한 설계 실수는 무엇인가?
    • 상담원이라는 actor 하나만 보고 자산별 접근 범위, 티켓 배정, export action을 분리하지 않는 것이다.
  • 클라이언트가 보낸 actor 값을 왜 믿으면 안 되는가?
    • actor는 공격자가 조작할 수 있는 입력이 아니라 인증 결과와 서버 내부 권한 조회로 만들어야 하는 보안 결정 근거이기 때문이다.

참고 문서