Skip to content

Commit eecb372

Browse files
nospameclaude
andcommitted
Restrict public web apps sessions to their own form session
Verify ownership at the single load chokepoint, FormSessionFactory: a public session may only build a live session whose stored username and domain match its HMAC-authenticated principal; otherwise throw FormNotFoundException. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5df6531 commit eecb372

2 files changed

Lines changed: 123 additions & 0 deletions

File tree

src/main/java/org/commcare/formplayer/application/FormSessionFactory.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package org.commcare.formplayer.application;
22

3+
import org.commcare.formplayer.beans.auth.HqUserDetailsBean;
4+
import org.commcare.formplayer.exceptions.FormNotFoundException;
35
import org.commcare.formplayer.objects.SerializableFormSession;
46
import org.commcare.formplayer.services.FormDefinitionService;
57
import org.commcare.formplayer.services.FormplayerRemoteInstanceFetcher;
@@ -8,13 +10,18 @@
810
import org.commcare.formplayer.services.RestoreFactory;
911
import org.commcare.formplayer.services.VirtualDataInstanceService;
1012
import org.commcare.formplayer.session.FormSession;
13+
import org.commcare.formplayer.util.RequestUtils;
14+
import org.commcare.modern.database.TableBuilder;
1115
import org.commcare.session.CommCareSession;
1216
import org.javarosa.core.model.actions.FormSendCalloutHandler;
1317
import org.jetbrains.annotations.NotNull;
1418
import org.springframework.beans.factory.annotation.Autowired;
1519
import org.springframework.lang.Nullable;
1620
import org.springframework.stereotype.Component;
1721

