Skip to content

fix(session): inject __enketo_meta_* metadata server-side so cookies can be HttpOnly - #4

Draft
FrankApiyo wants to merge 3 commits into
ona-buildfrom
fix/enketo-meta-httponly-ona
Draft

fix(session): inject __enketo_meta_* metadata server-side so cookies can be HttpOnly#4
FrankApiyo wants to merge 3 commits into
ona-buildfrom
fix/enketo-meta-httponly-ona

Conversation

@FrankApiyo

@FrankApiyo FrankApiyo commented Jul 9, 2026

Copy link
Copy Markdown
Member

🗒️ Checklist

  • draft PR with a title <type>(<scope>)<!>: <title> DEV-1234
  • assign yourself
  • fill in the template below and delete template comments
  • update all related docs (README, inline, etc.), if any
  • I have verified this PR works by manually testing (see CONTRIBUTING.md):
    • Online form submission
    • Offline form submission
    • Saving offline drafts
    • Loading offline drafts
    • Editing submissions
    • Form preview
    • None of the above
  • review thyself: read the diff and repro the preview as written
  • undraft PR & confirm that CI passes
  • request reviewers & improve according to review
  • delete this section before merging

📣 Summary

Form-session metadata (username, device id, etc.) is now read on the server, so the cookies that carry it no longer need to be readable by page JavaScript — hardening them against script-injection attacks.

👷 Description for integrators

__enketo_meta_* session cookies had to be readable by client-side JavaScript — enketo-core reads them from document.cookie to populate /session/context submission metadata — which forced every issuer to set them without HttpOnly, exposing them to XSS exfiltration. The server now reads them from req.signedCookies and injects them into the page; enketo-core's existing fallback chain is unchanged, so any value the server doesn't supply still falls back to the client-side read.

Bonus: req.signedCookies verifies the cookie signature; the old client-side read stripped it and trusted the value unverified.

💭 Notes

  • The hardening is scoped to online deployments: when offline enabled is true, meta cookies stay JS-readable and the service-worker-cached /x/ page keeps the legacy client-side cookie read — a session injected at render time would go stale in the cached page (wrong user after a user switch). Issuing services should mark their cookies HttpOnly only for deployments that do not serve offline forms.
  • Issuing services must keep signing __enketo_meta_* cookies with enketo's shared encryption key (they already do). Once this is deployed they can mark these cookies HttpOnly (subject to the offline caveat above), and any interim workarounds that kept them JavaScript-readable can be reverted.
  • What HttpOnly protects here is the signed cookie itself (no exfiltration of a replayable s:value.sig credential); the metadata values remain readable by page JavaScript via window.__enketoSession since the form engine needs them.
  • Server tests cover the injection, absent-property fallback, tampered-signature rejection, </script> breakout escaping, both offline modes for the cookie flags, and the login flow's username cookie. CI runs the full server suite.

👀 Preview steps

  1. ℹ️ have a data server that issues a signed __enketo_meta_username cookie marked HttpOnly
  2. open a form through enketo-express and submit it
  3. 🔴 [on main] the submission's /session/context metadata has username as prop not found — the client-side cookie read can't see an HttpOnly cookie
  4. 🟢 [on PR] the submission metadata carries the username — the server read it from the signed cookie and injected it into the page

…can be HttpOnly

Read __enketo_meta_* values from signed cookies on the server and inject
them into the webform page (window.__enketoSession) instead of relying on
enketo-core reading document.cookie client-side. This removes the
requirement that these cookies be JS-readable, so they can be set HttpOnly
by enketo-express and by upstream data servers (onadata/zebra/zonkey).

- survey-controller: build session from req.signedCookies (only present
  props, so missing values fall through to the existing readCookie
  fallback); set __enketo_meta_deviceid HttpOnly
- authentication-controller: set __enketo_meta_username HttpOnly
- webform.pug: serialize session to window.__enketoSession with
  </script>-breakout escaping
- controller-webform: thread session into all Form (re)inits

enketo-core is unchanged: createSession already prefers the injected
sessObj over readCookie.
@FrankApiyo FrankApiyo self-assigned this Jul 15, 2026
The offline (/x/) page is cached by the service worker, so a session
injected at render time can carry stale identity, and its client-side
cookie read needs JS-readable cookies. Meta cookies are therefore
HttpOnly only when offline is disabled, and session injection skips
the offline view.

Also extends the server specs: tampered-signature rejection,
</script> escaping, offline no-bake, HttpOnly in both offline modes,
setToken coverage via the CSRF login flow, chai assertions, and
offline-enabled state restored after the suite.
@FrankApiyo
FrankApiyo marked this pull request as draft August 4, 2026 07:28
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