Skip to content
Thinkcmf Admin Rest Api Missing Authorization

Thinkcmf Admin Rest Api Missing Authorization

hackmd.io • October 16, 2026

# ThinkCMF admin REST API: fail-open RBAC lets any backend admin reach every admin endpoint

![type](

![cwe](

![cwe2](

![component](

![severity](

![status](

**Project:** [thinkcmf/thinkcmf]( ThinkCMF content management framework, admin REST API authorization

**Affected:** ThinkCMF 8.0.0 and 8.0.1 as shipped (composer packages thinkcmf/cmf-core v8.0.1, thinkcmf/cmf-api v8.0.1, thinkcmf/cmf-install v8.0.1)

**Class:** CWE-862 Missing Authorization, CWE-863 Incorrect Authorization

ThinkCMF 8.x authorizes its admin REST API with a check that fails open. Every admin REST controller extends `RestAdminBaseController`, whose `initialize()` requires only that the caller is an admin-type account and then calls `cmf_auth_check()` with a rule name built as `admin_api:METHOD|path`. `cmf_auth_check()` delegates to `cmf\lib\Auth::check()`, which looks the rule name up in the `auth_rule` table and, when no row matches, returns `true` without consulting the caller's roles. The default install seed populates `auth_rule` only with web-admin rule names of the form `app/Controller/action`. It seeds no rule name carrying the `admin_api:` prefix, and the admin UI that assigns API permissions writes grants into a different table (`auth_access`), not into `auth_rule`. The combination means the `auth_rule` lookup for every admin REST endpoint is always empty under a default install, so the fail-open branch always runs. Any authenticated admin-type account whose id is not 1 can invoke every admin REST endpoint regardless of its assigned role. That includes listing all members with their PII, enabling or disabling any member account, and rewriting the attacker's own role grants to gain full web-admin capability. The trust boundary crossed is between different backend administrators, a restricted admin reaching actions reserved for higher-privileged roles. The equivalent web admin controllers are not affected because their rule names are seeded, so the same check consults roles and denies.

The product ships from `github.com/thinkcmf/thinkcmf`. The release repository is a thin shell whose `composer.json` pulls the running code from separate composer packages. The admin REST API lives in `thinkcmf/cmf-api`, the base controllers and the auth library live in `thinkcmf/cmf-core`, and the default database seed lives in `thinkcmf/cmf-install`. All links below are pinned to the v8.0.1 tag of each package.

### Admin REST controllers one authorization gate

Admin REST controllers extend `cmf\controller\RestAdminBaseController`. Its `initialize()` runs on every request and is the only authorization decision for the admin API.

`cmf-core/src/controller/RestAdminBaseController.php`

class RestAdminBaseController extends RestBaseController

$this->error(['code' => 10001, 'msg' => '登录已失效!']);

$this->error(['code' => 10001, 'msg' => '登录已失效!']);

$requestMethod = $this->request->method();

$ruleName = "admin_api:$requestMethod|{$this->getRoutePath()}";

if (!cmf_auth_check($this->getUserId(), $ruleName)) {

$this->error(['code' => 0, 'msg' => '无权限!']);

The gate accepts any caller whose `userType` is 1, which is the admin account type. It then builds a rule name such as `admin_api:GET|admin/user/users` and hands it to `cmf_auth_check()`. A denial here is the only thing that stops a restricted admin from reaching a privileged endpoint.

### The caller type and token come from a database lookup

`RestBaseController::_initUser()` sets `userType` from the stored user row. The token is an opaque value matched in `user_token`, so this is not a token-forgery issue. A restricted admin simply uses the token its own account received at login.

`cmf-core/src/controller/RestBaseController.php`

$token = $this->request->header('Authorization', '');

if (substr($token, 0, 7) === 'Bearer ') {

$token = $this->request->header('XX-Token');

$deviceType = $this->request->header('XX-Device-Type');

if (!in_array($deviceType, $this->allowedDeviceTypes)) {

->where(['token' => $token, 'device_type' => $deviceType])

### cmf_auth_check delegates to the auth library

`cmf_auth_check()` passes a superadmin (id 1) and otherwise calls `Auth::check()`.

function cmf_auth_check($userId, $name = null, $relation = 'or', $roleType = 'admin')

$name = strtolower($app . "/" . $controller . "/" . $action);

return $authObj->check($userId, $name, $relation, $roleType);

A non-superadmin admin reaches `Auth::check()` with the `admin_api:` rule name supplied by `checkAccess()`.

### Auth::check fails open when the rule name is not in auth_rule

public function check($uid, $name, $relation = 'or', $roleType = 'admin')

$findAuthRule = AuthRuleModel::where('name', $name)->field('id')->find();

// ... role lookup and auth_access matching only runs past this point

The line `if (empty($findAuthRule)) { return true; }` is the defect. When the requested rule name has no matching row in `auth_rule`, the method returns `true` and never looks at the caller's roles or their grants in `auth_access`. The code states this is intentional for unregistered rules.

### The default install seeds no admin_api rule names

The default database content lists every web-admin rule as `app/Controller/action`. It contains no rule name with the `admin_api:` prefix.

`cmf-install/src/install/data/thinkcmf.sql` (shape of the seeded `cmf_auth_rule` rows)

CREATE TABLE IF NOT EXISTS `cmf_auth_rule` (

`id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,

-- 'admin/Hook/index', 'admin/Setting/site', 'user/AdminUser/index', ...

Because `checkAccess()` always prefixes the rule name with `admin_api:` and no such name is ever present in `auth_rule`, the `AuthRuleModel::where('name', $name)->find()` lookup inside `Auth::check()` is always empty for admin REST endpoints. The fail-open branch then always runs for any admin-type caller that is not the superadmin.

### The API permission UI writes to the wrong table to fix this

An administrator can assign API permissions in the backend through `RoleController::apiAuthorizePut`. That handler writes grants into `auth_access`, the per-role grant table, not into `auth_rule`, the rule-existence table consulted first by `Auth::check()`.

`cmf-api/src/admin/controller/RoleController.php`

$adminApiIds = $this->request->param('ids/a');

if (is_array($adminApiIds) && count($adminApiIds) > 0) {

AuthAccessModel::where(["role_id" => $roleId, 'type' => 'admin_api'])->delete();

$adminApi = AdminApiModel::where("id", $adminApiId)->field('url')->find();

$name = strtolower("admin_api:{$adminApi['url']}");

Even an administrator who deliberately configures API permissions does not create the `auth_rule` rows that `Auth::check()` needs, so the first lookup still returns empty and the check still fails open before it ever consults these `auth_access` grants.

### The web admin path is the correct contrast

The web admin controllers extend `AdminBaseController`, whose `checkAccess()` builds a rule name `app . controller . action` with no `admin_api:` prefix. Those names ARE seeded in `auth_rule`, so the same `cmf_auth_check` passes the empty-rule test, loads the caller's roles, matches `auth_access`, and denies when the grant is missing.

`cmf-core/src/controller/AdminBaseController.php`

$app = $this->app->http->getName();

$controller = $this->request->controller();

$rule = $app . $controller . $action;

$notRequire = ['adminIndexindex', 'adminMainindex'];

The web path enforces RBAC. The API path does not. The asymmetry is the finding.

participant A as Restricted admin (role 2, token)

participant R as RestAdminBaseController initialize

participant C as cmf_auth_check then Auth check

A->>R: call admin REST endpoint with token and device header

R->>R: user set, userType equals 1, passes type check

R->>C: check rule admin_api GET admin user users

C->>D: look up rule name with admin_api prefix

C-->>R: returns true because no rule exists

R->>E: dispatch action, no role check performed

E-->>A: full member list, status change, or role grant rewrite

Prerequisites. A default ThinkCMF 8.0.1 install. One restricted admin account exists, created by a superadmin, assigned the default 普通管理员 role (role id 2) or any custom admin role that is not superadmin and that was given only a subset of permissions. The restricted admin has authenticated through the admin API login and holds a valid API token bound to a device type in `allowedDeviceTypes`. The illustrative requests below are unexecuted examples, no live request was issued during this research.

1. The restricted admin lists every member. Request: `GET /admin/user/users` with headers `Authorization: Bearer ` and `XX-Device-Type: `. State before: the account has no API grant for this route. `_initUser()` sets `userType` to 1. `checkAccess()` builds rule name `admin_api:GET|admin/user/users`. `Auth::check()` finds no matching `auth_rule` row and returns `true`. Expected response derived from source: HTTP 200 with a `users` array and a `total` count, every member row present with `user_pass` hidden but login, nickname, email, and mobile exposed. State after: the caller has read all members' PII.

2. The restricted admin disables a chosen member. Request: `POST /admin/user/users/42/status/0` with the same headers. State before: member 42 is active. The gate fails open again for rule `admin_api:POST|admin/user/users/:id/status/:status`. `AdminUserController::status` runs `UserModel::where(["id" => 42, "user_type" => 2])->update(['user_status' => 0])`. Expected response: a success message. State after: member 42 is disabled, an action reserved for member management, now performed by an account that was never granted it.

3. The restricted admin escalates its own role. Request: `PUT /admin/roles/2/authorize` with body parameter `ids` set to the full list of admin ids, and the same headers. State before: role 2 holds a limited set of `auth_access` rows of type `admin_url`. The gate fails open for rule `admin_api:PUT|admin/roles/:id/authorize`. `RoleController::authorizePut` deletes the existing `admin_url` grants for role 2 and inserts one grant per supplied id. State after: role 2 now holds every web-admin grant, so the restricted admin has full web-admin capability on the enforced web path as well.

This finding was confirmed by static review of the released package sources pinned at v8.0.1. Runtime validation was not performed. No repository was cloned or downloaded, no target code or PoC was executed, and no live deployment was contacted.

- `cmf-core/src/controller/RestAdminBaseController.php`, `initialize()` requires only `userType == 1` then calls `checkAccess()`, which builds the `admin_api:METHOD|path` rule name and calls `cmf_auth_check()`.

- `cmf-core/src/lib/Auth.php`, `check()` contains `if (empty($findAuthRule)) { return true; }`, the fail-open branch reached before any role or `auth_access` lookup.

- `cmf-core/src/common.php`, `cmf_auth_check()` returns `true` for id 1 and otherwise delegates to `Auth::check()`.

- `cmf-install/src/install/data/thinkcmf.sql`, the seeded `cmf_auth_rule` rows use only `app/Controller/action` names, none carry the `admin_api:` prefix.

- `cmf-api/src/admin/controller/RoleController.php`, `apiAuthorizePut()` writes to `auth_access`, not to `auth_rule`, so configured API grants do not create the rows the check consults first.

- `cmf-core/src/controller/AdminBaseController.php`, the web path builds a seeded rule name and therefore enforces RBAC, which is the negative control showing the auth layer is otherwise real.

- `cmf-api/src/user/controller/AdminUserController.php`, `index()` returns the full member list, `status()` toggles any member account.

What is statically established: the authorization gate for admin REST endpoints returns true under default install for any non-superadmin admin-type caller, and the reached handlers expose member data, member status control, and role-grant rewrite. What is not established by this review: the exact HTTP status codes and the committed database rows on a running instance, which would require a live request that was out of scope.

A low-privilege backend administrator can call every admin REST API endpoint that a higher-privileged role controls, because the API authorization check returns true whenever the endpoint's rule name is absent from `auth_rule`, and the default install seeds no such names for the API.

- Member data disclosure. `GET admin/user/users` returns all members with login, nickname, email, and mobile. A restricted admin reads the full member directory.

- Member account control. `POST admin/user/users/:id/status/:status` enables or disables any member account.

- Self privilege escalation. `PUT admin/roles/:id/authorize` rewrites the attacker's own role grants, so a restricted admin becomes a full web-admin.

- Broad admin API exposure. The remaining admin API controllers (Role, User, Setting, , Plugin, Nav, Slide, Link, Hook, Theme, App, RecycleBin, Route, and the user-module admin controllers) are all gated by the same inert check.

Preconditions and limits. The caller must be an authenticated admin-type account (`user_type == 1`) holding a valid API token and sending an allowed `XX-Device-Type` header. This is not an unauthenticated vulnerability. The superadmin (id 1) path is unaffected because it short-circuits earlier. What is NOT established: no remote code execution is claimed, and the runtime response codes and the resulting database state were not validated against a live instance. A deployment that has manually inserted `admin_api:` rows into `auth_rule` would re-enable enforcement, but a default install does not.

**Where exactly:** `cmf-core/src/lib/Auth.php`, the `check()` method, the empty-rule branch.

- $findAuthRule = AuthRuleModel::where('name', $name)->field('id')->find();

- if (empty($findAuthRule)) {//没有规则时,不验证!

+ $findAuthRule = AuthRuleModel::where('name', $name)->field('id')->find();

+ // Fail closed for protected prefixes. An unregistered rule

+ // must not grant access on a path that asked for a check.

+ if (strpos($name, 'admin_api:') === 0) {

Short term. Make the empty-rule result deny for the `admin_api:` prefix so an unregistered admin API rule denies instead of allowing. This closes the gap without a schema change.

Medium term. Seed the `admin_api:` rule names into `auth_rule` during install and migration so `Auth::check()` consults the `auth_access` grants that `RoleController::apiAuthorizePut` already writes. This aligns the rule-existence table with the grant table and makes the admin API permission UI effective.

Defense in depth. Add a regression test that a role without the API grant receives a permission-denied response on an admin REST endpoint, and audit every controller that extends `RestAdminBaseController` to confirm the gate denies by default.

Recommended fix: fail closed on unregistered `admin_api:` rule names, seed the API rule names so the existing `auth_access` grants take effect, and add a test proving an ungranted role is denied on an admin REST endpoint.

- No public advisory at time of writing for the admin REST API fail-open authorization.

- Pinned source (raw blobs at v8.0.1 of each package):

-

-

-

-

-

-

-

-

-

-

- Distribution: (release v8.0.1)

Reported by hieuPenguinnn, [github.com/hieuPenguinnn](