22+
import java.util.Objects;
23+
import java.util.Optional;
24+
1825
@Component
1926
public class FormSessionFactory {
2027

@@ -47,6 +54,7 @@ public FormSession getFormSession(SerializableFormSession serializableFormSessio
4754
@NotNull
4855
public FormSession getFormSession(SerializableFormSession serializableFormSession,
4956
@Nullable CommCareSession commCareSession, @Nullable String windowWidth) throws Exception {
57+
verifyPublicSessionOwnership(serializableFormSession);
5058
FormplayerRemoteInstanceFetcher formplayerRemoteInstanceFetcher = new FormplayerRemoteInstanceFetcher(
5159
runnerService.getCaseSearchHelper(),
5260
virtualDataInstanceService);
@@ -60,4 +68,24 @@ public FormSession getFormSession(SerializableFormSession serializableFormSessio
6068
windowWidth
6169
);
6270
}
71+
72+
/**
73+
* A public web apps session may only operate on the form session that its own one-time link
74+
* created. The session stores the scrubbed authoritative username, so any session bound to a
75+
* different user/domain is rejected. No-op for non-public sessions and non-request contexts.
76+
*
77+
* Package-private for testing.
78+
*/
79+
void verifyPublicSessionOwnership(SerializableFormSession session) {
80+
Optional<HqUserDetailsBean> userDetails = RequestUtils.getUserDetails();
81+
if (userDetails.isEmpty() || !userDetails.get().isPublicSession()) {
82+
return;
83+
}
84+
HqUserDetailsBean details = userDetails.get();
85+
boolean owned = Objects.equals(session.getDomain(), details.getDomain())
86+
&& Objects.equals(session.getUsername(), TableBuilder.scrubName(details.getUsername()));
87+
if (!owned) {
88+
throw new FormNotFoundException(session.getId());
89+
}
90+
}
6391
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package org.commcare.formplayer.application;
2+
3+
import static org.junit.jupiter.api.Assertions.assertThrows;
4+
import static org.mockito.Mockito.mock;
5+
import static org.mockito.Mockito.mockStatic;
6+
import static org.mockito.Mockito.when;
7+
8+
import org.commcare.formplayer.beans.auth.HqUserDetailsBean;
9+
import org.commcare.formplayer.exceptions.FormNotFoundException;
10+
import org.commcare.formplayer.objects.SerializableFormSession;
11+
import org.commcare.formplayer.util.RequestUtils;
12+
import org.commcare.modern.database.TableBuilder;
13+
import org.junit.jupiter.api.Test;
14+
import org.mockito.MockedStatic;
15+
16+
import java.util.Optional;
17+
18+
/**
19+
* Unit tests for {@link FormSessionFactory#verifyPublicSessionOwnership}: a public web apps session
20+
* may only load the form session its own one-time link created, never another user's by id.
21+
*/
22+
public class FormSessionFactoryTest {
23+
24+
private final FormSessionFactory factory = new FormSessionFactory();
25+
26+
private HqUserDetailsBean publicBean(String username, String domain) {
27+
HqUserDetailsBean bean = new HqUserDetailsBean(domain, new String[]{domain}, username,
28+
false, new String[]{}, new String[]{});
29+
bean.setPublicSession(true);
30+
return bean;
31+
}
32+
33+
private SerializableFormSession session(String username, String domain) {
34+
SerializableFormSession session = mock(SerializableFormSession.class);
35+
// Sessions persist the scrubbed username (see FormSession's new-session constructor).
36+
when(session.getUsername()).thenReturn(TableBuilder.scrubName(username));
37+
when(session.getDomain()).thenReturn(domain);
38+
when(session.getId()).thenReturn("session-id");
39+
return session;
40+
}
41+
42+
@Test
43+
public void publicSession_cannotLoadAnotherUsersFormSession() {
44+
SerializableFormSession victimSession = session("victim@domain", "domain");
45+
try (MockedStatic<RequestUtils> mocked = mockStatic(RequestUtils.class)) {
46+
mocked.when(RequestUtils::getUserDetails)
47+
.thenReturn(Optional.of(publicBean("public_abc@domain", "domain")));
48+
assertThrows(FormNotFoundException.class,
49+
() -> factory.verifyPublicSessionOwnership(victimSession));
50+
}
51+
}
52+
53+
@Test
54+
public void publicSession_cannotLoadFormSessionInAnotherDomain() {
55+
SerializableFormSession otherDomainSession = session("public_abc@other", "other");
56+
try (MockedStatic<RequestUtils> mocked = mockStatic(RequestUtils.class)) {
57+
mocked.when(RequestUtils::getUserDetails)
58+
.thenReturn(Optional.of(publicBean("public_abc@domain", "domain")));
59+
assertThrows(FormNotFoundException.class,
60+
() -> factory.verifyPublicSessionOwnership(otherDomainSession));
61+
}
62+
}
63+
64+
@Test
65+
public void publicSession_canLoadItsOwnFormSession() {
66+
SerializableFormSession ownSession = session("public_abc@domain", "domain");
67+
try (MockedStatic<RequestUtils> mocked = mockStatic(RequestUtils.class)) {
68+
mocked.when(RequestUtils::getUserDetails)
69+
.thenReturn(Optional.of(publicBean("public_abc@domain", "domain")));
70+
// Its own session passes the check (no exception).
71+
factory.verifyPublicSessionOwnership(ownSession);
72+
}
73+
}
74+
75+
@Test
76+
public void nonPublicSession_ownershipCheckIsSkipped() {
77+
// A regular session is unaffected: the check only constrains public sessions.
78+
SerializableFormSession anySession = session("someone-else@domain", "domain");
79+
try (MockedStatic<RequestUtils> mocked = mockStatic(RequestUtils.class)) {
80+
mocked.when(RequestUtils::getUserDetails)
81+
.thenReturn(Optional.of(new HqUserDetailsBean("domain", "user")));
82+
factory.verifyPublicSessionOwnership(anySession);
83+
}
84+
}
85+
86+
@Test
87+
public void noAuthenticatedUser_ownershipCheckIsSkipped() {
88+
// Non-request / unauthenticated contexts (e.g. purge tasks) must not be blocked.
89+
SerializableFormSession anySession = session("someone@domain", "domain");
90+
try (MockedStatic<RequestUtils> mocked = mockStatic(RequestUtils.class)) {
91+
mocked.when(RequestUtils::getUserDetails).thenReturn(Optional.empty());
92+
factory.verifyPublicSessionOwnership(anySession);
93+
}
94+
}
95+
}

0 commit comments

Comments
 (0)