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

  • 보안 리뷰 질문은 어떤 순서로 던져야 실질적인 결함을 찾을 수 있는가?
  • “취약한가요?”보다 나은 질문은 무엇인가?
  • 리뷰 결과를 테스트, 로그, 요구사항 보완으로 어떻게 남기는가?

개요

보안 리뷰 질문지는 체크박스를 채우는 문서가 아니다. 리뷰어가 코드와 API 계약을 따라가며 “어디서 신뢰하는가”, “누가 무엇을 할 수 있는가”, “실패가 증거로 남는가”를 확인하는 작업 도구다. 질문이 추상적이면 답도 추상적이다.

나쁜 질문: 이 API는 안전한가?
좋은 질문: 이 API의 `orderId`는 어느 계층에서 principal의 소유권과 함께 검증되는가?

좋은 질문은 파일과 테스트로 이어진다. 답을 찾을 수 없으면 설계나 코드가 부족하다는 신호다.

공격 시나리오

다음 PR이 올라왔다고 가정한다.

feature: 주문 부분 환불 API 추가
endpoint: POST /orders/{orderId}/refunds
actor: customer, support_agent
state change: payment refund requested
asset: order ownership, payment transaction, refund amount, audit log

리뷰어는 기능 설명만 읽지 않는다. 요청 값이 어디서 신뢰되는지 코드 경로를 따라간다.

취약한 요청/응답 예시

POST /orders/9182/refunds HTTP/1.1
Host: api.example.com
Cookie: SESSION=customer-session
Content-Type: application/json
{
  "userId": 1004,
  "amount": 50000,
  "reason": "wrong item",
  "supportOverride": true
}
HTTP/1.1 200 OK
Content-Type: application/json
 
{
  "refundId": 321,
  "orderId": 9182,
  "status": "APPROVED",
  "internalPaymentId": "pay_raw_123"
}

이 요청 하나로 여러 질문이 나온다.

  • userId는 왜 body에 있는가?
  • orderId가 principal의 주문인지 어디서 확인하는가?
  • amount는 서버 계산값인가, 클라이언트 입력값인가?
  • supportOverride는 고객이 보낼 수 있는 필드인가?
  • 응답에 내부 결제 식별자가 필요한가?
  • 승인과 거부가 감사 로그로 남는가?

주제별 핵심 판단

보안 리뷰는 다음 순서로 진행한다.

1. 기능의 상태 변경과 보호 자산을 찾는다.
2. actor와 entry point를 나눈다.
3. 클라이언트가 보낸 값 중 보안 결정에 쓰이는 값을 표시한다.
4. 인증, 인가, 입력 검증, 출력 제한, 로그 위치를 따라간다.
5. 실패 경로와 남는 증거를 확인한다.
6. 테스트가 위 질문을 실제로 검증하는지 확인한다.

이 순서가 없으면 리뷰는 “Spring Security 설정이 있어 보인다” 수준에서 멈추기 쉽다.

개선된 요청/응답 예시

[P1] `RefundRequest.userId`를 권한 판단에 사용하지 마세요.
현재 요청 body의 userId와 orderId로 환불 대상을 찾고 있어, 로그인 사용자와 다른 사용자의 주문을 조합할 수 있습니다.
principal.customerId로 소유권 조건을 걸고, userId 필드는 request DTO에서 제거해야 합니다.
거부 케이스는 audit event `refund_decision result=denied reason=ownership_mismatch`로 남겨 주세요.

좋은 리뷰 코멘트는 문제, 공격 경로, 수정 방향, 검증 방법을 같이 담는다.

방어 설계

보호 자산 질문:

  • 이 기능이 읽거나 바꾸는 보호 자산은 무엇인가?
  • 개인정보, 결제 상태, 권한, token, 로그, export 파일이 포함되는가?
  • 자산별로 읽기, 생성, 수정, 삭제, export action이 나뉘어 있는가?

actor 질문:

  • 이 요청의 actor는 어디서 만들어지는가?
  • body, query, header에 있는 userId, role, admin, ownerId를 권한 판단에 쓰지 않는가?
  • 고객, 관리자, 내부 batch, webhook, 파트너 서버가 같은 경로를 공유하는가?

entry point 질문:

  • public API, admin API, webhook, batch, file upload, message consumer가 같은 Service를 호출하는가?
  • 각 entry point마다 다른 검증과 로그가 필요한가?
  • UI에 버튼이 없다는 이유로 서버 검증을 생략하지 않았는가?

trust boundary 질문:

  • 어느 계층에서 외부 입력이 서버가 만든 값으로 바뀌는가?
  • Gateway header, forwarded IP, Origin, redirect URL, webhook signature를 어디서 검증하는가?
  • 내부 API라는 이유로 인증과 인가를 생략하지 않았는가?

