
Joomla 6.1.3 - High - Remote - ACL - POST /api/v1/content/articles ignores per-category ACL
abraxaslabs.tech · github.com/abraxas · @abraxas_null · [email protected] · joomla-articles-catid
Class: Privilege leftover Reach: Remote
Joomla CMS 6.1.3 (0f2d4de) - Open Source Matters
Administrator ArticleController::allowAdd checks com_content.category.{catid}. api/components/com_content/src/Controller/ArticlesController.php has no allowAdd. ApiController::add() then calls allowAdd() with no data, so the JSON catid never enters the check. Base allowAdd is true if the user can create in any category.
A Manager-class API user, granted core.login.api and create on one category, can POST an article into a category they are denied in the UI.
| ID | no CVE yet |
| Class | Privilege leftover / authorization bypass |
| Reach | Remote (API token with core.login.api) |
| CWE | CWE-863 |
| CVSS | Medium: 6.5 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N |
| Product | Joomla CMS |
| Affected | 6.1.3 (0f2d4de4729eb951e0878fec98c7990da07915b2), POST /api/index.php/v1/content/articles |
| Auth | API token for Manager (group 6) with core.login.api, com_content core.manage, and core.create on one category only. No Super User. Default core.login.api is Super User only. |
| License | GNU Affero GPL v3.0 |
| Lab | 127.0.0.1 only |
Use a non-Super-User API token that may create in one category to publish into categories they are banned from. They do not become Super User from this leftover. This is unauthorized publishing, not a site takeover and not a read of hidden articles.
| Privilege | Method / path | Kind | Lab-proven or inferred |
|---|---|---|---|
| Create an article in a category they are denied | POST /api/index.php/v1/content/articles JSON catid of B | integrity | Lab-proven. HTTP 200, #__content id=1, catid=9 (Press), title poc-art-JOOMLA-ARTICLES-CATID-WITNESS |
| Plant published copy in Official notices / Press while only allowed Drafts | Same POST, state=1 | integrity | Lab-proven create into denied Press. Inferred for any other category asset with core.create denied |
| Edit an existing article via the API | PATCH /api/index.php/v1/content/articles/{id} | n/a | Inferred closed for this Manager. Base allowEdit needs component core.edit; lab com_content rules give core.edit to Author (4), not Manager (6). Lab did not PATCH |
| Delete an article via the API | DELETE /api/index.php/v1/content/articles/{id} | n/a | Inferred closed. Base allowDelete needs component core.delete. Lab did not DELETE |
| Save an article into allowed category A from administrator | POST ... task=article.save jform[catid]=A | intended | Lab-proven open as intended. HTTP 200, not denied |
| Save an article into denied category B from administrator | Same task, jform[catid]=B | n/a | Lab-proven closed. HTTP 200 with "not permitted" / create-not-permitted (UI allowAdd on com_content.category.B) |
Hit the API with a stock Manager (no core.login.api) | Bearer token / X-Joomla-Token | n/a | Inferred closed. Default core.login.api is Super User (group 8) only. This lab grants it to group 6 first |
| Same gap on contacts / banners / newsfeeds | POST /api/index.php/v1/contacts (and siblings) with foreign catid | integrity | Inferred. Those API controllers also lack allowAdd. Lab did not exercise them |
| Super User / installer RCE | n/a | n/a | Closed. Manager stays Manager |
I pinned 6.1.3 after the 2026-07/08 JSST wave. Groups API already requires core.admin. Media webservice already requires core.edit on override. Articles API still falls through ApiController and treats "create in any category" as enough for every catid.
public function add()
{
if (!$this->allowAdd()) {
throw new NotAllowed('JLIB_APPLICATION_ERROR_CREATE_RECORD_NOT_PERMITTED', 403);
}
$recordId = $this->save();
$this->displayItem($recordId);
}
Base check:
protected function allowAdd($data = [])
{
$user = $this->app->getIdentity();
if (!$user->authorise('core.manage', $this->option)) {
return false;
}
return $user->authorise('core.create', $this->option)
|| \count($user->getAuthorisedCategories($this->option, 'core.create'));
}
Administrator UI already keys off catid:
protected function allowAdd($data = [])
{
$categoryId = ArrayHelper::getValue(
$data,
'catid',
$this->input->getInt('filter_category_id'),
'int'
);
if ($categoryId) {
return $this->app->getIdentity()->authorise(
'core.create',
'com_content.category.' . $categoryId
);
}
return parent::allowAdd();
}
Wrong turns already recorded: reading UI HTTP 200 as allow (denial of B is the "not permitted" body); treating component core.create as the hole (the lab Manager has none; one category allow is enough for the base getAuthorisedCategories count); a stock Manager without core.login.api. Theatre: a reverse shell. The oracle is API 200 into Press plus UI allow on Drafts plus UI deny on Press.
Same pin leftovers: levels API, menu items API, media override.
python3 joomla-articles-catid-Abraxas-Labs.py
or
cd lab
./run.sh
Target only http://127.0.0.1:19550. Image joomla:6.1.3-php8.3-apache plus mariadb:11.4.
SUCCESS joomla-articles-catid api-http=200 article-id=1 catid=9 cat-a=8 cat-b=9 ui-a=200 ui-b=200 JOOMLA-ARTICLES-CATID-WITNESS
article-id is the next #__content autoincrement (1 on a clean 6.1.3 install). ui-a / ui-b are HTTP 200; denial of B is the UI "not permitted" body, not a 403 status.
Override allowAdd (and allowEdit) on api/components/com_content/src/Controller/ArticlesController.php the same way as administrator ArticleController: check core.create on com_content.category.{catid}. Pass the request JSON into ApiController::add() so catid actually reaches that check. Apply the same pattern to contacts, banners, and newsfeeds if those controllers stay on the default allowAdd.