The role check passed, the group check was missing: cross-org IDOR in CiviForm (CVE-2026-107856)
TL;DR What: CiviForm's Trusted Intermediary (TI) dashboard has a handler, showEditClientForm, that renders the edit form for a client. It checked that the caller had the TI role. It never checked that the requested a
TL;DR
-
What: CiviForm's Trusted Intermediary (TI) dashboard has a handler,
showEditClientForm, that renders the edit form for a client. It checked that the caller had the TI role. It never checked that the requested account belonged to the caller's TI group. A TI could put the account id of another group's client in the URL and get that client's form back, pre-filled. -
Impact: client data crosses the boundary between TI groups. In my local run on synthetic accounts, the response carried the first name, last name and email of the other group's client. The CVE record says a TI can enumerate
accountIdvalues. Rated Moderate 4.5 on the advisory (CVSS:3.1/AV:N/AC:L/PR:H/UI:R/S:U/C:H/I:N/A:N), with the same score and vector on the CVE record. CWE-639. - Fixed in v3.33.0 (affected <= 3.32.0). Advisory GHSA-qv7c-9hjr-9rv7 credits me as reporter. CVE record: CVE-2026-107856.
Why you should care
CiviForm is an open-source platform for government benefits applications. It reuses an applicant's data across benefit applications, and the project's docs say it is deployed by governments at city, county and state level. Its Trusted Intermediary role is for staff at community organizations who apply on behalf of residents, working from a dashboard of their own clients.
Those organizations are a boundary. In the code they are TI groups (TrustedIntermediaryGroupModel, table ti_organizations), and the docs say applicant PII is "not visible to other organizations". showEditClientForm did not enforce that.
The setup
CiviForm is Java on the Play Framework. Controller actions are gated with pac4j's @Secure annotation, which names an authorizer. The TI dashboard uses @Secure(authorizers = Authorizers.Labels.TI), and that authorizer requires the TI role. It answers one question: is this caller a Trusted Intermediary?
It doesn't say which accounts this TI may touch. Two handlers in this controller, showAddClientForm and addClient, compare a group id in their URL with the caller's group. For this form, the account-level check is in TrustedIntermediaryService.updateClientInfo, on the save path. showEditClientForm takes an accountId and had neither.
The bug
This is the handler in v3.32.0. The file is identical at v3.31.0:
// server/app/controllers/ti/TrustedIntermediaryController.java:173-194 (v3.32.0)
@Secure(authorizers = Authorizers.Labels.TI)
public Result showEditClientForm(Long accountId, Http.Request request) {
CiviFormProfile civiformProfile = profileUtils.currentUserProfile(request);
Optional<TrustedIntermediaryGroupModel> trustedIntermediaryGroup =
accountRepository.getTrustedIntermediaryGroup(civiformProfile);
if (trustedIntermediaryGroup.isEmpty()) {
return notFound();
}
String applicantName =
accountRepository.lookupAccount(accountId).get().getApplicantDisplayName();
return ok(
editTiClientView.render(
/* tiGroup= */ trustedIntermediaryGroup.get(),
/* personalInfo= */ ApplicantPersonalInfo.ofLoggedInUser(
Representation.builder().setName(applicantName).build()),
/* request= */ request,
/* messages= */ messagesApi.preferred(request),
/* accountIdToEdit= */ Optional.of(accountId),
/* applicantIdOfTi= */ getTiApplicantIdFromCiviformProfile(civiformProfile),
/* tiClientInfoForm= */ Optional.empty(),
/* applicantIdOfNewlyAddedClient= */ null));
}
The caller's group gets loaded, and the only branch on it is the 404 for a caller who has no group. It is never compared with accountId. And lookupAccount is a primary-key lookup with no notion of who is asking:
// server/app/repository/AccountRepository.java:374-381 (v3.32.0)
public Optional<AccountModel> lookupAccount(long accountId) {
return database
.find(AccountModel.class)
.setId(accountId)
.setLabel("AccountModel.findById")
.setProfileLocation(queryProfileLocationBuilder.create("lookupAccount"))
.findOneOrEmpty();
}
In my run the name came back as form field values. That's because the handler passes accountId to EditTiClientView, which looks the account up again, takes its representative applicant and pre-fills the edit form. Here is that path for the first name:
// server/app/views/trustedintermediary/EditTiClientView.java:84-86,224,239-246 (v3.32.0)
if (accountIdToEdit.isPresent()) {
optionalAccountModel =
Optional.of(accountRepository.lookupAccount(accountIdToEdit.get()).get());
...
optionalApplicant = optionalAccount.get().representativeApplicant();
...
FieldWithLabel firstNameField =
setStateIfPresent(
FieldWithLabel.input()
.setId("first-name-input")
.setFieldName("firstName")
.setLabelText(messages.at(MessageKey.NAME_LABEL_FIRST.getKeyName()))
.setRequired(true)
.setValue(optionalApplicant.flatMap(ApplicantModel::getFirstName)),
Two neighbours show what a group check looks like in this code. showAddClientForm compares the group id in its URL with the caller's group:
// server/app/controllers/ti/TrustedIntermediaryController.java:155-157 (v3.32.0)
if (!trustedIntermediaryGroup.get().id.equals(id)) {
return unauthorized();
}
That route renders a blank add-client form and holds no client data, so it isn't answering the same question. The closer comparison is the save path for this same form, which only accepts accounts from the caller's group:
// server/app/services/ti/TrustedIntermediaryService.java:192-200 (v3.32.0)
Optional<AccountModel> accountMaybe =
tiGroup.getManagedAccounts().stream()
.filter(account -> account.id.equals(accountId))
.findAny();
ApplicantModel applicant =
accountMaybe
.flatMap(AccountModel::representativeApplicant)
.orElseThrow(() -> new ApplicantNotFoundException(accountId));
So, reading the code, saving an edit to another group's client fails on that lookup. showEditClientForm took an accountId and never checked it against the caller's group.
The "aha"
The role check answered "are you a Trusted Intermediary?" Nothing in this handler asked "is this client yours?"
Proof of concept (benign)
I reproduced this locally in Docker on 2026-07-06, with a script and synthetic accounts. No hosted system was involved. The TIs logged in through CiviForm's non-production dev-tools shortcut, and the script puts the second TI in a different group with a direct database update. The run did not record an exact image version; the handler code is identical at v3.31.0 and v3.32.0.
- TI-A: account 32, group 1, manages Client A (account 34).
-
TI-B: account 33, group 2, manages Client B: first name
ClientB, last nameOrgTwo, email[email protected], account 35.
Logged in as TI-A, the script requested Client B's edit form, then ran two controls. These are the recorded lines, in order. The last three are a grep of the first response for Client B's values, not a raw excerpt, and the # lines are my labels:
# as TI-A: GET /admin/tiDash/editClientForm/35
Cross-org editClientForm HTTP status: 200
Client B PII detected in response body: 1 (1=yes)
# as TI-A: GET /admin/tiDash/addClientForm/2
Control 1 status (expect 401/403 = enforced elsewhere): 401
# as TI-A: /admin/tiDash?page=1, checked for Client B's email
Control 2 own-list leak (expect 0): 0
# grep of the response to the first request
value="ClientB" maxlength="10000" aria-required="true" id="first-name-input" name="firstName" class="usa-input"
value="OrgTwo" maxlength="10000" aria-required="true" id="last-name-input" name="lastName" class="usa-input"
[email protected]
Control 1 is the blank add-client form for group 2. Its handler compares the group id in the URL with the caller's group, and TI-A got a 401. Control 2 shows that page 1 of TI-A's own client list did not contain Client B's email.
accountId is a bigserial primary key (server/conf/evolutions/default/5.sql), and the CVE record says a TI can enumerate it. I requested one id. I did not run enumeration.
Honest boundary: the script looked for Client B's name and email and nothing else, so that is all this run can speak to. It did not send a cross-group save. I did not re-run it against 3.33.0, so the 403 below comes from the code and the maintainers' test, not from a retest of mine.
The fix
Shipped in v3.33.0 (2026-07-22), through PR #13635 and commit ccfd84ff, merged three days after my report. The patch adds the membership test before the lookup: if the account isn't among the managed accounts of the caller's group, the handler returns 403. As shipped:
// server/app/controllers/ti/TrustedIntermediaryController.java:182-187 (v3.33.0)
boolean isManagedAccount =
trustedIntermediaryGroup.get().getManagedAccounts().stream()
.anyMatch(account -> account.id.equals(accountId));
if (!isManagedAccount) {
return forbidden();
}
The same commit adds a test for this case, testShowEditClientForm_returnsForbiddenForClientNotInGroup.
Takeaways
- A role check is not an ownership check. The annotation here answered only the role question, and nothing else in this handler asked the second one.
- On this form, the group filter sat on the save path only. Saving an edit went through a service method that filtered by the caller's group. Showing the form called the repository directly, so that filter never ran. The fix adds a membership test on the same managed-accounts list to the form handler.
- Sequential ids make the other records easy to find. They aren't a vulnerability by themselves, but they widen what a missing check exposes.
Disclosure timeline
- 2026-06-23: found.
- 2026-07-06: reproduced locally with synthetic accounts.
- 2026-07-17: reported through GitHub private vulnerability reporting.
- 2026-07-20: fix merged (PR #13635).
- 2026-07-22: fix released in v3.33.0.
- 2026-08-10: advisory GHSA-qv7c-9hjr-9rv7 published.
- 2026-10-09: CVE-2026-107856 published.
Credit / CTA
If you run CiviForm with Trusted Intermediaries, upgrade to 3.33.0 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
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.