권한 질문:

  • 인증과 인가가 분리되어 있는가?
  • role 체크 뒤에 resource ownership 체크가 있는가?
  • 자기 자신, 같은 조직, 위임 관리자, 만료된 권한 같은 예외를 테스트하는가?

로그 질문:

  • 성공뿐 아니라 거부, 반복 실패, 정책 위반도 로그에 남는가?
  • actor, asset, action, result, reason, trace id가 구조화되어 있는가?
  • token 원문, 비밀번호, 전체 개인정보, 카드번호가 로그에 남지 않는가?

Spring/HTTP 예시

public record RefundRequest(
    @NotBlank String reason
) {
}
@PostMapping("/orders/{orderId}/refunds")
public ResponseEntity<RefundResponse> refund(
        @AuthenticationPrincipal CustomerPrincipal principal,
        @PathVariable long orderId,
        @Valid @RequestBody RefundRequest request) {
 
    RefundResult result = refundService.requestRefund(
        new CustomerRefundCommand(principal.customerId(), orderId, request.reason()));
 
    return ResponseEntity.accepted().body(RefundResponse.safe(result));
}
@Transactional
public RefundResult requestRefund(CustomerRefundCommand command) {
    Order order = orderRepository.findByIdAndCustomerId(command.orderId(), command.customerId())
        .orElseThrow(() -> {
            audit.refundDenied(command.customerId(), command.orderId(), "ownership_mismatch");
            return new AccessDeniedException("order not found");
        });
 
    Money amount = refundPolicy.calculateCustomerRefund(order);
    Refund refund = refundRepository.request(order.id(), amount, command.reason());
    audit.refundRequested(command.customerId(), order.id(), refund.id(), amount.maskForLog());
    return RefundResult.accepted(refund.id());
}

리뷰 질문이 DTO 제거, Repository 조건, 감사 로그, 응답 축소로 이어져야 한다.

운영 로그와 감사 지점

level=WARN event=security_review_gap_found pr=184 file=RefundService.java question=resource_ownership result=missing endpoint=/orders/{orderId}/refunds reviewer=kim trace_id=review-184-02
level=WARN event=refund_decision result=denied actor_type=customer actor_id=1004 asset_type=order asset_id=9182 action=request_refund reason=ownership_mismatch trace_id=91ac7701

리뷰 과정 자체도 기록으로 남길 수 있다. 특히 권한 변경, 결제, 개인정보 export 같은 고위험 기능은 리뷰에서 발견한 gap을 이슈나 체크리스트로 남겨 다음 PR에서 재사용한다.

실전 판단 기준

  • 리뷰 질문마다 대응하는 테스트나 로그 확인 방법이 있는가?
  • 좋은 리뷰 질문은 endpoint, actor, resource, action, 실패 로그 중 최소 하나를 구체적으로 지목한다.
  • 질문지가 체크리스트로만 끝나면 안 되고 수정 PR, 테스트, 운영 로그 항목으로 이어져야 한다.

테스트 포인트

  • 다른 사용자의 orderId로 요청했을 때 403 또는 404로 거부되는가?
  • body에 예상 밖 필드 userId, role, supportOverride를 넣어도 무시되거나 거부되는가?
  • 고객, 상담원, 관리자 경로가 서로 다른 policy와 audit event를 거치는가?
  • 실패 응답이 내부 결제 식별자나 stack trace를 노출하지 않는가?
  • 리뷰에서 발견한 gap이 요구사항 ID나 테스트 이름으로 남았는가?

위험 신호!

  • 질문지가 “인증 적용 여부”, “SQL Injection 여부” 같은 예/아니오 항목만 있다.
  • Controller annotation만 보고 Service와 Repository 조건을 보지 않는다.
  • 성공 테스트만 있고 비소유자, 낮은 권한, 만료 token, 중복 요청 테스트가 없다.
  • 클라이언트 DTO의 필드가 왜 필요한지 설명하지 못한다.
  • 리뷰 코멘트가 “보안상 위험해 보입니다”에서 끝나고 공격 경로와 수정 기준이 없다.
  • 로그 검증을 리뷰 범위에서 제외한다.

확인 질문

확인 질문

  • 좋은 보안 리뷰 질문의 조건은 무엇인가?
    • 특정 값, 계층, actor, asset, 실패 경로를 따라가며 코드와 테스트로 검증할 수 있어야 한다.
  • 리뷰에서 DTO 필드를 보는 이유는 무엇인가?
    • 클라이언트가 보낸 값이 보안 결정에 섞이는 순간 권한 우회와 값 조작이 생기기 때문이다.
  • 리뷰 결과는 어디에 남겨야 하는가?
    • PR 코멘트, 요구사항 ID, 테스트 케이스, 감사 로그 필드, 재사용 가능한 체크리스트로 남겨야 한다.

참고 문서