A Django app hides its Refund button with {{ perms }} and guards the refund view, yet support agents still issue refunds; how do you find the gap?
answer
- follow the POST, not the page
- same code, several routes
- which methods are wrapped
- is the check as strict as the right
basics
~20 sTrace the request that actually performs the refund, not the page that shows the button. Usually another URL reaches the same code unguarded, a class-based view guards only get(), or the check is looser than the permission.
solid answer
~40 sI'd reproduce it as a support user and follow the POST: which URL pattern resolves, which view runs, and which checks wrap it. Typical gaps: the decorator guards the confirmation page (GET) while the form posts to a generic update view with no check; the same function is routed twice, once wrapped in `permission_required` in the URLconf and once bare; `method_decorator(permission_required(...), name="get")` leaves `post()` open; or the check is `user_passes_test(lambda u: u.is_staff)`, and support agents are staff. Then check the data: `user.get_all_permissions()` shows whether the support group was granted `payments.refund_payment`. Fix it by guarding the view that writes, on `dispatch()` or with `PermissionRequiredMixin`, add tests that POST as a support user and expect a 403, and consider checking `has_perm()` inside the shared refund code as well.
code
python · 22 linesfrom django.contrib.auth.decorators import permission_required
from django.utils.decorators import method_decorator
from django.views.generic import UpdateView
from payments.models import Payment
# Gap: only get() is wrapped; UpdateView.post() still saves for any caller.
@method_decorator(permission_required("payments.refund_payment"), name="get")
class LeakyRefundView(UpdateView):
model = Payment
fields = ["status"]
# Fix: dispatch() is the entry point for every HTTP method.
@method_decorator(
permission_required("payments.refund_payment", raise_exception=True),
name="dispatch",
)
class RefundView(UpdateView):
model = Payment
fields = ["status"]go deeper
Recall that the template check only hides markup and that the view performing the refund needs its own permission check.
Explain how method_decorator on get() or a second URL pattern can leave the writing path unguarded, and how dispatch() covers every method.
Run the audit from the real request: resolve the URL, inspect the wrapping, check the grants, then pin the fix with negative tests and a service-level check.
Decide where authorization lives across views, services and APIs so new entry points inherit the rule instead of each re-implementing it.
## Start from the request that changes data The template did its job: support agents do not see the button. The refund still happens, so some request reaches refund code without passing a matching check. The investigation is about that **request**, not the page: 1. Reproduce it as a support user, or find the request in the access log: method, path, parameters. 2. Resolve the path: `django.urls.resolve(path)` returns a `ResolverMatch` whose `func` is the view that ran; for a class-based view, `func.view_class` names the class. 3. Look at what wraps that view: decorators in the view module, wrappers applied in the URLconf, mixins in the class bases. 4. Check the grants: `user.get_all_permissions()` for the agent shows whether `payments.refund_payment` reached them, directly or through a group. ## Where enforcement usually leaks | Symptom | Cause | Fix | |---|---|---| | Confirmation page refuses support, POST succeeds | the check is on the page view; the form posts to another view, such as a generic update view that can set `status` | guard the view that writes | | One URL refuses, another succeeds | the function is routed twice, wrapped in `permission_required` in one `path()` and bare in another | decorate the function itself, or remove the extra route | | GET refused, POST allowed | `method_decorator(..., name="get")` wraps only `get()` | decorate `dispatch()` or use `PermissionRequiredMixin` | | Every staff member can refund | `user_passes_test(lambda u: u.is_staff)` or a group-name test stands in for the permission | check `payments.refund_payment` | | Only some support agents can refund | the permission was granted to the support group or to individuals | fix the grants; that is data, not code | Other entry points, such as an admin action or an API endpoint, reach the same refund code through their own permission machinery and need auditing in their own terms. ## Why class-based views leak so easily `method_decorator(decorator, name="...")` decorates exactly the method it names. Naming `get` leaves `post()` untouched, and generic editing views such as `UpdateView` implement `post()`, so the unguarded method performs the write. Decorating `dispatch()`, or listing `PermissionRequiredMixin` first in the bases, covers every HTTP method because every request passes through `dispatch()`. ## Hardening after the fix - **Guard the write, not the display.** Every view that changes state carries its own check, even when the page leading to it is already guarded. - **Check once more in the shared code.** If `Payment.refund()` or a refund service receives the acting user, it can raise `PermissionDenied` when `has_perm("payments.refund_payment")` is false. A future management command, admin action or API view that calls it then fails closed. - **Test the negative path.** For each URL that can trigger a refund, a test logs in a support user with the test client's `force_login()` and asserts a 403 on POST, and one test asserts that a finance user succeeds. - **Accept POST only.** `@require_POST` on the refund view stops a GET, including a prefetched link, from changing data. - **Keep one permission string.** The template, the view and the service all use `payments.refund_payment`; a typo in any of them either hides the button or closes the view for everyone except superusers. ## What not to conclude - That the template check failed: it only controls markup and never could stop a request. - That `LoginRequiredMiddleware` (Django 5.1) would have helped: it closes **login** gaps, and support agents are logged in. - That a guarded route means a guarded function: protection belongs to the wrapped callable a pattern points at, not to the Python function everywhere it is used.
- How would you prove that every refund-triggering route is guarded?List every URL pattern that can reach refund code, then write tests that `force_login()` a support user and POST to each one, asserting a 403, plus one test where a finance user succeeds. Run them in CI, so a new route added without a check fails the build instead of reaching production.
- Why also check has_perm inside the shared refund code?Views are only one entry point. A management command, an admin action or an API view added later can call the same refund method. If that method raises `PermissionDenied` when the acting user lacks `payments.refund_payment`, a forgotten view check fails closed instead of issuing money.
saying these in an interview costs you the question
- The button is hidden, so the leak must be in the template.
- method_decorator on get() protects every method of the class-based view.
- is_staff is equivalent to holding the refund permission.
- Guarding the confirmation page also protects the POST that follows it.
- If permission_required wraps one route, every route to that function is protected.