Skip to content

feat: sanitized rich text field labels - #89

Merged
scottgrayson merged 4 commits into
4.xfrom
feat/rich-text-field-labels
Nov 4, 2026
Merged

scottgrayson merged 4 commits into
4.xfrom
feat/rich-text-field-labels

Conversation

@scottgrayson

@scottgrayson scottgrayson commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

What this does

Lets a form field label hold formatted text (headings, paragraphs, lists, bold, links), so long agreements like CHECK's STEPS agreement (form 25, "Please Read and Sign Student Name") are readable. Every rich label is sanitized, and plain labels render exactly as before.

  • Support\FieldLabel holds the label rules.
    • Plain labels (no tags) are returned untouched.
    • Labels with tags go through symfony/html-sanitizer. It keeps safe elements and http, https, mailto, tel and relative links. It drops script, style, iframe, form, img, event handlers, style attributes, and javascript: and data: URLs, and adds rel="noopener noreferrer" to links.
    • Labels with block content are wrapped in fi-prose, so Filament's typography styles them.
  • Form rendering (Show) uses FieldLabel::toHtml() for field and repeater sub-field labels.
    • This also closes an existing stored-XSS hole: before, labels were passed to new HtmlString() unsanitized.
  • Field editor: there's a new Format label toggle, hidden for Heading and Repeater fields.
    • When it's on, the label is edited in a RichEditor with these buttons: bold, italic, underline, link, h2, h3, lists, quote, undo and redo.
    • The content is sanitized again on save and stored in the existing label column, so there's no migration.
    • Turning the toggle on for an existing plain label pre-fills the editor, with blank lines becoming paragraphs.
  • Entry views, the entries export, and the field table/tooltip show rich labels as plain text.
  • Stored entry data is unchanged: entries still record field as the raw label. Apps that match entries against fields or rubrics by label (CHECK grades tests this way) keep working.
  • Adds symfony/html-sanitizer: ^7.0|^8.0 to require.

Blast radius

  • Plain labels are byte-identical to before (new HtmlString($label)). Tests cover this across text, textarea, select, checkbox, checkbox list, radio, toggle, date, rich editor and heading fields, with labels that use &, entities, quotes, unicode and multiple lines.
  • Labels that already contain simple inline HTML (<strong>, <em>, <b>, <i>, <u>, <br />) render unchanged.
  • Labels that contained unsafe HTML lose the unsafe parts. Also, a label with a literal < that looks like a tag (for example <name>) is now treated as HTML.
  • label column, entry JSON, rubric/test matching in host apps: unchanged.

How it was tested

  • vendor/bin/pest: 126 passed. The new tests are in tests/FieldLabelTest.php:
    • plain-label blast radius
    • inline HTML kept
    • 12 XSS payloads stripped
    • safe links kept
    • agreement prose
    • schema sanitization
    • plain-text views
    • editor pre-fill and save folding
  • vendor/bin/pint: clean.
  • vendor/bin/phpstan: clean. 4.x already fails PHPStan in CI on one larastan error (FormSubmissionNotification passes the namespaced view to Content as a plain string, not a view-string). This PR adds that error to phpstan-baseline.neon and makes the PHPStan workflow run when the baseline changes.
  • CI: run-tests passes on PHP 8.3, 8.4 and 8.5 with Laravel 12 and 13. Code style and PHPStan pass.

Human test steps

  1. In a host app on this branch, open Admin → Forms → any existing form. Confirm field labels and the field table look the same as before.
  2. Edit a Checkbox field, turn on Format label, and add a heading, two paragraphs, a bulleted list, bold text and a link. Save.
  3. Open the form as a user. The label should show as formatted text, and the link should open with rel="noopener noreferrer".
  4. Turn Format label off and on again. The content should persist. Switch the field type to Heading: the toggle hides and the plain textarea returns.
  5. Submit an entry. In the admin entry view and the CSV export, the question should show as plain text.
  6. Editor-bypass check: set a label directly in the DB to <p onclick="alert(1)">x</p><script>alert(1)</script> and load the form. No script runs, and only x renders.

Bug replication

I ran this PR's tests/FieldLabelTest.php against 4.x, with none of the fix. These tests push each XSS payload through Show::getFormSchema() and read the label that gets rendered. On 4.x the payload comes through untouched, so all 24 cases fail: 12 payloads, each with a TEXT field and a CHECKBOX field.

  • strips scripts, event handlers and dangerous links from labels in the form schema:
    • script tag: Expecting 'name<script>alert(1)</script>' not to contain '<script'
    • img onerror: Expecting '<img src=x onerror="alert(1)"> name' not to contain 'onerror'
    • svg onload: Expecting '<svg onload="alert(1)"></svg>name' not to contain 'onload'
    • javascript link: Expecting '<a href="javascript:alert(1)"…ck</a>' not to contain 'javascript:'
    • data link: Expecting '<a href="data:text/html;base6…ck</a>' not to contain 'data:'
    • iframe: Expecting '<iframe src="https://evil.tes…e>name' not to contain '<iframe'
    • onclick: Expecting '<p onclick="alert(1)">name</p>' not to contain 'onclick'
    • style attribute, style tag, form, img tag, script body: same pattern, with the raw payload in the label.
  • sanitizes rich labels in the form schema: expected <div class="fi-prose"><h2>Agreement</h2><p>Sign below</p></div>, got <h2>Agreement</h2><p>Sign below<script>alert(1)</script></p>.

The plain-label schema tests pass on 4.x, as they should: plain labels render exactly as before. The full suite passes on this branch, 138 tests.

@scottgrayson
scottgrayson merged commit fcd3634 into 4.x Nov 4, 2026
9 checks passed
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