DEV Community

PUT checked the project space, DELETE didn't: cross-tenant deletes in the CommCare HQ API (GHSA-hq38-78vf-f4cj)

TL;DR

What: CommCare HQ's REST API deleted mobile workers, lookup tables and lookup-table rows by the ID in the URL without checking that the object belonged to the project space in the URL. The read and update handlers on the same resources did check.

Impact: per the advisory, a user with the relevant API permission in a project space they control could, given an object ID from another project space, retire a mobile worker there or delete its lookup tables and rows. The IDs aren't sequential and, per the advisory, generally aren't exposed across project spaces. Rated Moderate 6.8 on the advisory (CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:H), CWE-639 and CWE-863.

Fixed in commit 3570c0ff9c7 (PR #38029), merged on 2026-08-14. Advisory GHSA-hq38-78vf-f4cj credits me as reporter.

Why you should care

CommCare HQ is the server side of CommCare, Dimagi's platform for building offline-first mobile apps for frontline workers. One instance hosts many project spaces, and the project space is the tenant boundary.

Mobile workers are the field users of a project's apps. Lookup tables hold reference data that a project's apps and forms read. A delete from another tenant takes away a project's field user or the data its forms depend on.

The setup

The API is built on django-tastypie. Each request names a project space in its URL, /a/<domain>/api/... (the code calls a project space a domain).

Authentication checks that the caller has the required permission in that project space. It says nothing about the object the request points at. That second check is left to each resource's own methods.

The bug

The mobile-worker resource, at commit 2ef6f1c from 2026-08-13:

# corehq/apps/api/resources/v0_5.py, commit 2ef6f1c, lines 351-353 and 375-380 (CommCareUserResource)
def obj_update(self, bundle, **kwargs):
    bundle.obj = CommCareUser.get(kwargs['pk'])
    assert bundle.obj.domain == kwargs['domain']
    ...
def obj_delete(self, bundle, **kwargs):
    user = CommCareUser.get(kwargs['pk'])
    if user:
        user.retire(bundle.request.domain, deleted_by=bundle.request.couch_user, deleted_via=USER_CHANGE_VIA_API)
    return ImmediateHttpResponse(response=http.HttpAccepted())

The update handler loads the user and asserts it belongs to the URL's project space. The delete handler loads the user by ID alone and retires it.

The lookup-table resource has the same shape. Like its read handler, its update handler raises NotFound when the table's project space doesn't match the URL's. Its delete handler filters by ID only:

# corehq/apps/fixtures/resources/v0_1.py, commit 2ef6f1c, lines 176-183 and 208-214 (LookupTableResource)
def obj_delete(self, bundle, **kwargs):
    query = LookupTable.objects.filter(id=kwargs['pk'])
    if not query.exists():
        raise NotFound('Lookup table not found')
    query.delete()
    clear_fixture_cache(kwargs['domain'], [kwargs['pk']])
    return ImmediateHttpResponse(response=HttpAccepted())
    ...
    try:
        bundle.obj = LookupTable.objects.get(id=kwargs['pk'])
    except LookupTable.DoesNotExist:
        raise NotFound('Lookup table not found')
    if bundle.obj.domain != kwargs['domain']:
        raise NotFound('Lookup table not found')

And the lookup-table row resource:

# corehq/apps/fixtures/resources/v0_1.py, commit 2ef6f1c, lines 359-367 (LookupTableItemResource)
def obj_delete(self, bundle, **kwargs):
    try:
        row = LookupTableRow.objects.get(id=kwargs['pk'])
    except LookupTableRow.DoesNotExist:
        raise NotFound('Lookup table item not found')
    table_id = row.table_id
    row.delete()
    clear_fixture_cache(row.domain, [table_id])
    return ImmediateHttpResponse(response=HttpAccepted())

So a caller with permission to edit mobile workers through the API in project space X could send DELETE /a/X/api/v0.5/user/<id>/ with the ID of a mobile worker in project space Y, and the request would pass authentication in X and retire the user in Y. The advisory lists the affected routes for users, lookup tables and rows across the v0.5, v0.6, v1 and v2 API paths.

The "aha"

The URL's project space authenticated the caller. The read and update handlers checked that the object belonged to it; the delete handlers didn't.

Proof of concept (benign)

I didn't run a full CommCare HQ. On 2026-08-13 I ran a harness that drives django-tastypie 0.15.1, the version CommCare HQ pins, through its real request handling. Into it I copied CommCare HQ's obj_delete method from commit 2ef6f1c, and obj_update up to its project-space check, with the class names swapped for in-memory stand-ins.

The harness seeded one synthetic mobile worker in domain_Y, and caller A made the request as a user of domain_X. These are the delete lines of the harness's output, as recorded in my run notes (the harness's separator lines removed):

