Cohesivo (Ibexa DXP) published a security advisory on 5 November 2026 (IBEXA-SA-2026-005). This advisory covers four fixes released together. In this blog post, we cover the most critical vulnerability which can lead to RCE (Remote Code Execution) by users with access to the REST API.
Upgrading to 4.6.33/5.0.11 is the right fix. Do that if you can, and stop reading. (You can find the release notes in our developer documentation.)
What follows is for the case where you cannot upgrade yet, or just want to get the RCE bug fixed ASAP. It takes two files in src/ , no new package, no vendor patching.
RCE (Remote Code Execution) via REST API vulnerability
Ibexa DXP exposes a bulk REST endpoint at POST /api/ibexa/v2/bulk. You hand it a list of operations, it runs each one as an HTTP sub-request and gives you back all the responses at once. The Admin UI leans on it heavily: the content tree, the sub-items list, the image picker, the
taxonomy tree and the scheduler button all batch their calls through it.
Sub-requests, though, skip checks that apply to ordinary requests. Until recently the endpoint dispatched whatever URI you put in an operation, whether or not it pointed at a REST resource. A bulk operation could therefore reach parts of the application that were never meant to be reachable that way, and have the kernel run them.
That is remote code execution. Ibexa rates this critical as the application is reachable by anyone who can call the REST API — which, depending on how the installation is set up, can include unauthenticated users.
Ibexa fixed this vulnerability in v4.6.33 and v5.0.11. Every sub-request URI is now validated with UriParserInterface::matchUri(), and anything that does not resolve to a REST route is rejected:
400 Bad Request Bulk operation URI must point to a REST resource.
The manual fix, step by step
If upgrading to 4.6.33/5.0.11 is just not an option for you at this moment, here is a do-it-yourself guide. It covers the same fix, by hand, step by step, with no extra dependency.
How it works
The route ibexa.rest.bulk_operation points at a service id:
# vendor/ibexa/admin-ui/src/bundle/Resources/config/routing_rest.yaml
ibexa.rest.bulk_operation:
path: /bulk
options:
expose: true
defaults:
_controller: 'Ibexa\Bundle\AdminUi\Controller\BulkOperation\BulkOperationController::bulkAction'
methods: ['POST']
So we leave the route alone and swap the class behind that service id for a copy of the fixed controller.
Step 1 : Copy the fixed controller into src/
Take it from the release that first carried the fix. If you are using 4.6.x:
mkdir -p src/Controller/BulkOperation curl -sS -o src/Controller/BulkOperation/BulkOperationController.php \ <https://raw.githubusercontent.com/ibexa/admin-ui/v4.6.33/src/bundle/Controller/BulkOperation/BulkOperationController.php>
If you are using 5.0.x:
mkdir -p src/Controller/BulkOperation curl -sS -o src/Controller/BulkOperation/BulkOperationController.php \ <https://raw.githubusercontent.com/ibexa/admin-ui/v5.0.11/src/bundle/Controller/BulkOperation/BulkOperationController.php>
Step 2: Fix the namespace
The file arrives with the vendor namespace, which will not autoload from src/ . Repoint it at App\ , and drop the class_alias() call at the bottom: The real vendor class still declares that alias, and we must not declare it twice (on 5.0 there is no such line and the second command simply does nothing):
sed -i 's#^namespace Ibexa\\Bundle\\AdminUi\\Controller\\BulkOperation;#namespace App\\Controller\\BulkOperation;#' \ src/Controller/BulkOperation/BulkOperationController.php sed -i '/^class_alias(BulkOperationController::class/d' \ src/Controller/BulkOperation/BulkOperationController.php
That is the only edit. Everything else comes from upstream as-is: The matchUri() call, the BadRequestException , the setSession() that now happens after validation.
Step 3: The compiler pass
Create src/DependencyInjection/Compiler/ReplaceBulkOperationControllerPass.php :
<?php
declare(strict_types=1);
namespace App\DependencyInjection\Compiler;
use App\Controller\BulkOperation\BulkOperationController;
use Ibexa\Bundle\AdminUi\Controller\BulkOperation\BulkOperationController as UpstreamBulkOperationController;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
final class ReplaceBulkOperationControllerPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->hasDefinition(UpstreamBulkOperationController::class)) {
return;
}
// The route keeps pointing at the upstream service id; only the class behind it changes.
// The definition stays autowired, so the patched constructor is resolved on both 4.6 and 5.0,
// where the signatures differ.
$container
->getDefinition(UpstreamBulkOperationController::class)
->setClass(BulkOperationController::class);
}
}
The compiler pass simply updates the service, replacing the original class with the one we just copied into src/
The hasDefinition() guard keeps the pass harmless on an installation where that service does not exist.
Step 4: Register the pass
Compiler passes from application code go in src/Kernel.php. Ibexa ship a bare kernel with no build() method, on both 4.6 and 5.0, so unless your project added one you need to write it:
use App\DependencyInjection\Compiler\ReplaceBulkOperationControllerPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
protected function build(ContainerBuilder $container): void
{
$container->addCompilerPass(new ReplaceBulkOperationControllerPass());
}
}If your project already has a build() , just add the addCompilerPass() line to the existing body. Do not replace the method.
Step 5: Rebuild and check
php bin/console cache:clear php bin/console debug:container 'Ibexa\Bundle\AdminUi\Controller\BulkOperation\BulkOperationController'
The reported class must be App\Controller\BulkOperation\BulkOperationController , while the service id stays the Ibexa one.
How do I know it is working?
Talk to the endpoint. The Admin UI calls it with a session, so these examples do the same: create one and keep the cookie and the CSRF token it hands back.
curl -s -c cookies.txt -X POST <http://localhost:8080/api/ibexa/v2/user/sessions> \
-H 'Content-Type: application/vnd.ibexa.api.SessionInput+json' \
-H 'Accept: application/vnd.ibexa.api.Session+json' \
-H 'X-SiteAccess: site' \
-d '{"SessionInput":{"login":"admin","password":"publish"}}'
Take Session.csrfToken out of the response, then send a bulk request whose operation points somewhere that is not a REST resource; the login page will do:
curl -s -b cookies.txt -X POST <http://localhost:8080/api/ibexa/v2/bulk> \
-H 'Content-Type: application/vnd.ibexa.api.BulkOperation+json' \
-H 'Accept: application/vnd.ibexa.api.BulkOperationResponse+json' \
-H 'X-SiteAccess: site' -H "X-CSRF-Token: <csrfToken>" \
-d '{"bulkOperations":{"operations":{"a":{"uri":"/login","method":"GET"}}}}'Before the patch the outer call returns 200 and the operation carries a status of its own; proof the sub-request really ran. After it, you should get:
{
"ErrorMessage": {
"errorCode": 400,
"errorMessage": "Bad Request",
"errorDescription": "Bulk operation URI must point to a REST resource."
}
}
When REST is closed to the public
Ibexa DXP can be configured in many ways, and it might be that you have blocked REST on your public site accesses. This shuts the door on the public, which is good, and the examples above won't work for you.
It does not shut the door on your own staff. The endpoint is still there, and any logged-in
back-office user can call it. The fix is worth applying even when the endpoint is unreachable from outside.
Reproducing it from an admin session takes one extra step. You cannot open a REST session against the admin siteaccess with POST /user/sessions : that needs the Anonymous role to hold user/login on admin , which a stock install does not grant (REST API authentication).
So borrow the session the browser already has.
Log into the Admin UI, then open the browser developer tools:
- CSRF token — the Admin UI keeps it in a meta tag. In the Console:
document.querySelector('meta[name="CSRF-Token"]').content- Session cookie — it is HttpOnly, so the Console cannot read it. Copy it from the Application → Cookies panel — the Ibexa session cookie — its name starts with eZSESSID on 4.6 or IBX_SESSION on 5.0 by default, though either can be renamed in configuration. Then replay the bulk call by hand, against admin this time:
curl -s -b '<cookie-name>=<cookie-value>' -X POST <http://localhost:8080/api/ibexa/v2/bulk> \
-H 'Content-Type: application/vnd.ibexa.api.BulkOperation+json' \
-H 'Accept: application/vnd.ibexa.api.BulkOperationResponse+json' \
-H 'X-SiteAccess: admin' -H 'X-CSRF-Token: <csrfToken>' \
-d '{"bulkOperations":{"operations":{"a":{"uri":"/login","method":"GET"}}}}'
Patch bundle for RCE bug available
Whilst creating this guide we also updated a bundle we maintain and that now carries the fix. It is available to install:
composer require vidarl/ibexa-bulk-operation-guard
It does exactly what this post describes, on 4.6 and 5.0 alike, and composer refuses to keep it installed once ibexa/admin-ui is upgraded to a fixed version, so it cannot be forgotten.
We hope this guide helps you quickly respond to the IBEXA-SA-2026-005 security advisory. If something in this guide is unclear or could be improved, please do let us know.
All Security advisories are available on our developers portal.
Full Release Details: Ibexa DXP v5.0.11 Release Notes and Ibexa DXP v4.6.33 Release Notes