Public Webforms: Deep-link handling and security - #1792
Conversation
3207aa0 to
eecb372
Compare
A public web apps session authenticates with the public_form_session_key cookie, not the Django sessionid, so the required sessionid @CookieValue on MenuController.navigateToEndpoint (get_endpoint) rejected it at request binding (400) before the handler. Relax that cookie to required=false, as answer/submit-all already do. A cookie-less non-public request is still rejected upstream by the session auth request matcher. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a default-deny route allowlist for public web apps sessions, enforced in the Spring Security authorization layer. getPublicSessionAuthManager grants a request if it is authenticated AND either the principal is not a public session or the path is one of specified form-navigation routes. Every other route is denied with the app's standard 403 before any controller or aspect runs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
For a public web apps session, HQ's session_details response carries the app id and session endpoint that the one-time link is bound to. Add publicAppId (@JsonProperty "commcare_app_id") and publicEndpointId (@JsonProperty "endpoint_id") to the bean. Reference types, so they default to null for non-public responses that omit them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both app_id and endpoint_id in a get_endpoint request are client-supplied, so a valid public session key could otherwise open any app/form in the domain. PublicSessionLockAspect, for a public session, replaces the request's app_id and endpoint_id with the HMAC-authenticated values HQ returned (HqUserDetailsBean.publicAppId/publicEndpointId) and clears endpoint args, confining the session to its one designated form. It fails closed if HQ did not supply those authoritative values rather than trusting the client. Runs before AppInstallAspect (which keys the sandbox DB off the request's app_id), so storage, the MenuSession build, and endpoint navigation all use the authoritative app. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The lock aspect silently returned if the first handler arg was not an InstallRequestBean, which for a public session would let the request proceed without pinning it to the authoritative app/endpoint. Unreachable today (every allowlisted @appinstall route takes a SessionNavigationBean), but preserves the fail-closed contract for any future allowlisted route with a different request bean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A public web apps session is a one-time deep link into a single endpoint, so endpoint navigation is intrinsic to it. For public sessions, bypass thegate via a new RequestUtils.isPublicSession() predicate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A public session's username is never validated (isAuthorized skips it), and configureRestoreFactory took username/restoreAs/restoreAsCaseId straight from the client request bean. Instead, configure the restore for a public session from the HMAC-authenticated principal only, ignoring all client-supplied identity fields. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
eecb372 to
7251404
Compare
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>
7251404 to
ca819c9
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## ejp/public-webforms-auth #1792 +/- ##
==============================================================
+ Coverage 70.28% 70.45% +0.17%
- Complexity 2046 2079 +33
==============================================================
Files 259 260 +1
Lines 8039 8103 +64
Branches 767 790 +23
==============================================================
+ Hits 5650 5709 +59
+ Misses 2104 2099 -5
- Partials 285 295 +10 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
kaapstorm
left a comment
There was a problem hiding this comment.
I'm not familiar with Formplayer, but I am passing on findings from Claude.
| @NotNull | ||
| public FormSession getFormSession(SerializableFormSession serializableFormSession, | ||
| @Nullable CommCareSession commCareSession, @Nullable String windowWidth) throws Exception { | ||
| verifyPublicSessionOwnership(serializableFormSession); |
There was a problem hiding this comment.
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.
| @Log | ||
| public class PublicSessionLockAspect { | ||
|
|
||
| @Before(value = "@annotation(org.commcare.formplayer.annotations.AppInstall)") |
There was a problem hiding this comment.
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.
| } | ||
| requestBean.setAppId(details.getPublicAppId()); | ||
| requestBean.setUsername(details.getUsername()); | ||
| requestBean.setDomain(details.getDomain()); |
There was a problem hiding this comment.
Add requestBean.setPreview(false).
With default preview: true, initFromArchive skips the jr://archive/<appId>/ cache and re-downloads the CCZ from HQ every request, and MenuSession skips performTimedSync(). A loop of get_endpoint with preview:true is CCZ-download amplification against HQ and enters the form with an unsynced sandbox.
| * Package-private for testing. | ||
| */ | ||
| void verifyPublicSessionOwnership(SerializableFormSession session) { | ||
| Optional<HqUserDetailsBean> userDetails = RequestUtils.getUserDetails(); |
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
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.
Product Description
Public Web Apps Sessions let someone open a one-time link and fill a single, pre-designated form
without an HQ account. This PR is where that behavior is confined: a public session can reach only
the form-entry routes it needs, and is locked to the exact app, endpoint, and identity HQ bound the
link to — it cannot navigate to any other app or form.
Technical Summary
SAAS-19928, SAAS-19929, SAAS-19930
WebSecurityConfig:restrictPublicSession(...)default-deny allowlist on/validate_formandanyRequest. A public session may reach onlyget_endpoint+ the in-form actions;new-formand
validate_formare denied.get_endpoint/new-repeat:sessionidcookie binding relaxed torequired=false(publicsessions carry no Django cookie).
HqUserDetailsBean: HQ-authoritativeapp_build_id+endpoint_id.PublicSessionLockAspect(ordered ahead of all other aspects): overrides client-supplied app,endpoint, endpoint args, and identity with HQ's values; fails closed if they're missing.
UserRestoreAspect#configureRestoreFactory: a public session restores strictly as itsHQ-authenticated user (no client username / restore-as / case id).
MenuSessionRunnerService: public sessions use endpoint navigation without theSESSION_ENDPOINTStoggle - rather than building out a new method of navigation orrequiring the toggle to be enabled in HQ.
Code and PR description written or co-written by AI and edited by human. Review by commit.
Safety Assurance
Safety story
The core safety property is default-deny:
restrictPublicSessiononly ever removes access, andonly when the principal is a public session — regular sessions get exactly their prior decision. The
lock aspect and restore pinning both fail closed on missing authoritative values, and a public
session restores under its own synthetic user, so no real user's sandbox or identity is touched.
Aspect ordering (a subtle correctness requirement) is guarded by a dedicated weaving test after a
real ordering bug was caught there. Still inert in production until HQ issues links.
Automated test coverage
SessionAuthTests— allowlist: allowed routes not 403,new-form/validate_formdenied, regularsessions unaffected.
PublicSessionLockAspectTest— app/endpoint/identity override + fail-closed cases.PublicSessionLockAspectWeavingTest— proves the aspect actually weaves and is correctly ordered.EndpointLaunchTest— public launch without theSESSION_ENDPOINTStoggle.HqUserDetailsTests+ test harness (@WithHqUserpublic flag).QA Plan
Public Webforms will get end-to-end QA before its release.
Special deploy instructions
session_detailsreturningapp_build_id+endpoint_idfor public sessions. Safe to deploy formplayer first (feature stays dark until HQ issues links), but
the feature must not be enabled on HQ until this is deployed.
Rollback instructions
Review