STEP 2 -- THE FINDING: A issues DELETE (obj_delete, the unguarded sink)
DELETE dispatch returned HTTP 204 (HttpNoContent)
target user domain at delete time was: domain_Y (caller's domain: domain_X)
target user base_doc AFTER delete: CouchUser-Deleted
retire() was invoked with retired_by_domain=[('domain_X', 'stub-deletion-id')]

The delete from domain_X went through and marked the domain_Y user deleted. It returned 204, not the 202 in the handler, because tastypie returns 204 after a delete. A second harness ran the same delete for a lookup table and a lookup-table row, and both were removed.

The harness also tried the update as a control. On review, that step never reached CommCare HQ's code: the stand-in request object lacks a method tastypie calls on every PUT, so the PUT failed with a 500 before obj_update ran. It doesn't show the update check working. The check is in the source above, and the advisory describes only the deletes as affected.

Honest boundary: the harness ran tastypie's real request path, but with stand-ins for the request object, the database, authentication, CommCare HQ's base-resource dispatch check (which looks only at the caller's own project space), and retire() (a reduced copy that marks the user deleted). I didn't send HTTP requests to a running CommCare HQ. The impact on a live instance is as the advisory states it. The data was synthetic.

The fix

Fixed in commit 3570c0ff9c7, through PR #38029, merged on 2026-08-14. Each delete now looks the object up within the URL's project space:

# corehq/apps/api/resources/v0_5.py, commit 3570c0ff9c7
def obj_delete(self, bundle, **kwargs):
    user = CommCareUser.get_by_user_id(kwargs['pk'], kwargs['domain'])
    if user:
        user.retire(bundle.request.domain, deleted_by=bundle.request.couch_user, deleted_via=USER_CHANGE_VIA_API)
    else:
        raise NotFound(f"Could not find user for id: {kwargs['pk']}")
    return ImmediateHttpResponse(response=http.HttpAccepted())
# corehq/apps/fixtures/resources/v0_1.py, commit 3570c0ff9c7
def obj_delete(self, bundle, **kwargs):
    try:
        query = LookupTable.objects.get(id=kwargs['pk'], domain=kwargs['domain'])
    except LookupTable.DoesNotExist:
        raise NotFound('Lookup table not found')
    query.delete()
    ...
def obj_delete(self, bundle, **kwargs):
    try:
        row = LookupTableRow.objects.get(id=kwargs['pk'], domain=kwargs['domain'])
    except LookupTableRow.DoesNotExist:
        raise NotFound('Lookup table item not found')
    table_id = row.table_id

Per the advisory, a foreign object ID now returns 404 instead of being deleted. The advisory asks self-hosted instances to deploy 3570c0ff9c7 or later. For anyone who can't upgrade right away, it suggests blocking the /a/<domain>/api/ paths at the proxy, since per-user permission changes don't help when the attacker controls their own project space.

Takeaways

  • Each object had read, update and delete handlers, and only two checked ownership. Read and update compared the object's project space with the URL's. Delete didn't, on three resources.
  • Authentication answered a different question. It confirmed the caller's permission in the project space named in the URL, not that the object lived there.
  • Looking up by ID within the tenant closes it. The fix keeps the same handlers and adds the project space to the lookup.

Disclosure timeline

  • 2026-08-12: found during source review.
  • 2026-08-13: reproduced with the harness above.
  • 2026-08-14: reported by email to Dimagi's security team; the fix (PR #38029) was merged the same day.
  • 2026-08-16: Dimagi acknowledged the report.
  • 2026-08-20: advisory GHSA-hq38-78vf-f4cj published.

Credit / CTA

If you self-host CommCare HQ, make sure you've deployed 3570c0ff9c7 or later. Reported by me; the advisory credits me as reporter. Comments and corrections welcome.

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

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.