fix(sharing): enforce object-level authorization outside the rules app

The rules endpoints were one instance of a pattern repeated across every
SharedObject-backed app. Route the rest through the same helper.

DCA entries were the worst case and are strictly wider than the reported
rules bug: DCAEntry has an unscoped default manager, so filtering by
strategy__id alone reached entries on strategies the caller could not see
at all. No public or shared strategy was needed -- only a guessable
integer. strategy_entry_add/edit/delete now resolve through the parent
strategy with via="strategy".

Every SharedObject delete view carried an inverted condition:

    if obj.owner != request.user and request.user in obj.shared_with.all():
        obj.shared_with.remove(request.user)
    else:
        obj.delete()

An object its owner had made public matched neither branch's intent and
fell through to delete(), so any authenticated user could destroy it.
Confirmed reachable for accounts, account groups, categories, tags,
entities and DCA strategies. The owner now deletes, a shared user revokes
only their own access, and anyone else gets a 403.

The API viewsets had no object-level check at all. DjangoModelPermissions
gated them shut for ordinary users, who hold no model permissions, so this
was not reachable in a default install -- but the check belongs there
regardless, and a user granted change_account in the admin could write any
visible account. SharedObjectPermission adds it for the SharedObject
viewsets, leaving reads to SharedObjectManager.

account_toggle_untracked only flips the calling user's own row in the
untracked_by m2m, so it takes READ rather than EDIT.

Refs GHSA-83g9-vjqf-2j5q
This commit is contained in:
Herculino Trotta
2026-09-01 23:15:41 -03:00
parent 039ad225d3
commit e2f26b3629
16 changed files with 785 additions and 183 deletions
+18 -31
View File
@@ -1,13 +1,19 @@
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.shortcuts import render
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_http_methods
from apps.accounts.forms import AccountGroupForm
from apps.accounts.models import AccountGroup
from apps.common.decorators.htmx import only_htmx
from apps.common.functions.permissions import (
EDIT,
READ,
get_shared_object_or_error,
)
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
@@ -63,17 +69,7 @@ def account_group_add(request, **kwargs):
@login_required
@require_http_methods(["GET", "POST"])
def account_group_edit(request, pk):
account_group = get_object_or_404(AccountGroup, id=pk)
if account_group.owner and account_group.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
account_group = get_shared_object_or_error(AccountGroup, request, id=pk, level=EDIT)
if request.method == "POST":
form = AccountGroupForm(request.POST, instance=account_group)
@@ -101,17 +97,18 @@ def account_group_edit(request, pk):
@login_required
@require_http_methods(["DELETE"])
def account_group_delete(request, pk):
account_group = get_object_or_404(AccountGroup, id=pk)
account_group = get_shared_object_or_error(AccountGroup, request, id=pk, level=READ)
if (
account_group.owner != request.user
and request.user in account_group.shared_with.all()
):
if account_group.is_editable_by(request.user):
account_group.delete()
messages.success(request, _("Account Group deleted successfully"))
elif account_group.shared_with.filter(pk=request.user.pk).exists():
# Someone else's object shared with us: we can drop our own access
# to it, but never delete it.
account_group.shared_with.remove(request.user)
messages.success(request, _("Item no longer shared with you"))
else:
account_group.delete()
messages.success(request, _("Account Group deleted successfully"))
raise PermissionDenied
return HttpResponse(
status=204,
@@ -125,7 +122,7 @@ def account_group_delete(request, pk):
@login_required
@require_http_methods(["GET"])
def account_group_take_ownership(request, pk):
account_group = get_object_or_404(AccountGroup, id=pk)
account_group = get_shared_object_or_error(AccountGroup, request, id=pk, level=EDIT)
if not account_group.owner:
account_group.owner = request.user
@@ -146,17 +143,7 @@ def account_group_take_ownership(request, pk):
@login_required
@require_http_methods(["GET", "POST"])
def account_group_share(request, pk):
obj = get_object_or_404(AccountGroup, id=pk)
if obj.owner and obj.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
obj = get_shared_object_or_error(AccountGroup, request, id=pk, level=EDIT)
if request.method == "POST":
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
+21 -28
View File
@@ -1,13 +1,19 @@
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.shortcuts import render
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_http_methods
from apps.accounts.forms import AccountForm
from apps.accounts.models import Account
from apps.common.decorators.htmx import only_htmx
from apps.common.functions.permissions import (
EDIT,
READ,
get_shared_object_or_error,
)
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
@@ -63,16 +69,7 @@ def account_add(request, **kwargs):
@login_required
@require_http_methods(["GET", "POST"])
def account_edit(request, pk):
account = get_object_or_404(Account, id=pk)
if account.owner and account.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
account = get_shared_object_or_error(Account, request, id=pk, level=EDIT)
if request.method == "POST":
form = AccountForm(request.POST, instance=account)
@@ -100,17 +97,7 @@ def account_edit(request, pk):
@login_required
@require_http_methods(["GET", "POST"])
def account_share(request, pk):
obj = get_object_or_404(Account, id=pk)
if obj.owner and obj.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
obj = get_shared_object_or_error(Account, request, id=pk, level=EDIT)
if request.method == "POST":
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
@@ -138,14 +125,18 @@ def account_share(request, pk):
@login_required
@require_http_methods(["DELETE"])
def account_delete(request, pk):
account = get_object_or_404(Account, id=pk)
account = get_shared_object_or_error(Account, request, id=pk, level=READ)
if account.owner != request.user and request.user in account.shared_with.all():
if account.is_editable_by(request.user):
account.delete()
messages.success(request, _("Account deleted successfully"))
elif account.shared_with.filter(pk=request.user.pk).exists():
# Someone else's object shared with us: we can drop our own access
# to it, but never delete it.
account.shared_with.remove(request.user)
messages.success(request, _("Item no longer shared with you"))
else:
account.delete()
messages.success(request, _("Account deleted successfully"))
raise PermissionDenied
return HttpResponse(
status=204,
@@ -159,7 +150,9 @@ def account_delete(request, pk):
@login_required
@require_http_methods(["GET"])
def account_toggle_untracked(request, pk):
account = get_object_or_404(Account, id=pk)
# Only flips the calling user's own row in untracked_by, so visibility --
# not ownership -- is the right bar here.
account = get_shared_object_or_error(Account, request, id=pk, level=READ)
if account.is_untracked_by():
account.untracked_by.remove(request.user)
messages.success(request, _("Account is now tracked"))
@@ -179,7 +172,7 @@ def account_toggle_untracked(request, pk):
@login_required
@require_http_methods(["GET"])
def account_take_ownership(request, pk):
account = get_object_or_404(Account, id=pk)
account = get_shared_object_or_error(Account, request, id=pk, level=EDIT)
if not account.owner:
account.owner = request.user