DEV Community

Santosh Kumar Puppala
Santosh Kumar Puppala

Posted on

It checked the request, but it changed the composer: FOIA request takeover in MuckRock

TL;DR

  • What: In MuckRock — the FOIA / public-records platform that runs muckrock.com and is self-hostable — a collaborator holding only the editor tier on a request could reassign that request's owner to any account on the platform, via POST action=change_owner.
  • Impact: The editor hands an embargoed public-records request to an account they control, and the account that filed it is locked out of its own request entirely — HTTP 404. CWE-269 / CWE-862. I score it CVSS v3.1 8.1 (High) (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N) — that is my scoring; there is no advisory and no vendor rating, and a scorer who declines the confidentiality impact would land at 6.5.
  • Fixed: a one-line change on main, three days after I reported it. MuckRock publishes no tagged releases and deploys continuously from main, so there is no version number to point at. Reported by me, Santosh Kumar Puppala, under coordinated disclosure. No CVE has been assigned.

Why you should care

A lot of American public-records work runs through MuckRock. Journalists, researchers and ordinary citizens file FOIA requests on it, and requests can be embargoed — deliberately hidden while a story is still being reported. It is also multi-tenant in the most literal sense: independent requesters who compete with each other share one deployment.

FOIA requests are collaborative. You invite an editor onto a request so they can help chase an agency. That invitation is a trust decision, but it is a bounded one — you're granting help, not handing over the story.

This bug is what happens when the bound isn't enforced by the code that matters.

The setup

MuckRock's authorization lives in a rules.py predicate file — a clean, readable design. Two predicates matter here (four lines, gathered from across that file):

# muckrock/foia/rules.py
can_edit          = is_owner | is_editor | is_staff | is_proxy   # :200
can_edit_composer = is_owner_composer | is_staff                 # :229

add_perm("foia.change_foiarequest",  can_edit)                   # :232
add_perm("foia.change_foiacomposer", can_edit_composer)          # :257
Enter fullscreen mode Exit fullscreen mode

Read those together and the intent is unambiguous. Editing a request is something editors may do. Editing the composer — the object that carries user, i.e. who owns this request — is reserved for the owner and staff. The codebase already knew ownership was a higher-privilege concept than editing. It wrote the rule down.

The other piece of the setup is how the request detail page handles POSTs:

# muckrock/foia/views/detail.py:388-394  (abridged)
action = getattr(detail_actions, request.POST.get("action", ""), None)
...
return action(request, self.foia)
Enter fullscreen mode Exit fullscreen mode

There is no allowlist. Every callable in the detail_actions module is effectively a user-reachable endpoint, selected by an attacker-supplied string. That alone isn't the vulnerability, but it means the security of the page is only as strong as the weakest permission check inside that module — and nobody audits that module as an attack surface, because it doesn't look like a router.

The bug

Here is the action the string change_owner selects:

# muckrock/foia/views/detail_actions.py:511-521  (abridged)
has_perm = foia.has_perm(request.user, "change")
...
if not has_perm or not form.is_valid():
    return _get_redirect(...)
form.change_owner(request.user, [foia])
Enter fullscreen mode Exit fullscreen mode

foia.has_perm(request.user, "change") resolves to foia.change_foiarequest — which is can_edit — which includes is_editor.

And here is what the form actually writes:

# muckrock/foia/forms/detail.py:105-134  (abridged)
user = ModelChoiceField(queryset=User.objects.all())
...
foia.composer.user = new_user
foia.composer.save()
...  # an audit note recording the change is written here
Enter fullscreen mode Exit fullscreen mode

Two things to notice. First, the queryset is User.objects.all() — the new owner can be any account on the platform, not just an existing collaborator on the request. Second, the field being written is foia.composer.user. That is a composer mutation, governed by can_edit_composer — the predicate that deliberately excludes editors — and can_edit_composer is never consulted.

The gate checked the permission for the object it routed through; the write landed on a different object, with a stricter permission the codebase had already defined and simply never called.

That framing matters because it's what separates this from a design decision. If rules.py had no can_edit_composer, a maintainer could reasonably say "editors are trusted, this is intended." But the project's own permission model says owner mutations are owner-and-staff only. The check that enforces that rule exists. It just isn't on this path.

It's also the only such path. revoke_access — the other way to remove someone — edits the read/edit collaborator many-to-many sets, and composer.user is in neither of them. An editor cannot dislodge the owner any other way. change_owner isn't a redundant convenience; it's a unique escalation.

Proof of concept

I reproduced this end to end against shipped code (HEAD 817f134), driving the real FOIARequestDetail.post()change_owner()FOIAOwnerForm.change_owner() path with detail.py, detail_actions.py, forms/detail.py and rules.py unmodified. (The harness used MuckRock's own test settings and Django's test client, because login is delegated to a separate SSO service, and an unrelated private submodule was stubbed so Django would boot. Neither touches the permission logic under test.)

