Skip to content

Public Webforms: Deep-link handling and security - #1792

Open
nospame wants to merge 12 commits into
ejp/public-webforms-authfrom
ejp/public-webforms-confinement
Open

Public Webforms: Deep-link handling and security#1792
nospame wants to merge 12 commits into
ejp/public-webforms-authfrom
ejp/public-webforms-confinement

Conversation

@nospame

@nospame nospame commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

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

  • Second of three public webforms PRs in this repo; builds on Public Webforms: Authentication #1791. Adds the confinement layer:
    • WebSecurityConfig: restrictPublicSession(...) default-deny allowlist on /validate_form and
      anyRequest. A public session may reach only get_endpoint + the in-form actions; new-form
      and validate_form are denied.
    • get_endpoint/new-repeat: sessionid cookie binding relaxed to required=false (public
      sessions carry no Django cookie).
    • HqUserDetailsBean: HQ-authoritative app_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 its
      HQ-authenticated user (no client username / restore-as / case id).
    • MenuSessionRunnerService: public sessions use endpoint navigation without the
      SESSION_ENDPOINTS toggle - rather than building out a new method of navigation or
      requiring 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: restrictPublicSession only ever removes access, and
only 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_form denied, regular
    sessions unaffected.
  • PublicSessionLockAspectTest — app/endpoint/identity override + fail-closed cases.
  • PublicSessionLockAspectWeavingTest — proves the aspect actually weaves and is correctly ordered.
  • EndpointLaunchTest — public launch without the SESSION_ENDPOINTS toggle.
  • HqUserDetailsTests + test harness (@WithHqUser public flag).

QA Plan

Public Webforms will get end-to-end QA before its release.

Special deploy instructions

  • Cross-repo dependency: relies on HQ's session_details returning app_build_id + endpoint_id
    for 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

  • This PR can be reverted after deploy with no further considerations.

Review

  • The set of people pinged as reviewers is appropriate for the level of risk of the change.

@nospame nospame mentioned this pull request Jul 28, 2026
2 tasks
@nospame
nospame changed the base branch from ejp/public-webforms-auth to ejp/public-webforms-base July 28, 2026 17:52
@nospame
nospame force-pushed the ejp/public-webforms-confinement branch from 3207aa0 to eecb372 Compare July 28, 2026 17:55
@nospame
nospame marked this pull request as ready for review August 11, 2026 16:24
@nospame
nospame marked this pull request as draft August 11, 2026 16:25
@nospame
nospame changed the base branch from ejp/public-webforms-base to ejp/public-webforms-auth August 11, 2026 16:25
@nospame
nospame marked this pull request as ready for review August 11, 2026 16:26
nospame and others added 7 commits August 11, 2026 09:34
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>
@nospame
nospame force-pushed the ejp/public-webforms-confinement branch from eecb372 to 7251404 Compare August 11, 2026 16:37
@nospame
nospame requested review from dannyroberts and removed request for shubham1g5 August 11, 2026 16:41
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>
@nospame
nospame force-pushed the ejp/public-webforms-confinement branch from 7251404 to ca819c9 Compare August 11, 2026 17:58
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.56098% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.62%. Comparing base (0388456) to head (230343c).
⚠️ Report is 2 commits behind head on ejp/public-webforms-auth.

Files with missing lines Patch % Lines
...re/formplayer/configuration/WebSecurityConfig.java 88.88% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@                      Coverage Diff                       @@
##             ejp/public-webforms-auth    #1792      +/-   ##
==============================================================
+ Coverage                       70.28%   70.62%   +0.34%     
- Complexity                       2046     2095      +49     
==============================================================
  Files                             259      260       +1     
  Lines                            8039     8119      +80     
  Branches                          767      794      +27     
==============================================================
+ Hits                             5650     5734      +84     
+ Misses                           2104     2094      -10     
- Partials                          285      291       +6     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@kaapstorm kaapstorm left a comment

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.

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);

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

@Log
public class PublicSessionLockAspect {

@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

}
requestBean.setAppId(details.getPublicAppId());
requestBean.setUsername(details.getUsername());
requestBean.setDomain(details.getDomain());

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.

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();

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

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

nospame and others added 4 commits September 9, 2026 15:07
Check ownership in the 2-arg getFormSession, ahead of the getCommCareSession
call that runs configureApplication. Previously that check ran only afterward,
so a public session sending another user's sessionId could have reached
configureApplication and deleted that user's app DB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pin username/domain/restoreAs/restoreAsCaseId from a @UserRestore advice, which
covers every allowlisted public route, and force preview off where an app is
installed. Previously only @appinstall routes were pinned, so a public session
could send /answer with another user's username and hold that user's lock.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A public session has its endpoint args cleared, so an endpoint that declares
any can never be satisfied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@kaapstorm kaapstorm left a comment

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.

Looks good to me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants