Skip to content

Fix cross-store IDOR gaps in Grand.Web.Store - #783

Merged
KrzysztofPajak merged 4 commits into
developfrom
fix/store-tenant-isolation
Aug 14, 2026
Merged

Fix cross-store IDOR gaps in Grand.Web.Store#783
KrzysztofPajak merged 4 commits into
developfrom
fix/store-tenant-isolation

Conversation

@KrzysztofPajak

Copy link
Copy Markdown
Member

Resolves: no tracked issue - internal security audit of the store-owner/store-manager panel (Grand.Web.Store), analogous to the earlier Grand.Web.Vendor audit (#782).
Type: bugfix

Issue

Audited every controller in src/Web/Grand.Web.Store (the store-owner/store-manager panel, scoped by WorkContext.CurrentCustomer.StaffStoreId) for IDOR-style gaps: entities loaded by an id from the request and mutated without verifying they belong to the current store manager's store.

Found:

  • ProductController: ~20 sub-resource mutation actions (category/collection/related/similar/bundle/cross-sell/recommended/associated product mappings, pictures, spec attributes, prices, tier prices, attribute mappings/values) had no ownership check at all, even though the paired GET/list action next to each of them did. A store manager could mutate these sub-resources on any product in any store.
  • CategoryController/CollectionController: ProductUpdate/ProductDelete/ProductAddPopup mutate the product's category/collection list, not the category/collection entity - the missing check was on the product, not the category/collection.
  • MerchandiseReturnController.MerchandiseReturnNoteAdd: an unchecked orderId parameter was used to build a customer-facing notification email, letting a manager forge/misdirect it using another store's order data.
  • NewsController.List: scoped by request host (StoreContext.CurrentStore) instead of StaffStoreId, letting the result set diverge from the manager's actual store depending on which host the panel is reached through.
  • ShippingController.RestrictionSave: mutated restricted-countries/restricted-groups on global (store-independent) shipping methods returned alongside store-owned ones, not just store-owned methods.
  • MessageTemplateController.Edit/Delete and SettingController's IsStoreOwnerAccessAllowed (used by MerchandiseReturnReason/Action) used a non-exclusive "is my store one of the assigned stores" check instead of requiring exclusive ownership, letting one store's manager edit-and-reassign-away or delete an entity another store still depends on.

Along the way, also found and cleaned up a related code-quality issue the user flagged: ViewBag used as an untyped data carrier between controller and view in this area.

Solution

  • Added entity.AccessToEntityByStore(StaffStoreId) / entity.StoreId == StaffStoreId checks on the loaded entity immediately before every mutation identified above, matching the pattern already used consistently elsewhere in each file.
  • Where a shared *ViewModelService method (also used by Admin) mutates entities referenced by an id list (e.g. InsertAssociatedProductModel/InsertCategoryProductModel/InsertCollectionProductModel), the Store controller now pre-filters that list to store-owned entities before calling the shared service.
  • MerchandiseReturnController.MerchandiseReturnNoteAdd now validates order.Id == merchandiseReturn.OrderId.
  • SettingController.IsStoreOwnerAccessAllowed now additionally requires exclusive ownership (Stores.Count == 1), fixing all four MerchandiseReturnReason/Action call sites at once; MessageTemplateController now calls AccessToEntityByStore directly instead of its own looser check.
  • Updated ShippingControllerTests fixtures that encoded the old (vulnerable) behavior and added RestrictionSave_GlobalShippingMethod_NotUpdated as a regression test.
  • Separate commit: replaced the ViewBag data carriers that actually cross the controller-to-view boundary (IsReadOnly, RefreshPage, ShowCopyButton, FilterByBlogPostId) with typed model properties, following the two patterns already established in this area (XxxStoreModel : XxxModel subclass + AutoMapper profile, or a property added directly to the shared model when it's a generic UI flag). Removed 17 dead ViewBag.AllLanguages/ViewBag.productIdsInput assignments that nothing ever read (also fixing two missing-await calls hidden inside that dead code). Left ViewBag.Title (152 of the original 183 occurrences) untouched - it's the standard Razor <title> idiom, set only in views.

Breaking changes

None. All changes are internal to Grand.Web.Store request handling, plus small additive (non-breaking) properties on shared Grand.Web.AdminShared view models (PageModel.ShowCopyButton, OrderModel.UploadLicenseModel.RefreshPage, PaymentTransactionModel.RefreshPage) that default to false and are unused by Admin/Vendor.

Testing

  1. dotnet build src/Web/Grand.Web.Store/Grand.Web.Store.csproj - 0 errors, 0 warnings.
  2. dotnet build src/Web/Grand.Web.Admin/Grand.Web.Admin.csproj and src/Web/Grand.Web.Vendor/Grand.Web.Vendor.csproj - 0 errors, 0 warnings (both reference the touched shared Grand.Web.AdminShared models).
  3. dotnet test src/Tests/Grand.Web.Store.Tests/Grand.Web.Store.Tests.csproj - 18/18 passing.
  4. Manual repro of the fixed IDORs (e.g. ProductCategoryUpdate/RelatedProductUpdate/etc. with a productId belonging to another store, or RestrictionSave against a global shipping method) now redirect/no-op instead of mutating the foreign entity.

Audit of the store-owner/store-manager panel (scoped by
WorkContext.CurrentCustomer.StaffStoreId) found several places where a
loaded entity, or an entity referenced by id in a request, was mutated
without verifying it belonged to the current store manager's store.
- ProductController: ~20 sub-resource mutation actions (category/
collection/related/similar/bundle/cross-sell/recommended/associated
product mappings, pictures, spec attributes, prices, tier prices,
attribute mappings/values) had no ownership check at all, even though
the paired GET/list action next to each of them did. Added
AccessToEntityByStore checks before every mutation, and filter
SelectedProductIds down to store-owned products where the shared
ProductViewModelService mutates each selected entity by id.
- CategoryController/CollectionController: ProductUpdate/ProductDelete/
ProductAddPopup mutate the product's category/collection list, not the
category/collection entity - the missing check was on the product.
- MerchandiseReturnController.MerchandiseReturnNoteAdd: an unchecked
orderId parameter was used to build a customer-facing notification
email, letting a manager forge/misdirect it with another store's order
data. Now validated against the merchandise return's own OrderId.
- NewsController.List: scoped by request host (StoreContext.CurrentStore)
instead of StaffStoreId, letting the result set diverge from the
manager's actual store depending on which host the panel is reached
through.
- ShippingController.RestrictionSave: mutated restricted-countries/
restricted-groups on global (store-independent) shipping methods
returned alongside store-owned ones, not just store-owned methods.
- MessageTemplateController.Edit/Delete and SettingController's
IsStoreOwnerAccessAllowed (MerchandiseReturnReason/Action) used a
non-exclusive "is my store one of the assigned stores" check instead of
requiring exclusive ownership, letting one store's manager edit-and-
reassign-away or delete an entity another store still depends on.
Updated ShippingControllerTests fixtures that encoded the old (vulnerable)
behavior and added a regression test for the fix.
Verified with dotnet build (Store, Admin, Vendor - all reference the
touched shared AdminShared extension) and dotnet test on
Grand.Web.Store.Tests.
…b.Store
Of 183 ViewBag occurrences in Grand.Web.Store, 152 are ViewBag.Title, set
only in views (the standard Razor <title> idiom) - left untouched.
Of the rest, 17 were dead: ViewBag.AllLanguages (Blog/Page/News
controllers) and ViewBag.productIdsInput (ProductController) are set but
never read anywhere in the solution. Removed them (two of the
AllLanguages assignments in NewsController were also missing `await`, an
unobserved-task bug hidden inside otherwise-dead code).
The remaining 4 keys actually cross the controller-to-view boundary and
were promoted to typed model properties, following the two patterns
already used in this area for extending a shared Grand.Web.AdminShared
model from the store panel:
- MessageTemplateController.IsReadOnly: new MessageTemplateStoreModel :
MessageTemplateModel + MessageTemplateStoreProfile AutoMapper profile,
mirroring the existing ContactAttributeStoreModel/
CustomerAttributeStoreModel/AddressAttributeStoreModel pattern.
- OrderController/PaymentTransactionController.RefreshPage: added
directly to the shared OrderModel.UploadLicenseModel and
PaymentTransactionModel, mirroring the existing
CustomerAttributeModel.IsReadOnly pattern (harmless if unused by
Admin/Vendor).
- PageController.ShowCopyButton: same, added to the shared PageModel.
- BlogController.Comments had no model at all; added a small
Store-only BlogCommentListModel.
Verified with dotnet build on Store, Admin, and Vendor (all three
reference the touched shared AdminShared models) - 0 errors, only a
pre-existing unrelated warning in Grand.Business.Common. dotnet test on
Grand.Web.Store.Tests stays green (no behavior touched by this change is
covered by unit tests, but the build is the safety net for Razor view
compilation here).
CopilotAI lite review requested due to automatic review settings August 14, 2026 12:11

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@KrzysztofPajak
KrzysztofPajak merged commit c7be1f4 into developAug 14, 2026
6 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the fix/store-tenant-isolation branch August 14, 2026 18:40
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@KrzysztofPajak