Skip to content

fix(forms): escape stored HTML, gate submit on is_public, enforce RBAC on legacy forms routes - #1057

Open
mmcintosh wants to merge 3 commits into
mainfrom
fix/legacy-forms-security
Open

fix(forms): escape stored HTML, gate submit on is_public, enforce RBAC on legacy forms routes#1057
mmcintosh wants to merge 3 commits into
mainfrom
fix/legacy-forms-security

Conversation

@mmcintosh

Copy link
Copy Markdown
Collaborator

Description

The legacy forms/form_submissions tables started shipping via migration 0004_forms.sql (added in #1022, 2026-08-11), which reactivates admin-forms.ts/public-forms.ts — routes that previously 404'd/503'd harmlessly on missing tables. Several pre-existing gaps in those routes are now reachable in production instead of moot. This PR closes them.

Changes

  • Stored/reflected XSS: display_name, description, category, name, and the ?search= query param were rendered raw into HTML across the admin list/builder pages and the public form page — added escapeHtml() (existing utils/sanitize.ts helper) at every site.
  • Script-tag breakout: formio_schema/settings are embedded via JSON.stringify(...) directly inside inline <script> tags; a </script> in any schema field value (e.g. a component label, which a form-builder admin controls) broke out of the tag. Added a jsonForScript() helper (<-escapes <) used at both embed sites.
  • Missing RBAC: admin-forms.ts was gated by requireAuth() only — any authenticated user, any role, had full form CRUD and submission-data read access. The sidebar nav's 'forms:manage' permission gate is UI-only (requirePermission is a no-op stub); added requireRole(['admin', 'editor']) as the actual server-side enforcement.
  • is_public bypass on submit: POST /:identifier/submit and GET /:identifier/turnstile-config checked is_active but not is_public, unlike the render/schema GET routes — a form marked private was still directly reachable via a crafted request. Both now match.
  • Anonymous stored XSS via submission-data keys (found in review, worse than the rest — zero privileges required to plant): the admin submissions viewer dumps sub.submission_data via JSON.stringify into a <pre> with no escaping. The pre-existing sanitizeDeep() sanitizer only ever recursed into object values, never keys — and an anonymous public submitter controls both. Fixed at both layers: escapeHtml() at the render site, and key sanitization in sanitizeDeep() at the root cause.

Testing

Two independent fresh-context reviews (security/reachability/blast-radius) were run against this patch before opening it — both are reflected in the fixes above, not just the diff.

Unit Tests

  • Added/updated unit tests — real-SQLite integration coverage for the is_public/RBAC/escaping fixes (the pre-existing mock-DB suite matches on sql.includes(...) and can't catch a dropped WHERE condition), plus a submissions-page XSS regression and a sanitizeDeep key-escaping regression. 44 forms-scoped tests (up from 5).
  • All unit tests passing — full suite 1742/0, tsc --noEmit clean.

E2E Tests

  • Added/updated E2E tests — spec 104, display_name XSS on the admin builder + public form page (the reachable UI path; see the spec file header for why the is_public gate isn't included there).
  • All E2E tests passing — not run locally per project policy; CI validates on this PR.

Screenshots/Videos

N/A — no visual/UI changes, only escaping and access-control.

Checklist

  • Code follows project conventions
  • Tests added/updated and passing
  • Type checking passes
  • No console errors or warnings
  • Documentation updated (if needed) — N/A

…C on legacy forms routes

The legacy forms/form_submissions tables (0004_forms.sql) went live via #1022,
reactivating admin-forms.ts and public-forms.ts routes that previously 503'd
harmlessly on missing tables. Their existing gaps are now reachable:

- No escapeHtml on admin-authored display_name/description/category/name
  rendered into admin and public HTML (R8) — stored XSS.
- JSON.stringify(formioSchema) embedded raw in a <script> tag — a </script>
  in any schema field value (e.g. a component label) breaks out of the tag.
- admin-forms.ts gated by requireAuth() only; the 'forms:manage' nav
  permission is UI-only (requirePermission is a no-op stub) — any
  authenticated user of any role could manage forms and read submissions.
- POST /:identifier/submit checked is_active but not is_public — a form
  marked private was still directly submittable, bypassing the GET routes'
  gate.

Fixes: escapeHtml() on every admin-controlled string rendered into HTML;
a new jsonForScript() helper (escapes `<` to <) for the two schema/
settings script embeds; requireRole(['admin','editor']) on admin-forms.ts;
is_public added to the submit route's form lookup, matching the GET routes.

Real-SQLite integration tests added (public-forms.integration.test.ts) since
the existing mock-DB suite matches on `sql.includes(...)` and can't catch a
dropped WHERE condition; admin-forms.test.ts covers the new RBAC gate.
… turnstile-config on is_public

Follow-up from an independent review of 517a2e9. Findings addressed:

- admin-forms.ts submissions viewer dumped sub.submission_data (populated by
  the anonymous public submit endpoint) via JSON.stringify into a <pre> tag
  with no escaping. The pre-existing sanitizeDeep() only recurses into object
  VALUES (Object.entries(value), key never touched) — an anonymous submitter
  fully controls both. A malicious JSON key survived storage unescaped and
  executed in an admin/editor's session the moment they opened that form's
  submissions page. Worse than anything in the original patch: requires zero
  privileges to plant. Fixed both layers — escapeHtml() at the render site
  (defense in depth) and sanitizeInput() on keys in sanitizeDeep() (root
  cause, matches values' treatment).
- public-forms.ts's GET /:identifier/turnstile-config selects on is_active
  only, same bug class as the already-fixed submit route — a private form's
  existence was still probeable via 200-vs-404. Added is_public = 1 to match
  the other GET routes. Note: this route currently 500s unconditionally
  (both before and after this fix) for an unrelated, pre-existing reason —
  migration 0004_forms.sql never added the turnstile_enabled/turnstile_settings
  columns this query names explicitly, so it errors before the WHERE clause
  is evaluated. Not fixed here (a migration change, separate scope+review);
  the integration test documents current real behavior rather than asserting
  something not yet true. The submit handler is unaffected (uses SELECT *,
  degrades to global-settings inheritance rather than erroring).
- admin-forms-builder.template.ts rendered data.name unescaped in two spots
  while display_name right next to it was escaped — currently inert only
  because of creation-time regex validation (^[a-z0-9_]+$), not defense in
  depth. Escaped for consistency with every other field this area handles.

New/extended tests verify all three: a key-escaping regression in
public-forms.test.ts, a submissions-page XSS regression in admin-forms.test.ts,
and an is_public regression for turnstile-config in the integration suite.
Covers what's actually reachable through a real UI flow: display_name has
no validation (unlike name's ^[a-z0-9_]+$ regex) and renders on both the
admin builder and public form page. The is_public submit-gate fix isn't
included here — there's no admin UI/API path to ever set is_public=0 today,
so it's covered by the real-SQLite integration suite instead (see file
header for detail). Written per project policy; not run locally.
@mmcintosh
mmcintosh marked this pull request as ready for review August 18, 2026 01:41
@mmcintosh
mmcintosh requested a review from lane711 as a code owner August 18, 2026 01:41
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