-
Notifications
You must be signed in to change notification settings - Fork 10
Public Webforms: Deep-link handling and security #1792
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3082d29
c06e90f
5d950a5
00767bf
c3c28b2
6df8cbd
1e6163d
ca819c9
d6942fb
eca1e14
9ca886d
230343c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
|
|
@@ -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 { | ||
|
|
||
|
|
@@ -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); | ||
| FormplayerRemoteInstanceFetcher formplayerRemoteInstanceFetcher = new FormplayerRemoteInstanceFetcher( | ||
| runnerService.getCaseSearchHelper(), | ||
| virtualDataInstanceService); | ||
|
|
@@ -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(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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 |
|---|---|---|
| @@ -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)") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Identity pinning covers only
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Forcing
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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 callsgetCommCareSession()before calling this line.getCommCareSession()loads the target menu session and runsinstallService.configureApplication(...)against a DB. A public session POSTing/answerwith someone else'ssessionIdopens the victim's app DB, and if re-init throws,InstallServicecallssqliteDB.deleteDatabaseFile()on it, beforeFormNotFoundException.Move this check to the 2-arg entry point above (line 50) before
commCareSessionis set, or into an aspect.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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