Skip to content

fix(ssr): validate dynamic attribute and tag names - #592

Merged
ryansolid merged 1 commit into
mainfrom
fix/ssr-dynamic-name-validation
Oct 7, 2026
Merged

ryansolid merged 1 commit into
mainfrom
fix/ssr-dynamic-name-validation

Conversation

@ryansolid

Copy link
Copy Markdown
Owner

Summary

ssrElement and ssrSpread wrote spread keys and the tag name exactly as given. A runtime name containing whitespace, a quote, <, >, /, = or a control character is read back by the HTML tokenizer as more than one name (or ends the tag). This PR makes SSR validate those runtime names, using the same grammar as Solid 2.0:

  • Attribute name is valid iff non-empty and free of /[\0-\x20\x7F-\x9F"'<>/=]/.
  • Tag name is valid iff it starts with an ASCII letter and passes the same check.

Legitimate names are unaffected: @click, x-on:click, :class, xlink:href, data-*, aria-*, custom elements (my-widget, x-foo.bar, math-α), foreignObject, svg:rect.

Changes (packages/dom-expressions/src/server.js)

  • Spread keys in ssrElement / ssrSpread (plain, attr: and bool: branches) that are not a single attribute name are dropped, as the client's setAttribute would reject them. A dropped key spaces the output exactly like an existing skipped key (e.g. an undefined value).
  • Invalid tag names make ssrElement throw "<tag>" is not a valid tag name, matching client document.createElement and Solid 2.0. In solid-js this is reachable through <Dynamic component="..."> with a string.
  • ssrSpread now escapes class / className values with escape(..., true), the same as ssrElement already did. ssrSpread is no longer emitted by the compiler but is still exported for older compiled output.
  • ssrClassList, ssrStyle, ssrStyleProperty attribute-escape class names and style property names, so a " in a name cannot end the quoted class="…" / style="…" attribute. ssrClassList(obj) is written directly inside class="…" by the compiler, and computed style keys (style={{ [k]: v }}) reach ssrStyleProperty as k + ":". Escaping in the runtime covers already-compiled code without a compiler change.
  • escape itself is unchanged.

Performance

Validated names are kept in bounded module-level caches (512 entries; only valid names are cached). For spread keys the cache stores the final written name (Aliases[prop] || escape(prop)), so a hit replaces the validation, the alias lookup and the escape call. The output is identical because it is the same calculation, just cached. Style names use a similar cache around escape(name, true).

Micro-benchmark, 100k renders, median ms (Node 26):

Case Before After
ssrElement, 10 spread keys 65 54
ssrElement, 10 keys + 2 alternating tags 66 56
ssrSpread, 10 keys 58 48
ssrStyle, 4 keys 10.7 12.1
ssrClassList, 4 keys 6.9 7.1
ssrStyleProperty, 4 calls 2.5 3.6

Output changes

Output for every valid name is byte-identical to before (including the existing data-a&b → data-a&amp;b behavior). The byte-level changes are:

  1. Spread keys (plain, attr:, bool:) that are not a single attribute name are dropped in ssrElement and ssrSpread.
  2. ssrElement throws "<tag>" is not a valid tag name for an invalid tag name (reachable via solid-js <Dynamic component="...">).
  3. ssrSpread attribute-escapes class / className values, so & and " in them change bytes (now matching ssrElement).
  4. ssrClassList keys containing " now render as &quot;. Keys containing < now render raw instead of as &lt;; that is a byte change, but the parsed value is the same.
  5. ssrStyle and ssrStyleProperty escape style names containing & or ".
  6. Known follow-up (compiler): when a static or dynamic class is combined with a dynamic classList (e.g. <div class="a" classList={obj}>), the compiler wraps the ssrClassList result in another escape(..., true). A " in a class name there is now double-escaped and shows as a literal &quot;. It stays inside the attribute, but the value is wrong; & already behaved this way on that path before this PR. The proper fix is for the compiler to skip the outer escape for ssrClassList calls.

Tests

  • New dynamic attribute and tag names block in packages/dom-expressions/test/ssr/ssr.spec.js (14 tests): invalid keys in all three branches for both helpers, spacing when a key is dropped, invalid tags throw, custom element / SVG tags accepted, byte-identical output for valid names, behavior on repeated renders and past the cache bound, ssrSpread class escaping, and name escaping in ssrClassList / ssrStyle / ssrStyleProperty. 10 of them failed before the change.
  • Package suites: babel-plugin-jsx-dom-expressions 124, dom-expressions 166, hyper-dom-expressions 7, lit-dom-expressions 14, all passing.
  • Built solid-js 1.x (main, 1.9.16) against this branch: its suites pass (487 client, 8 server), and the built solid-js/web server bundle drops invalid keys, escapes the ssrSpread class value, and throws for an invalid <Dynamic> tag. Valid <Dynamic> output is byte-identical to the 0.40.11 build.

Prepared with Claude via Cursor

ssrElement and ssrSpread wrote spread keys and the tag name as given, so a
runtime name containing whitespace, a quote, `<`, `>`, `/`, `=` or a
control character was read back by the HTML parser as more than one name.
Such spread keys (including `attr:` and `bool:` names) are now dropped,
as the client's setAttribute would reject them, and ssrElement throws for
such a tag name, as document.createElement does. Valid names render
exactly as before; validated names are kept in a bounded cache, so a
repeated key costs one lookup.

ssrSpread now escapes class/className values the way ssrElement does, and
ssrClassList, ssrStyle and ssrStyleProperty attribute-escape class names
and style property names.

Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Oct 7, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: d387b80

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@ryansolid
ryansolid merged commit 04f6e9a into main Oct 7, 2026
2 checks passed
ryansolid added a commit to solidjs/solid that referenced this pull request Oct 7, 2026
SSR validates dynamic attribute and tag names, and escapes class and
style names (ryansolid/dom-expressions#592). The lockfile is refreshed
for 0.40.12, which also updates the stale babel-preset-solid solid-js
peer entry left by the 1.9.16 release.

Co-authored-by: Cursor <cursoragent@cursor.com>
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