Skip to content

Public Webforms: Sandbox cleanup - #1793

Draft
nospame wants to merge 12 commits into
ejp/public-webforms-basefrom
ejp/public-webforms-cleanup
Draft

Public Webforms: Sandbox cleanup#1793
nospame wants to merge 12 commits into
ejp/public-webforms-basefrom
ejp/public-webforms-cleanup

Conversation

@nospame

@nospame nospame commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Technical Summary

  • Last of three PRs in this repo for Public Webforms. A public session restores under a unique
    synthetic user, creating a persistent on-disk sandbox that is never reused once the one-time link
    is consumed. This PR deletes that sandbox on successful submit so they don't accumulate.
    • FormSubmissionHelper: on a confirmed submit (where the form session is deleted — the point HQ
      fires consume_public_form_session), a public session marks its request-scoped RestoreFactory
      for cleanup.
    • UserRestoreAspect#closeRestoreFactory: deletes the sandbox folder after the connection is
      closed, best-effort. Removing the one user folder reclaims both the user DB and the nested app DB.
  • Runs only on a genuine submit (in-form actions leave an in-progress form intact) and also covers
    the auto-submit-on-get_endpoint path. Identity is the HQ-pinned restore user, never
    client-supplied — which is why this is server-side rather than exposing clear_user_data.

Code and this description written or co-written by AI and edited by human.

Safety Assurance

Safety story

Scope is tightly bounded: cleanup only fires for a public session, only on a successful submit, and
deletes only that session's own single-use sandbox (identity taken from the pinned RestoreFactory,
never the request body). Deletion happens after the connection closes and is wrapped best-effort, so
a cleanup failure logs and never fails the submit. No regular-user or shared data is touched.

Automated test coverage

  • UserRestoreAspectTest — marked → sandbox deleted after close; not marked → left intact; delete
    failure is swallowed. (Verified locally: passing.)

QA Plan

This particular change will not get QA.

Special deploy instructions

  • This PR can be deployed after merge with no further considerations.

Rollback instructions

  • This PR can be reverted after deploy with no further considerations. (Reverting just stops the eager cleanup; abandoned/older sandboxes are already handled by the out-of-band old-user-DB purge.)

Review

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

nospame and others added 12 commits July 20, 2026 16:18
When a request carries the `CommCare-Public-Session: true` header and a
`public_form_session_key` cookie, the session auth filter now produces a
typed PublicSessionCredential instead of the Django sessionid string, and
HqUserDetailsService sends it to HQ's session_details endpoint as
`publicSessionKey` rather than `sessionId`.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
HQ's session_details response marks a public web apps session (one-time
link) with a JSON `public` field. Add a boolean publicSession field mapped
via @JsonProperty("public"). Uses a primitive boolean so a missing field
defaults to false, which matters because the bean is
@JsonIgnoreProperties(ignoreUnknown = true) and would otherwise silently
drop it.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
When HqUserDetailsBean.publicSession is true, isAuthorized() no longer
requires the request's username to equal the bean's username. Public web
apps sessions authenticate via a single-use key that HQ validates
server-to-server, and their username is a synthetic per-session string
(not a real account), so echoing it is not a meaningful membership control.

The requested domain is still required to be the session's domain
(domains.contains(domain)). This keeps a session key from being
replayed against a different domain.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
For a public web apps session, formplayer's outbound calls to HQ must send the
`public_form_session_key` cookie together with the `CommCare-Public-Session:
true` header, and must NOT send the Django `sessionid`.

- New `PublicFormSessionAuth` (an `HqAuth`) emits exactly that cookie+header
  pair and nothing else; its key is guarded and never logged.
- `UserRestoreAspect.getHqAuth` now selects the credential for the request:
  if the authenticated user is a public session it returns a
  `PublicFormSessionAuth` built from the session key, otherwise the existing
  `DjangoAuth`/null.

Gated on the HMAC-authenticated `public` field (`isPublicSession()`), never
on the client-supplied header; the public credential is preferred when both
signals are present.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
@nospame nospame changed the title Delete a public web apps session's sandbox on successful submit Public Webforms: Sandbox cleanup Jul 27, 2026
@nospame
nospame changed the base branch from ejp/public-webforms-confinement to ejp/public-webforms-base July 28, 2026 17:52
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.

1 participant