HeyForm: Stored XSS in Form Fields via an Obfuscated `javascript:` URL

A registered user or project member stores a script in a form field via an obfuscated javascript: URL that runs in the workspace owner's context and takes over their session.

Advisory ID: TP-2026-074
Product: HeyForm (open-source form builder)
Vulnerability type: Stored cross-site scripting (CWE-79)
CVE: not requested
CVSS 3.1: 8.7 (High) · CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N
Affected versions: <= 3.0.0-rc.8
Status: Published by the vendor; no fixed version designated
Vendor advisory: GHSA-8q89-27mx-fvg6
Reported: 11 June 2026

Summary

HeyForm is an open-source form builder. The form renderer serializes a field's title and description to raw HTML and emits it through dangerouslySetInnerHTML to both anonymous form respondents and authenticated editors. The allowlist sanitizer for <a href> rejects javascript: only when the scheme appears contiguous at the start; a tab between java and script passes both the server and client sanitizers unchanged, and the browser restores the live javascript: URL on click. A registered user or a project member stores a script that runs in the workspace owner's authenticated same-origin context and rides the session cookie to full workspace takeover. turingpoint verified the flow and reported it responsibly.

Root cause

The form renderer serializes a field's stored rich-text schema to an HTML string (packages/form-renderer/src/utils/form.ts:119 for title, :127 for description) and injects it via dangerouslySetInnerHTML (packages/form-renderer/src/blocks/Block.tsx:364 and :370). The same FormRenderer backs the anonymous public form page (PublicFormResolver.publicForm, packages/server/src/resolver/form/form-detail.resolver.ts:43-48, no @Auth) and the authenticated Builder preview (packages/webapp/src/pages/form/Builder/PreviewModal/index.tsx:62). Both the server and client sanitizers gate <a href> with UNSAFE_URL_PROTOCOL_REGEX = /^\s*(?:javascript|vbscript|data):/i (packages/server/src/utils/form-schema.ts:29 and :53), which matches only a contiguous scheme. A tab between java and script passes both checks, escapeAttribute leaves the tab intact, and dangerouslySetInnerHTML bypasses React's JSX href sanitizer. Because anonymous self-signup is open by default and no response carries a CSP, any registered user or a low-privilege project member suffices as the attacker.

Proof of Concept

Schematically, as a field's title schema through the anonymous publishForm:

[["a",["CLICK_ME"],{"href":"java<TAB>script:<payload>"}]]

-> the public form page /form/<id> serves the tab verbatim;
   on click the browser normalizes a.href back to javascript:<payload>
   and runs it in the viewer's session context.

The tab breaks the contiguous scheme the sanitizer expects, but the browser strips it when resolving the link, restoring a live javascript: URL.

Impact

  • Script execution in the workspace owner's authenticated same-origin context when the form preview is opened.
  • Session takeover via the HttpOnly session cookie and access to the GraphQL API up to full workspace takeover.
  • Execution also in the browsers of anonymous respondents on the public form page.

References

Is Something Like This in Your Software?

Our team found this vulnerability in the course of its work. Have your applications tested by the same specialists, with a penetration test from turingpoint.