Five synthetic accounts, one embargoed request, deliberately benign — the "payload" is a permission test, nothing more:

  • A — owner of an embargoed request
  • B — editor collaborator, added via the owner-conferred add_editor path
  • C — an unrelated account, standing in for an account B controls
  • D — authenticated stranger, no role at all
  • E — read-only viewer on the request

B, authenticated as an editor only, posted action=change_owner with user=C. The response was a 302 to the same page — identical to the failure path, which is why the database is the real oracle (foia_foiacomposer):

composer_id | composer_owner_id | foia_id | embargo_status
1           | 1  (Account A)    | 1       | embargo    <- before
1           | 3  (Account C)    | 1       | embargo    <- after B's POST
Enter fullscreen mode Exit fullscreen mode

Then the part that settles the impact: A requested her own embargoed FOIA request and got HTTP 404. has_perm(A, "view") was now False. She had not been demoted; she had been erased from the object.

The controls are what make the boundary precise:

  • Positive control — C, now the legitimate owner, transferred it back to A. The feature works correctly for an owner. The defect is the gate, not the function.
  • Negative control #1 — D (no role) is blocked earlier, at get_object(). 404, owner unchanged.
  • Negative control #2, the sharp one — E is a viewer: has_perm(E, "view") is True, so E reaches the same handler B did, but has_perm(E, "change") is False and the transfer is refused. Owner unchanged.

So the exploitable line falls exactly at the editor tier. Not "anyone who can see the request," not "any authenticated user" — specifically the collaboration tier the owner grants, which is the one the codebase said should not be able to do this.

One detail I enjoyed: the app writes its own audit note during the transfer, so the exploit signs its own name.

foia_foianote: author_id=2  "poc_editor_b (2) changed ownership of this request
                             from poc_owner_a (1) to poc_recipient_c (3)"
Enter fullscreen mode Exit fullscreen mode

The fix

The fix that landed is one line — commit 9e6e82c1, 2026-07-21, one file, +1/−1, commit message "Fix sharing". It changes the gate to the one the codebase was already asking for: authorize against the object you're about to mutate.

- has_perm = foia.has_perm(request.user, "change")
+ has_perm = foia.composer.has_perm(request.user, "change")
Enter fullscreen mode Exit fullscreen mode

foia.composer.has_perm(..., "change") resolves to can_edit_composer = is_owner_composer | is_staff, so editors are refused and owners are unaffected.

Two hardening changes would belong with it, and as far as I can tell were not part of that one-line commit: replace the getattr(detail_actions, action) dispatch with an explicit allowlist, and constrain FOIAOwnerForm.user to plausible targets rather than every account in the database.

MuckRock has no tagged releases — it deploys continuously from main, so "fixed in" here means "landed on main" rather than a version number. I confirmed the fix from the commit; I have not rebuilt a patched instance and re-run the PoC against it.

Takeaways

Authorize the object you mutate, not the object you routed through. The permission check and the write were on different models. That's an easy mistake in any ORM where a.b.c = x reads like one operation on a, and it's invisible to a reviewer skimming for "is there a permission check here?" — because there is one. It's just checking the wrong noun.

When a codebase defines two permissions for two tiers, grep every call site of the looser one. can_edit vs can_edit_composer is a documented intent. Any place that writes composer state while checking can_edit is, by the project's own standard, a bug. That's a mechanical audit you can run in minutes, and it generalizes: find the permission pair, then find the mismatched call sites.

getattr(module, user_input) dispatch quietly promotes a whole module into a router. Every function becomes reachable, and reviewers stop treating the module as an attack surface because it has no URL patterns. Siblings in that module — promote, demote, generate_key, withdraw — appear to share the same gate; I flagged them as leads rather than chasing them, which is exactly why variant sweeps are worth doing after any confirmed sink.

Disclosure timeline

Date Event
2026-07-14 Found during source review; live PoC confirmed the same day (5-account harness, positive control + two negative controls)
2026-07-18 Reported by email to MuckRock's maintainers and general contact address under coordinated disclosure, with full remediation guidance
2026-07-21 One-line fix lands on main — commit 9e6e82c1, "Fix sharing", one file, +1/−1. Three days after the report.
No CVE assigned

Credit

Reported by Santosh Kumar Puppala — GitHub: @Santoshkumarpuppala. My thanks to the MuckRock team, who run a genuinely important piece of public-interest infrastructure on a nonprofit budget, and who turned this around in three days.

If you maintain a Django app with a rules.py-style predicate file, go grep for the tighter predicate and check who's actually calling it. That's a fifteen-minute audit and it's how this one was found.


Santosh Kumar Puppala — AI/ML Platform Architect and security researcher (multiple CVEs; creator of Norviq & Veridor). GitHub: @Santoshkumarpuppala

Top comments (0)