Shopping list addItem does not validate ingredient belongs to household #12
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
In
ShoppingService.addItem(), when a user provides aningredientId, it is looked up by ID only — without checking that the ingredient belongs to the user's household. This allows cross-household data references.Affected files
ShoppingService.java:131-134Attack scenario
A user guesses or obtains an ingredient UUID from another household and adds it to their shopping list, linking cross-tenant data.
Recommended fix
Use
ingredientRepository.findByIdAndHouseholdId(request.ingredientId(), householdId)instead offindById.Severity
Medium — cross-household data leakage via IDOR on ingredient references.
👨💻 Kai — Frontend Engineer
This is a pure backend bug, but I want to flag the frontend surface that interacts with it.
How this manifests in the UI
Questions for me to answer
ingredientIdexplicitly, or does it send ingredient data (name, unit) and let the backend look it up or create it? The spec on this would help me understand what I'm passing toPOST /shopping/items.What I'd want from the fix
+page.server.tsneeds to handle that case and surface a meaningful message to the user rather than a generic failure.🛠️ Backend Engineer
Classic IDOR — simple to fix, important to catch. The fix itself is one line, but there are a few things to think through carefully.
The fix
Change:
To:
This should throw a 404, not a 403. Returning 404 is the right choice here — it avoids confirming to an attacker that the UUID exists in another household. A 403 would be an information leak ("yes, that ID exists, but you can't access it").
Repository method
findByIdAndHouseholdIddoesn't exist yet, it's a one-liner Spring Data method:Optional<Ingredient> findByIdAndHouseholdId(UUID id, UUID householdId). Spring Data will generate the query automatically — no custom@Queryneeded.Questions before implementing
Ingredienta shared global entity (e.g., a product catalog) or a household-scoped entity with its ownhousehold_idcolumn? The fix assumes household-scoped. If ingredients can be shared across households (e.g., a global ingredient library), the model is different and the fix needs more thought.ShoppingService.addItem()also create new ingredients inline (without aningredientId)? If so, does that creation path also correctly associate the new ingredient with the current household?ingredientRepository.findById()is called without a household check? This might not be the only instance.Broader audit
findByIdwithout household scope — is worth a codebase-wide grep. Any repository'sfindByIdcall that isn't paired with a household check is a potential IDOR. Related to #13.🧪 QA Engineer
This is a textbook IDOR that needs a direct regression test. The fix is small but the test is what guarantees it never regresses.
Tests I'd add for this fix
Happy path (confirm fix doesn't break valid usage):
shouldAddItemWithValidIngredientFromSameHousehold()— provide aningredientIdthat belongs to the user's household, assert item is created successfully.Security regression tests:
shouldReturn404WhenIngredientIdBelongsToADifferentHousehold()— create an ingredient in household A, authenticate as household B user, attempt to add that ingredient ID to household B's shopping list, assert 404 (not 200, not 403, not 500).shouldReturn404WhenIngredientIdDoesNotExist()— provide a random UUID that doesn't exist in any household, assert 404. (Verifies the error path works regardless of the isolation fix.)Edge cases worth testing
shouldReturn400WhenIngredientIdIsNotAValidUUID()— malformed UUID in the request body.shouldAddItemWithoutIngredientId()— if theingredientIdfield is optional, verify the happy path works when it's omitted entirely.How to structure the integration test
Question
TestHouseholdFactoryif not already present.🔒 Sable — Security Engineer
This is a confirmed IDOR (Insecure Direct Object Reference) — OWASP Top 10 #1 (Broken Access Control). The attack scenario in the issue is realistic and low-effort.
Attack scenario, spelled out
POST /shopping/itemswith{ "ingredientId": "<household-A-UUID>" }.Why this matters beyond the obvious
The fix is correct — 404 not 403
Returning 404 for a mismatched household is the right call. A 403 would confirm the UUID exists somewhere in the system, which is information leakage. The caller should not be able to distinguish "this ID doesn't exist" from "this ID exists but isn't yours."
Broader audit I'd require before closing
grep -r "findById" src/across all repository usages — flag every one that isn't paired with a household scope check. This issue caught one instance; there may be others.RecipeService,MealPlanService, orIngredientServicewhere a resource is looked up by ID alone?updateItem()ordeleteItem()inShoppingServicealso look up items by ID without household scope, those are separate IDORs.Severity note
The issue rates this Medium. I'd keep that rating — the ingredient data itself isn't highly sensitive, but the pattern is. Any similar bug on a more sensitive entity (meal plans, household members, invite tokens) would be High.
🎨 Atlas — UI/UX Designer
Backend fix, but I want to flag the user-facing consequence of the 404 response once the fix is in.
The scenario from a user's perspective
In normal usage, users will never trigger this — they select ingredients from their own household's list through the UI. But if a user somehow ends up with a stale or invalid
ingredientId(e.g., an ingredient that was deleted between when they opened the add-item dialog and when they submitted), they'll now get a 404.What does the UI show on 404?
POST /shopping/items? Or does it only handle success?Design guidance for the error state
--color-errorfor the error text, consistent with other form validation in the design system.Question for Kai