Skip to content
isdnetworks
Go back

하위 처리가 상위 권한을 물려받고 있었다

관리자만 쓸 수 있어야 하는 기능이 다른 경로로 열려 있었다.

Table of contents

Open Table of contents

증상 — 바깥에만 있던 권한 확인

관리자 화면 쪽은 이렇게 돼 있었다.

class AdminOrderController extends CI_Controller {
    public function __construct() {
        parent::__construct();
        if (!$this->auth->isAdmin()) { show_404(); }
    }

    public function forceCancel($orderNo) {
        $this->orderService->forceCancel($orderNo);
    }
}

들어오는 자리에서 isAdmin 을 보고 아니면 막는다. 여기까지는 맞다.

실제 처리를 하는 쪽에는 확인이 없었다.

class OrderService {
    public function forceCancel(int $orderNo): void {
        // 권한 확인 없음
        $this->orders->updateState($orderNo, 'CANCELED');
        $this->refund->request($orderNo);
    }
}

밖에서 확인했으니 안에서는 안 해도 된다고 본 것이다.

AdminOrderController 쪽에만 두는 방식에는 전제가 있다. 부르는 쪽이 전부 그 사정을 알고 있어야 성립한다. 새 경로를 만드는 사람이 forceCancel 안에 확인이 없다는 것을 알아야 한다. 그런데 안에 없으니 알 길이 없다.

다른 경로가 있었다

일반 사용자가 쓰는 API에서 같은 것을 부르고 있었다.

class OrderApiController extends CI_Controller {
    public function cancel($orderNo) {
        if (!$this->auth->isOwner($orderNo)) { show_404(); }
        $order = $this->orders->find($orderNo);
        if ($order->state === 'SHIPPED') {
            $this->orderService->forceCancel($orderNo);   // ← 여기
        }
    }
}

발송된 주문을 취소하려고 강제 취소를 그대로 쓴 것이다.

isOwner 로 본인인지는 봤지만 강제 취소는 관리자만 할 수 있어야 하는 처리였다. isOwnerisAdmin 은 서로 다른 질문인데 앞의 것만 하고 뒤의 것은 안 한 셈이다.

구멍은 새 경로가 생긴 그날 함께 생겼다. OrderService 안에 확인이 없으니 부르는 경로가 늘어난 만큼 구멍이 생겼다. 이 코드를 쓴 사람에게는 forceCancel 을 재사용한 것이었을 뿐이다.

조치 — 안쪽에도 확인을 넣었다

누가 부르는지를 인자로 받게 했다.

public function forceCancel(int $orderNo, Actor $actor): void {
    if (!$actor->can('order.force_cancel')) {
        throw new Forbidden('강제 취소 권한이 없습니다');
    }
    ...
}

부르는 쪽이 Actor 를 넘겨야 하고 can 이 거짓이면 Forbidden 으로 그 자리에서 멈춘다.

고치는 일 자체는 한 줄이었다. 인자를 더해도 부르는 자리가 저절로 드러나지는 않는다. 그래서 grep 으로 forceCancel 을 부르는 자리를 전부 뽑아 하나씩 고쳤다. 그 목록에 우리가 몰랐던 경로가 하나 더 있었다.

넘길 주체가 없는 자리도 있었다.

// 배치에서 부르는 자리
$this->orderService->forceCancel($no, Actor::system('auto_cancel_batch'));

auto_cancel_batch 처럼 사람이 아니라 배치가 부르는 곳이었다.

여기만 확인을 건너뛰게 하면 그 길이 다시 구멍이 된다. Actor::system 을 만들어 넘기니 누가 한 것인지가 남고 건너뛰는 길도 안 생겼다.

검증 — 기록과 지난 41건

무엇을 누가 했는지 남기게 했다.

$this->auditLog->write([
    'action'   => 'order.force_cancel',
    'target'   => $orderNo,
    'actor'    => $actor->id(),
    'actor_type' => $actor->type(),
    'at'       => date('Y-m-d H:i:s'),
]);

actor_type 을 따로 둔 것은 사람과 배치를 나중에 갈라 보기 위해서였다. 둘을 한 칸에 담으면 그 구분을 다시 문자열로 풀어야 한다.

기록을 넣고 나서 지난 것부터 훑었다. 일반 사용자 경로로 강제 취소가 나간 것이 41건 있었다. 그 41건을 하나씩 확인했다.

actor_type 이 사용자로 찍힌 41건 중 대부분은 실제로 취소가 필요한 건이었다. 다만 정상 절차를 안 거쳐서 정산에 반영이 안 된 것이 넷 있었고 그 넷은 따로 처리해야 했다.

설정 — 권한 목록과 역할 표

권한 이름을 한 곳에 모았다.

final class Permission {
    public const ORDER_VIEW         = 'order.view';
    public const ORDER_CANCEL       = 'order.cancel';
    public const ORDER_FORCE_CANCEL = 'order.force_cancel';
    public const ORDER_REFUND       = 'order.refund';
}

역할과 권한은 표로 이었다.

CREATE TABLE role_permission (
  role       varchar(30) NOT NULL,
  permission varchar(50) NOT NULL,
  PRIMARY KEY (role, permission)
);

코드에서는 역할 이름을 안 보고 order.force_cancel 같은 권한 이름만 본다. 역할은 표에만 있고 코드에는 안 나온다.

역할이 하나 늘어도 코드는 그대로이고 role_permission 에 줄을 넣으면 된다. isAdmin 처럼 역할 이름을 코드에 적어 두면 역할이 생길 때마다 조건문이 함께 늘어난다.

확인이 빠진 곳을 찾았다

다른 자리에도 같은 구멍이 있는지 셌다.

$ grep -rn "public function" src/Service/*.php | wc -l
182
$ grep -rn "\$actor->can(" src/Service/*.php | wc -l
34

182개 중 34개만 확인하고 있었다. 나머지가 전부 문제인 것은 아니다.

조회만 하는 것은 이 확인이 필요 없어서 바꾸는 것만 골라 봤다.

$ grep -rn "public function \(create\|update\|delete\|cancel\|approve\|reject\)" src/Service/*.php

47개가 나왔다. 그중 확인이 있는 것이 34개라 열셋이 남았다.

열셋을 하나씩 보니 여덟은 넣어야 했고 다섯은 applyApproval 처럼 안쪽에서만 부르는 것이라 필요 없었다. 필요 없는 다섯에는 왜 없는지를 적어 뒀다.

/* 이 메서드는 approve() 안에서만 부른다. 권한은 그쪽에서 확인한다. */
private function applyApproval(int $no): void { ... }

주석 한 줄이면 되는 일이었다. 적어 두지 않으면 다음 사람이 빠뜨린 것으로 보고 같은 검토를 다시 한다. 없는 것과 일부러 안 넣은 것은 코드만 봐서는 구분되지 않는다.

정리


Share this post on:

Previous Post
두 칸에 같은 글
Next Post
화면과 서버가 규격이 달랐다