diff --git a/CHANGELOG.md b/CHANGELOG.md index 46097985..e07b5cec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,36 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows ## [Unreleased] +## [1.8.1] — 2026-09-15 + +Findings from an AI-driven install test of the web installer on a shared cPanel host (TIGER-138). + +### Security + +- **Comment moderation was open to anyone once comments were enabled.** `Comment_Service_Comment` + is granted to guests (anyone may post), and its admin-only methods gated on `_isAdmin()` — which + asks "is this role allowed on this service", true for a guest there. `moderate`, `datatable` and + deleting/editing someone else's comment now use the new `Tiger_Service_Service::_isAtLeastAdmin()` + (admin, or a role that inherits admin). The suite's own tests had been moderating as a plain + `user` and passing. Every other service that uses bare `_isAdmin()` is admin/superadmin-granted, + where it means what it says. +- **`/mcp` cookie sessions are honoured only from the site's own origin.** A cross-site POST riding + the admin's cookie (Sec-Fetch-Site not same-origin/none, or a foreign Origin) is 403; a + non-JSON Content-Type is 415 (closes the text/plain-form CSRF shape). Bearer clients are unaffected. + +### Fixed + +- **Bearer tokens never reached `/mcp` or `/api` on PHP-FPM hosts.** Apache drops the Authorization + header for CGI/FastCGI/FPM unless told otherwise, so a valid token degraded silently to the guest + surface (the headless-agent story was dead on every shared host). The skeleton's `public/.htaccess` + (1.0.21) now passes it through (`CGIPassAuth On`, with a rewrite-env fallback); Tiger reads the + header from `HTTP_AUTHORIZATION`, `REDIRECT_HTTP_AUTHORIZATION` and `apache_request_headers()` + (`Tiger_Ajax_ServiceFactory::authorizationHeader()`, one reader for both endpoints). +- **A Bearer that does not verify is 401** on `/mcp` (`WWW-Authenticate: Bearer`), never a silent + downgrade to guest — a client with a bad key learns it is bad. +- Comment admin methods answer "not allowed" BEFORE the feature flag, so an outsider is never told + the feature is off instead. + ## [1.8.0] — 2026-09-14 ### Added diff --git a/library/Tiger/Ajax/ServiceFactory.php b/library/Tiger/Ajax/ServiceFactory.php index bbe808f0..f81b21f0 100644 --- a/library/Tiger/Ajax/ServiceFactory.php +++ b/library/Tiger/Ajax/ServiceFactory.php @@ -290,10 +290,31 @@ protected function _identity() /** The Bearer token from the Authorization header, or null. */ protected function _bearerToken() { - $h = (string) $this->_request->getHeader('Authorization'); + $h = self::authorizationHeader($this->_request); return preg_match('/^\s*Bearer\s+(\S+)/i', $h, $m) ? $m[1] : null; } + /** + * The request's Authorization header, wherever the server put it. Apache drops it for CGI / + * FastCGI / PHP-FPM unless told otherwise (`CGIPassAuth On`, in public/.htaccess since the + * skeleton's 1.0.21); the rewrite-env fallback there surfaces it as REDIRECT_HTTP_AUTHORIZATION; + * some SAPIs only expose it through apache_request_headers(). One reader for /api and /mcp. + * + * @param Zend_Controller_Request_Abstract|null $request + * @return string '' when absent + */ + public static function authorizationHeader($request = null) + { + $h = ''; + if ($request !== null && method_exists($request, 'getHeader')) { $h = (string) $request->getHeader('Authorization'); } + if ($h === '') { $h = (string) ($_SERVER['HTTP_AUTHORIZATION'] ?? ''); } + if ($h === '') { $h = (string) ($_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''); } + if ($h === '' && function_exists('apache_request_headers')) { + foreach ((array) apache_request_headers() as $k => $v) { if (strcasecmp((string) $k, 'Authorization') === 0) { $h = (string) $v; break; } } + } + return $h; + } + /** * Deny-by-default ACL pre-auth, shared by both modes. When no ACL is loaded * (only before the ACL engine exists) we fail-OPEN rather than hard-deny every diff --git a/library/Tiger/Service/Service.php b/library/Tiger/Service/Service.php index 3a99a6a4..4355242e 100644 --- a/library/Tiger/Service/Service.php +++ b/library/Tiger/Service/Service.php @@ -231,6 +231,29 @@ protected function _isAdmin($resource = null, $privilege = null) return $acl->isAllowed($role, $resource, $privilege); } + /** + * "Is the caller an admin (or a role that inherits admin)?" — the question _isAdmin() does NOT + * answer. _isAdmin() asks whether the role is ALLOWED ON THIS SERVICE; on a service granted to + * guests (comments, search, signup) every caller is. A method that is admin-only inside a + * public service must ask THIS. + * + * @return bool + */ + protected function _isAtLeastAdmin() + { + $identity = Zend_Auth::getInstance()->getIdentity(); + $role = (string) ($identity->role ?? ''); + if ($role === '') { return false; } + if ($role === 'admin') { return true; } + if (!Zend_Registry::isRegistered('Zend_Acl')) { return false; } + try { + $acl = Zend_Registry::get('Zend_Acl'); + return $acl->hasRole($role) && $acl->hasRole('admin') && $acl->inheritsRole($role, 'admin'); + } catch (Throwable $e) { + return false; + } + } + // ----- data / transactions ---------------------------------------------- /** The default DB adapter, or a clear failure if none is configured. */ diff --git a/library/Tiger/Version.php b/library/Tiger/Version.php index d5b24404..970d8cc4 100644 --- a/library/Tiger/Version.php +++ b/library/Tiger/Version.php @@ -9,5 +9,5 @@ class Tiger_Version { /** Current Tiger Core version. Keep in lockstep with the git tag cut for a release. */ - const VERSION = '1.8.0'; + const VERSION = '1.8.1'; } diff --git a/modules/comment/services/Comment.php b/modules/comment/services/Comment.php index 16d639da..d6249c2f 100644 --- a/modules/comment/services/Comment.php +++ b/modules/comment/services/Comment.php @@ -179,9 +179,9 @@ public function edit(array $params): void $userId = (string) ($this->_user_id ?? ''); $mine = $userId !== '' && (string) $row->user_id === $userId; - if (!$mine && !$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + if (!$mine && !$this->_isAtLeastAdmin()) { $this->_error('core.api.error.not_allowed'); return; } - if ($mine && !$this->_isAdmin() && (time() - strtotime((string) $row->created_at)) > Tiger_Comment::editWindow()) { + if ($mine && !$this->_isAtLeastAdmin() && (time() - strtotime((string) $row->created_at)) > Tiger_Comment::editWindow()) { $this->_error('comment.error.edit_window'); return; } @@ -219,8 +219,9 @@ public function edit(array $params): void */ public function moderate(array $params): void { + // Authorization BEFORE the feature flag: "not allowed" must never be masked by "not enabled". + if (!$this->_isAtLeastAdmin()) { $this->_error('core.api.error.not_allowed'); return; } if (!$this->_enabled()) { return; } - if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } $status = (string) ($params['status'] ?? ''); if (!in_array($status, Tiger_Model_Comment::STATUSES, true)) { $this->_error('comment.error.bad_status'); return; } @@ -256,7 +257,7 @@ public function delete(array $params): void $userId = (string) ($this->_user_id ?? ''); $mine = $userId !== '' && (string) $row->user_id === $userId; - if (!$mine && !$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + if (!$mine && !$this->_isAtLeastAdmin()) { $this->_error('core.api.error.not_allowed'); return; } try { $this->_transaction(function () use ($model, $row) { @@ -278,8 +279,8 @@ public function delete(array $params): void */ public function datatable(array $params): void { + if (!$this->_isAtLeastAdmin()) { $this->_error('core.api.error.not_allowed'); return; } if (!$this->_enabled()) { return; } - if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } $dt = $this->_dtParams(); $status = (string) ($params['status'] ?? Tiger_Model_Comment::STATUS_PENDING); diff --git a/modules/mcp/controllers/ServerController.php b/modules/mcp/controllers/ServerController.php index 0dfec03a..00cff5d4 100644 --- a/modules/mcp/controllers/ServerController.php +++ b/modules/mcp/controllers/ServerController.php @@ -53,6 +53,15 @@ public function indexAction() return; } + // JSON-RPC is JSON. Refusing any other Content-Type also closes the classic text/plain-form + // CSRF: a cross-site