Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,8 @@ private FormEntryResponseBean saveAnswer(AnswerQuestionRequestBean answerQuestio
@UserRestore
@ConfigureStorageFromSession
public FormEntryResponseBean newRepeat(@RequestBody RepeatRequestBean newRepeatRequestBean,
@CookieValue(Constants.POSTGRES_DJANGO_SESSION_ID) String authToken) throws Exception {
@CookieValue(name = Constants.POSTGRES_DJANGO_SESSION_ID, required = false) String authToken)
throws Exception {
SerializableFormSession serializableFormSession = formSessionService.getSessionById(
newRepeatRequestBean.getSessionId());
FormSession formEntrySession = formSessionFactory.getFormSession(serializableFormSession, newRepeatRequestBean.getWindowWidth());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package org.commcare.formplayer.application;

import org.commcare.formplayer.beans.auth.HqUserDetailsBean;
import org.commcare.formplayer.exceptions.FormNotFoundException;
import org.commcare.formplayer.objects.SerializableFormSession;
import org.commcare.formplayer.services.FormDefinitionService;
import org.commcare.formplayer.services.FormplayerRemoteInstanceFetcher;
Expand All @@ -8,13 +10,18 @@
import org.commcare.formplayer.services.RestoreFactory;
import org.commcare.formplayer.services.VirtualDataInstanceService;
import org.commcare.formplayer.session.FormSession;
import org.commcare.formplayer.util.RequestUtils;
import org.commcare.modern.database.TableBuilder;
import org.commcare.session.CommCareSession;
import org.javarosa.core.model.actions.FormSendCalloutHandler;
import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;

import java.util.Objects;
import java.util.Optional;

@Component
public class FormSessionFactory {

Expand All @@ -40,13 +47,15 @@ public class FormSessionFactory {
private CommCareSessionFactory commCareSessionFactory;

public FormSession getFormSession(SerializableFormSession serializableFormSession, String windowWidth) throws Exception {
verifyPublicSessionOwnership(serializableFormSession);
CommCareSession commCareSession = commCareSessionFactory.getCommCareSession(serializableFormSession.getMenuSessionId());
return getFormSession(serializableFormSession, commCareSession, windowWidth);
}

@NotNull
public FormSession getFormSession(SerializableFormSession serializableFormSession,
@Nullable CommCareSession commCareSession, @Nullable String windowWidth) throws Exception {
verifyPublicSessionOwnership(serializableFormSession);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a caller calls getFormSession() with two arguments, it calls getCommCareSession() before calling this line. getCommCareSession() loads the target menu session and runs installService.configureApplication(...) against a DB. A public session POSTing /answer with someone else's sessionId opens the victim's app DB, and if re-init throws, InstallService calls sqliteDB.deleteDatabaseFile() on it, before FormNotFoundException.

Move this check to the 2-arg entry point above (line 50) before commCareSession is set, or into an aspect.

@nospame nospame Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good callout. Added to the 2-arg version here, but also kept in the 3-arg version because that is called directly with submit-all. d6942fb

FormplayerRemoteInstanceFetcher formplayerRemoteInstanceFetcher = new FormplayerRemoteInstanceFetcher(
runnerService.getCaseSearchHelper(),
virtualDataInstanceService);
Expand All @@ -60,4 +69,24 @@ public FormSession getFormSession(SerializableFormSession serializableFormSessio
windowWidth
);
}

/**
* A public web apps session may only operate on the form session that its own one-time link
* created. The session stores the scrubbed authoritative username, so any session bound to a
* different user/domain is rejected. No-op for non-public sessions and non-request contexts.
*
* Package-private for testing.
*/
void verifyPublicSessionOwnership(SerializableFormSession session) {
Optional<HqUserDetailsBean> userDetails = RequestUtils.getUserDetails();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If HQ mints a shared public username per app/domain (which the bean's own isAuthorized comment implies — "no real HQ account, so the per-session username is not a meaningful check"), holder A can read/answer/submit holder B's session given its id, and all concurrent public sessions serialize on one @UserLock key. Worth confirming HQ issues a per-link username, or binding to the link's session key instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HQ doesn't mint a shared public username per app or domain, public session usernames are guaranteed unique, based on the model's own unique id field. https://github.com/dimagi/commcare-hq/blob/b766337a10696df02f2a3e89a6badc255099c404/corehq/apps/public_webforms/models.py#L147

if (userDetails.isEmpty() || !userDetails.get().isPublicSession()) {
return;
}
HqUserDetailsBean details = userDetails.get();
boolean owned = Objects.equals(session.getDomain(), details.getDomain())
&& Objects.equals(session.getUsername(), TableBuilder.scrubName(details.getUsername()));
if (!owned) {
throw new FormNotFoundException(session.getId());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ private static <T extends LocationRelevantResponseBean> T setLocationNeeds(T res
@UserRestore
@AppInstall
public BaseResponseBean navigateToEndpoint(@RequestBody SessionNavigationBean sessionNavigationBean,
@CookieValue(Constants.POSTGRES_DJANGO_SESSION_ID) String authToken,
@CookieValue(value = Constants.POSTGRES_DJANGO_SESSION_ID, required = false) String authToken,
HttpServletRequest request) throws Exception {
// Apps using aggressive syncs are likely to hit a sync whenever using endpoint-based navigation,
// since they use it to jump between different sandboxes. Turn it off.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import org.commcare.formplayer.aspects.LockAspect;
import org.commcare.formplayer.aspects.LoggingAspect;
import org.commcare.formplayer.aspects.MetricsAspect;
import org.commcare.formplayer.aspects.PublicSessionLockAspect;
import org.commcare.formplayer.aspects.SetBrowserValuesAspect;
import org.commcare.formplayer.aspects.TagTracingDisabledAspect;
import org.commcare.formplayer.aspects.UserRestoreAspect;
Expand Down Expand Up @@ -198,6 +199,11 @@ public AppInstallAspect appInstallAspect() {
return new AppInstallAspect();
}

@Bean
public PublicSessionLockAspect publicSessionLockAspect() {
return new PublicSessionLockAspect();
}

@Bean
public ConfigureStorageFromSessionAspect configureStorageAspect() {
return new ConfigureStorageFromSessionAspect();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package org.commcare.formplayer.aspects;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.commcare.formplayer.beans.AuthenticatedRequestBean;
import org.commcare.formplayer.beans.InstallRequestBean;
import org.commcare.formplayer.beans.SessionNavigationBean;
import org.commcare.formplayer.beans.auth.HqUserDetailsBean;
import org.commcare.formplayer.util.RequestUtils;
import org.springframework.core.annotation.Order;
import org.springframework.util.StringUtils;

import java.util.Objects;
import java.util.Optional;

import lombok.extern.java.Log;

/**
* Locks a public web apps session to the identity, app, and session endpoint that HQ bound its
* one-time link to, replacing the client-supplied values in the request with the
* HMAC-authenticated ones from session_details. Identity is pinned on every public request; the
* app and endpoint are pinned wherever an app is installed.
*
* Ordered ahead of every other formplayer aspect so that {@link LockAspect} derives its lock key,
* and {@link AppInstallAspect} keys the sandbox DB, from the authoritative values.
*/
@Aspect
@Order(0)
@Log
public class PublicSessionLockAspect {

@Before(value = "@annotation(org.commcare.formplayer.annotations.UserRestore)")
public void pinPublicSessionIdentity(JoinPoint joinPoint) {
Optional<HqUserDetailsBean> userDetails = publicSessionDetails();
if (userDetails.isEmpty()) {
return;
}
HqUserDetailsBean details = userDetails.get();
Object[] args = joinPoint.getArgs();
if (args.length == 0 || !(args[0] instanceof AuthenticatedRequestBean requestBean)) {
throw new IllegalStateException(
"Public web apps session reached a handler whose request identity cannot be "
+ "pinned to the authenticated user");
}
requestBean.setUsername(details.getUsername());
requestBean.setDomain(details.getDomain());
requestBean.setRestoreAs(null);
requestBean.setRestoreAsCaseId(null);
}

@Before(value = "@annotation(org.commcare.formplayer.annotations.AppInstall)")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Identity pinning covers only @AppInstall. The other 12 allowlisted public routes (answer, next_index, submit-all, …) aren't @AppInstall, so username/restoreAs stay client-supplied and feed LockAspect.getLockKeyForAuthenticatedBean. A link holder can send /answer with username: "realuser@domain" and repeatedly hold that user's FormplayerLockRegistry lock, resulting in LockError/423 for the real user. Pin username/domain/restoreAs for every public request, not just installs.

@nospame nospame Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, good point. Addressed here (along with setting preview: false as a related property) eca1e14

public void lockToPublicApp(JoinPoint joinPoint) {
Optional<HqUserDetailsBean> userDetails = publicSessionDetails();
if (userDetails.isEmpty()) {
return;
}
HqUserDetailsBean details = userDetails.get();
Object[] args = joinPoint.getArgs();
if (args.length == 0 || !(args[0] instanceof InstallRequestBean requestBean)) {
// Fail closed: this only runs for a public session (non-public returned above), so an
// @AppInstall handler whose request we cannot lock must be rejected.
throw new IllegalStateException(
"Public web apps session reached an @AppInstall handler whose request cannot be "
+ "locked to the authoritative app/endpoint");
}

// Fail closed: a public session must carry HQ's authoritative app id. A missing value means
// a misconfigured or out-of-date HQ; never fall back to the client-supplied app id.
if (!StringUtils.hasText(details.getPublicAppId())) {
throw new IllegalStateException(
"Public web apps session is missing an authoritative app id from HQ");
}
if (!Objects.equals(requestBean.getAppId(), details.getPublicAppId())) {
log.warning("Public session request app id did not match the authoritative value; "
+ "using the authoritative app id");
}
requestBean.setAppId(details.getPublicAppId());
requestBean.setPreview(false);

if (requestBean instanceof SessionNavigationBean navigationBean) {
if (!StringUtils.hasText(details.getPublicEndpointId())) {
throw new IllegalStateException(
"Public web apps session is missing an authoritative endpoint id from HQ");
}
boolean clientDiffers = !Objects.equals(navigationBean.getEndpointId(),
details.getPublicEndpointId())
|| (navigationBean.getEndpointArgs() != null
&& !navigationBean.getEndpointArgs().isEmpty());
if (clientDiffers) {
log.warning("Public session request endpoint/args did not match the authoritative "
+ "endpoint; using the authoritative endpoint with no args");
}
navigationBean.setEndpointId(details.getPublicEndpointId());
// Public sessions have an empty restore, so endpoint args (e.g. case ids) cannot
// resolve; the designated public endpoint must take no required arguments.
navigationBean.setEndpointArgs(null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forcing setEndpointArgs(null) skips the endpointArgs != null block in MenuSessionRunnerService.java:780-808, which is where Endpoint.populateEndpointArgumentsToEvaluationContext validates missing/unexpected args. A public link bound to an endpoint with a required argument fails with an opaque XPath/500 instead of "Missing arguments: …". Consider validating the endpoint takes no required args and failing closed.

@nospame nospame Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right this would have thrown an unhelpful XPath error - that is handled by the GlobalDefaultExceptionHandler and shouldn't 500, but it's not really what we want. Added validation here: 230343c

}
}

private Optional<HqUserDetailsBean> publicSessionDetails() {
return RequestUtils.getUserDetails().filter(HqUserDetailsBean::isPublicSession);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,15 @@ private void configureSentryScope(RestoreFactory restoreFactory) {
});
}

private void configureRestoreFactory(AuthenticatedRequestBean requestBean, HqAuth auth) throws Exception {
// Package-private for testing.
void configureRestoreFactory(AuthenticatedRequestBean requestBean, HqAuth auth) throws Exception {
Optional<HqUserDetailsBean> userDetails = RequestUtils.getUserDetails();
if (userDetails.isPresent() && userDetails.get().isPublicSession()) {
// A public web apps session restores only as its own user; never use client-supplied values
HqUserDetailsBean details = userDetails.get();
restoreFactory.configure(details.getUsername(), details.getDomain(), null, auth);
return;
}
if (requestBean.getRestoreAsCaseId() != null) {
// SMS user filling out a form as a case
restoreFactory.configure(requestBean.getDomain(), requestBean.getRestoreAsCaseId(), auth);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ public class HqUserDetailsBean implements UserDetails {
@JsonProperty("public")
private boolean publicSession;

// For a public web apps session, HQ returns the authoritative app and session endpoint the
// link is bound to. Formplayer uses these instead of the client-supplied values so a public
// session cannot navigate to any other app/form. Null for non-public sessions.
@JsonProperty("app_build_id")
private String publicAppId;

@JsonProperty("endpoint_id")
private String publicEndpointId;

public HqUserDetailsBean() {
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import org.commcare.formplayer.auth.CommCareSessionAuthFilter;
import org.commcare.formplayer.auth.HmacAuthFilter;
import org.commcare.formplayer.beans.auth.HqUserDetailsBean;
import org.commcare.formplayer.services.FormSessionService;
import org.commcare.formplayer.services.HqUserDetailsService;
import org.commcare.formplayer.util.Constants;
Expand All @@ -18,6 +19,7 @@
import org.springframework.security.authorization.AuthenticatedAuthorizationManager;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
Expand All @@ -30,7 +32,11 @@
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.OrRequestMatcher;
import org.springframework.security.web.util.matcher.RequestHeaderRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;

import java.util.Arrays;

@Configuration
public class WebSecurityConfig {
Expand All @@ -46,6 +52,30 @@ public class WebSecurityConfig {
@Value("${formplayer.allowDoubleSlash:true}")
private boolean allowDoubleSlash;

// The only routes a public web apps session may reach: get_endpoint to enter its locked form,
// plus every in-form action (endpoints that operate on an already-established form session).
// Every other route is denied (default-deny).
private static final String[] PUBLIC_SESSION_ALLOWED_URLS = {
Constants.URL_GET_ENDPOINT,
Constants.URL_ANSWER_QUESTION,
Constants.URL_ANSWER_MEDIA_QUESTION,
Constants.URL_CLEAR_ANSWER,
Constants.URL_NEW_REPEAT,
Constants.URL_DELETE_REPEAT,
Constants.URL_NEXT_INDEX,
Constants.URL_NEXT,
Constants.URL_PREV_INDEX,
Constants.URL_CURRENT,
Constants.URL_CHANGE_LANGUAGE,
Constants.URL_GET_INSTANCE,
Constants.URL_SUBMIT_FORM,
};

private final RequestMatcher allowedForPublicSession = new OrRequestMatcher(
Arrays.stream(PUBLIC_SESSION_ALLOWED_URLS)
.map(url -> new AntPathRequestMatcher("/" + url))
.toArray(RequestMatcher[]::new));

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
disableDefaults(http);
Expand All @@ -59,12 +89,15 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.requestMatchers(new AntPathRequestMatcher("/serverup")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/favicon.ico")).permitAll()

// validate form auth
// validate form auth. Any route-specific rule added before anyRequest must be
// wrapped in restrictPublicSession, or a public session bypasses the allowlist here.
.requestMatchers(new AntPathRequestMatcher("/validate_form")).access(
getFormValidationAuthManager())
restrictPublicSession(getFormValidationAuthManager()))

// full auth required for all other requests
.anyRequest().authenticated()
// full auth required for all other requests; a public web apps session is
// further restricted to its allowlisted routes (default-deny)
.anyRequest().access(
restrictPublicSession(AuthenticatedAuthorizationManager.authenticated()))
);

// Configure the authentication manager with a provider that will use the
Expand Down Expand Up @@ -115,6 +148,31 @@ private AuthorizationManager<RequestAuthorizationContext> getFormValidationAuthM
};
}

/**
* Wraps an {@link AuthorizationManager} with the public web apps session route allowlist: a
* public session may reach only urls in {@code PUBLIC_SESSION_ALLOWED_URLS}; on every other
* route it is denied (default-deny), regardless of what {@code inner} decides.
* Non-public sessions get exactly {@code inner}'s decision.
*/
private AuthorizationManager<RequestAuthorizationContext> restrictPublicSession(
AuthorizationManager<RequestAuthorizationContext> inner) {
return (authentication, context) -> {
AuthorizationDecision authDecision = inner.check(authentication, context);
if (authDecision != null && authDecision.isGranted()
&& isPublicSession(authentication.get())
&& !allowedForPublicSession.matches(context.getRequest())) {
return new AuthorizationDecision(false);
}
return authDecision;
};
}

private boolean isPublicSession(Authentication authentication) {
return authentication != null
&& authentication.getPrincipal() instanceof HqUserDetailsBean bean
&& bean.isPublicSession();
}

private HmacAuthFilter getHmacAuthFilter() {
return HmacAuthFilter.builder()
.hmacKey(formplayerAuthKey)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -770,7 +770,10 @@ private NotificationMessage establishVolatility(FormSession session) {
public BaseResponseBean advanceSessionWithEndpoint(MenuSession menuSession, String endpointId,
@Nullable HashMap<String, String> endpointArgs)
throws Exception {
if (!FeatureFlagChecker.isToggleEnabled(TOGGLE_SESSION_ENDPOINTS)) {
// A public web apps session is a deep-link into a form itself; we use the underlying
// functionality of SESSION_ENDPOINTS without its toggle.
if (!FeatureFlagChecker.isToggleEnabled(TOGGLE_SESSION_ENDPOINTS)
&& !RequestUtils.isPublicSession()) {
throw new RuntimeException("Linking into applications has been disabled for this project.");
}

Expand All @@ -780,6 +783,11 @@ public BaseResponseBean advanceSessionWithEndpoint(MenuSession menuSession, Stri
"This link does not exist. Your app may have changed so that the given link is no longer "
+ "valid");
}
// Endpoint args are stripped from a public session, so fail here rather than downstream
if (RequestUtils.isPublicSession() && !endpoint.getArguments().isEmpty()) {
throw new ApplicationConfigException(
"This link requires additional information and cannot be opened as a public link.");
}
SessionWrapper sessionWrapper = menuSession.getSessionWrapper();
EvaluationContext evalContext = sessionWrapper.getEvaluationContext();
try {
Expand Down
Loading
Loading