recipes: Group-Permission Cache Key Omits Active Space
What fixes it
No source in this snapshot names a fixed version.
Description
Summary
has_group_permission() caches the current user's group names under a key that contains only the user id, with no active-space component, and rewrites that cache entry on every call:
CACHE_KEY = 'GROUP_CACHE_' + str(request.user.pk)
user_groups = cache.get(CACHE_KEY, default=None)
if no_cache or user_groups is None:
user_groups = []
if user_space := request.user.userspace_set.filter(active=True):
if len(user_space) == 1:
user_groups = user_space.first().groups.values_list('name', flat=True)
cache.set(CACHE_KEY, user_groups, timeout=30)The groups are read from the user's active space only on a cache miss, but the key omits the space, so after the active space changes the cached groups from the previous space keep answering permission checks. switch_user_active_space() flips the active UserSpace rows without deleting the cache entry, and nothing anywhere deletes a GROUP_CACHE_ key. The UserSpace signal receivers in signals.py invalidate only the household_user_ids cache.
The cache.set(..., timeout=30) call runs unconditionally, including on a cache hit, so every request resets the 30-second TTL. Under continuous activity the entry never expires, which turns a 30-second staleness window into an open-ended one.
The cached decision feeds every group-based permission class (CustomIsGuest, CustomIsUser, CustomIsAdmin, CustomRecipePermission, CustomAiProviderPermission, CustomUserPermission), the GroupRequiredMixin views, and the function-based views that use these checks.
This is the same defect class that GHSA-29pm-4vh2-f8mp described for the previous hash-based cache key. The 2.6.15 rework (commit 395f752) replaced that key with GROUP_CACHE_<pk> but kept the space-less key, removed none of the missing invalidation, and added the TTL-refresh-on-every-call behavior. The code is unchanged in the latest release (2.6.15), in a5179f8, and on develop.
Details
cookbook/helper/permission_helper.pyhas_group_permission(),switch_user_active_space()cookbook/signals.pyUserSpace signal receivers
Proof of concept
Verified against the application code at commit a5179f8 (Django 5.2.16 from requirements.txt, sqlite, locmem cache). A harness imports the real has_group_permission and switch_user_active_space and creates a user who is admin in space A and a plain user in space B:
[control] space A active, has_group_permission(["admin"]) -> True [bypass] after switch_user_active_space(B) -> True (stale A groups) [revocation] admin group removed from all spaces -> True (cached, TTL kept alive) [control] after cache clear -> False (matches revoked DB state)
The cache-clear step flipping the result back to the correct answer isolates the cache as the cause. Cross-space escalation is also self-serviceable in a deployed instance: any member of a space can create their own space (POST /api/space/) and is automatically granted its admin group, then prime admin=True before switching into the victim space.
Impact
In multi-space deployments a user is authorized using the groups of whichever space they most recently populated the cache from, not their groups in the active space. A user who holds admin in one space is treated as admin in another space where they are only a guest or user, for as long as the entry lives. Because the TTL is refreshed on every request, group revocations and demotions do not take effect while the affected user stays active: a space owner who removes someone's admin group cannot rely on it applying. The default per-process LocMemCache means the stale entry lives in the worker that primed it, so on multi-worker deployments the follow-up request must reach that worker.
Text from GitHub Advisory Database
Affected versions 1
- recipes2.6.15GitHub