The session list checked the owner, the session ID didn't: cross-project form-session IDOR in CommCare formplayer (GHSA-qjg7-cgv8-3c43)
TL;DR What: formplayer is the Java and Spring service behind the Web Apps feature of CommCare HQ. Its incomplete-form routes, POST /incomplete-form and POST /delete-incomplete-form, looked up a form session by the ID
TL;DR
-
What: formplayer is the Java and Spring service behind the Web Apps feature of CommCare HQ. Its incomplete-form routes,
POST /incomplete-formandPOST /delete-incomplete-form, looked up a form session by the ID in the request body and never checked that the session belonged to the signed-in user. The query that lists a user's sessions filters by username and project space. The lookup and delete by ID didn't. -
Impact: per the advisory, a Web Apps user in any project space who knew another user's session ID, including one in a different project space, could read that session with every answer entered so far, alter it, or delete it. The ID is a random UUID, so it has to leak first. Rated Moderate 5.0 on the advisory (
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:L), CWE-639 and CWE-862. -
Fixed in formplayer
5088b09(PR #1797, merged 2026-10-07). Advisory GHSA-qjg7-cgv8-3c43 credits me as reporter and @gherceg for the fix. Self-hosted installs need a separate formplayer deploy.
Why you should care
CommCare is Dimagi's platform for building offline-first mobile apps for frontline workers. Formplayer backs the "Live Preview" and "Web Apps" features of CommCare HQ, the server side. A form that someone starts in Web Apps and doesn't finish is kept as an incomplete form session, and the user can come back to it from an Incomplete Forms list.
Project spaces are the tenant boundary in CommCare HQ. The advisory notes that in clinical and beneficiary forms, the answers in a session are PII and PHI.
The setup
Formplayer authenticates each Web Apps request against CommCare HQ, so by the time a route runs it knows the caller's username and project space (called domain in the code). The session list uses both. The incomplete-form routes take a session ID from the request body instead.
The bug
This is the open route at commit 372832b, the commit behind formplayer's 2026-08-12 production deploy tag:
// src/main/java/org/commcare/formplayer/application/IncompleteSessionController.java, commit 372832b, lines 37-45
@RequestMapping(value = Constants.URL_INCOMPLETE_SESSION, method = RequestMethod.POST)
@UserLock
@UserRestore
public NewFormResponse openIncompleteForm(@RequestBody SessionRequestBean incompleteSessionRequestBean,
@CookieValue(Constants.POSTGRES_DJANGO_SESSION_ID) String authToken) throws Exception {
SerializableFormSession session = formSessionService.getSessionById(incompleteSessionRequestBean.getSessionId());
storageFactory.configure(session);
return newFormResponseFactory.getResponse(session, commCareSessionFactory.getCommCareSession(session.getMenuSessionId()), incompleteSessionRequestBean.getWindowWidth());
}
And the delete route, in the same controller:
// src/main/java/org/commcare/formplayer/application/IncompleteSessionController.java, commit 372832b, lines 68-78
@RequestMapping(value = Constants.URL_DELETE_INCOMPLETE_SESSION, method = RequestMethod.POST)
public NotificationMessage deleteIncompleteForm(
@RequestBody SessionRequestBean incompleteSessionRequestBean,
@CookieValue(Constants.POSTGRES_DJANGO_SESSION_ID) String authToken) throws Exception {
deleteSession(incompleteSessionRequestBean.getSessionId());
return new NotificationMessage("Successfully deleted incomplete form.", false, NotificationMessage.Tag.incomplete_form);
}
protected void deleteSession(String id) {
formSessionService.deleteSessionById(id);
}
Both hand the ID straight to the service, which loads or deletes by primary key:
// src/main/java/org/commcare/formplayer/services/FormSessionService.java, commit 372832b, lines 54-61 and 103-106
@Cacheable
public SerializableFormSession getSessionById(String id) {
Optional<SerializableFormSession> session = formSessionRepo.findById(id);
if (!session.isPresent()) {
throw new FormNotFoundException(id);
}
return session.get();
}
...
@CacheEvict
public void deleteSessionById(String id) {
formSessionRepo.deleteById(id);
}
Neither method takes a username or a project space. The repository already had an owner-scoped query, which the session list uses:
// src/main/java/org/commcare/formplayer/repo/FormSessionRepo.java, commit 372832b, lines 15 and 22-23
public interface FormSessionRepo extends JpaRepository<SerializableFormSession, String> {
...
List<FormSessionListView> findByUsernameAndDomainAndAsUserIsNullOrderByDateCreatedDesc(
String username, String domain, Pageable page);
findById and deleteById come from JpaRepository and select by ID, with no owner in the query.
The "aha"
Formplayer knew who was asking. The session list used that. The lookup by ID didn't.
Proof of concept (benign)
I didn't stand up CommCare. On 2026-09-05 I compiled formplayer's own SerializableFormSession entity and FormSessionRepo interface, unmodified from commit 372832b, into a small test on Spring Data JPA, Hibernate and an in-memory H2 database. The test seeds two synthetic sessions, S_B for userB in domainB and S_A for userA in domainA. Then it makes the repository calls the two routes end in, findById and deleteById, with S_B's ID. No caller identity goes into either call, which is what the routes passed down too.
These are the test's own steps and assertions:
// my harness test (excerpt; "..." marks dropped lines)
// READ arm: Account A supplies S_B's UUID; findById takes only the id (no caller identity).
Optional<SerializableFormSession> readByA = formSessionRepo.findById(idB);
...
assertThat(got.getUsername()).isEqualTo("userB");
assertThat(got.getDomain()).isEqualTo("domainB");
assertThat(got.getInstanceXml()).contains(MARKER_B);
// POSITIVE CONTROL: scoped path filters by owner AND domain on the same two rows.
List<FormSessionListView> aScoped = formSessionRepo
.findByUsernameAndDomainAndAsUserIsNullOrderByDateCreatedDesc("userA", "domainA", PageRequest.of(0, 10));
List<FormSessionListView> aUserBDomain = formSessionRepo
.findByUsernameAndDomainAndAsUserIsNullOrderByDateCreatedDesc("userA", "domainB", PageRequest.of(0, 10));
...
assertThat(aUserBDomain).isEmpty();
// DELETE arm: Account A deletes S_B by id, unconditionally, cross-domain.
...
formSessionRepo.deleteById(idB);
...
assertThat(bGone).isTrue();
assertThat(aStillThere).isTrue();
The lookup by ID returned userB's session from domainB, saved answers included. The owner-scoped query found nothing for userA in domainB. The delete by ID removed userB's session and left userA's in place. Per my run notes, the test passed (1 test, 0 failures).
Honest boundary: this tested the repository layer, formplayer's own entity and repository interface, not FormSessionService or a running formplayer. I didn't send the HTTP requests, so the read through the full route, which also builds the form tree, wasn't tested by me. The advisory confirms the read, including the answers, and adds altering a session. The data was synthetic and the marker string is made up.
The advisory also says that while investigating my report, Dimagi found the same gap on every route that resolves a session by ID: the debugger read routes and the answer, repeat group, navigation and submit routes. I reported the two incomplete-form routes.
The fix
Shipped in formplayer 5088b09 through PR #1797, merged on 2026-10-07. The PR adds an annotation, @ValidateSessionOwner, and puts it on every route that acts on a session by ID. On the incomplete-form routes:
@RequestMapping(value = Constants.URL_INCOMPLETE_SESSION, method = RequestMethod.POST)
@UserLock
@UserRestore
+ @ValidateSessionOwner
public NewFormResponse openIncompleteForm(@RequestBody SessionRequestBean incompleteSessionRequestBean,
...
@RequestMapping(value = Constants.URL_DELETE_INCOMPLETE_SESSION, method = RequestMethod.POST)
+ @ValidateSessionOwner
public NotificationMessage deleteIncompleteForm(
An aspect runs before each annotated route, loads the session and compares its stored owner with the caller:
// src/main/java/org/commcare/formplayer/aspects/SessionOwnershipAspect.java, commit 5088b09, lines 46-47 and 75-86
@Before(value = "@annotation(org.commcare.formplayer.annotations.ValidateSessionOwner)")
public void validateSessionOwner(JoinPoint joinPoint) {
...
SerializableFormSession session = formSessionService.getSessionById(sessionId);
boolean owned = callerDomain.equals(session.getDomain())
&& TableBuilder.scrubName(callerUsername).equals(session.getUsername());
if (!owned) {
log.warn(String.format(
"Blocked user '%s' in domain '%s' from accessing form session %s owned by '%s' "
+ "in domain '%s'",
TableBuilder.scrubName(callerUsername), callerDomain, sessionId,
session.getUsername(), session.getDomain()));
// behaves the same as if the session does not exist
throw new FormNotFoundException(sessionId);
}
A session that belongs to someone else gets the same "form not found" answer as an ID that doesn't exist. Requests signed with formplayer's shared HMAC key, used for SMS and HQ-proxied form edits, skip the check by design. The PR also adds a test that fails the build if a route whose request body carries a session ID, a SessionRequestBean, lacks the annotation.
The advisory notes that a normal CommCare HQ deploy doesn't update formplayer. Self-hosted installs apply the fix with commcare-cloud <env> deploy formplayer.
Takeaways
- A lookup by primary key returns whatever row has that key. Here the list query filtered by username and project space, and the by-ID path needed the same comparison.
- Authenticating the caller isn't the same as checking ownership. Formplayer knew who the caller was on these routes. Nothing compared that with the session's stored owner until the fix.
- A random ID raised the bar, not the wall. The advisory rates attack complexity as high, and the UUID has to leak first, through shared-device browser history, a screenshot or a screen share.
Disclosure timeline
- 2026-09-01: found during source review.
- 2026-09-05: reproduced at the repository layer with the local harness above.
- 2026-09-06: reported by email to Dimagi's security team.
- 2026-09-08: Dimagi acknowledged the report.
- 2026-10-07: fix merged (PR #1797) and advisory GHSA-qjg7-cgv8-3c43 published.
Credit / CTA
If you self-host CommCare, make sure formplayer is at 5088b09 or later; a normal HQ deploy won't bring it in.
Reported by me; the advisory credits me as reporter and @gherceg for the fix. Comments and corrections welcome.
Santosh Kumar Puppala — AI/ML Platform Architect and security researcher (multiple CVEs; creator of Norviq). GitHub: @Santoshkumarpuppala
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.