fix(builder): escape/allowlist all attribute-value sinks incl. numeric/enum props (XSS)

An adversarial pass found 5 Critical XSS sinks where props declared number/enum
in TypeScript were interpolated raw into exported HTML attribute values,
trusting the type — but nothing enforces it at runtime (AI update_props only
validates node_id; deserialized saved state is untyped JSON). Fixed all 5
(NumberCounter data-target, StarRating aria-label, FormContainer method,
ContactForm/InputField input type) plus 6 sibling sinks found by an exhaustive
audit of every attribute-value interpolation across src/components: a
JS-source injection into ContentSlider's inline setInterval script, a
prototype-pollution-adjacent allowlist gap in Section's divider-shape lookup,
TextareaField rows, Testimonials rating aria-label, HeroSimple textAlign, and
MapEmbed zoom. Adds shared sanitizeFormMethod/sanitizeInputType allowlist
helpers to utils/escape.ts alongside the existing escapeAttr/safeUrl/cssValue
primitives. Every fix is TDD'd: a malicious-value test reproduces the raw
injection against the pre-fix code, then passes after the fix.

502 tests green (npx vitest run), tsc + vite build green (npm run build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 18:03:44 -07:00
co-authored by Claude Opus 4.8
parent 7ba91d9829
commit 591a51dcc2
45 changed files with 1039 additions and 26 deletions
+20 -4
View File
@@ -1,7 +1,7 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { cssValue } from '../../utils/escape';
import { cssValue, escapeAttr } from '../../utils/escape';
interface StarRatingProps {
rating?: number;
@@ -98,8 +98,19 @@ StarRating.craft = {
/* ---------- HTML export ---------- */
(StarRating as any).toHtml = (props: StarRatingProps, _childrenHtml: string) => {
const rating = props.rating ?? 4.5;
const maxStars = props.maxStars || 5;
// `rating`/`maxStars` are declared `number` in TS but arrive unchecked at
// runtime (AI update_props only validates node_id; deserialized saved
// state is untyped JSON) -- a string like `5" onmouseover="alert(1)`
// breaks out of the aria-label attribute below, and an uncoerced/unclamped
// maxStars can also blow up the star-glyph loop (NaN, absurd loop count,
// or -- observed -- a RangeError from string concatenation overflow with
// e.g. maxStars=1e9). Coerce to numbers with sane fallbacks/clamps first.
const ratingRaw = Number(props.rating);
const rating = Number.isFinite(ratingRaw) ? ratingRaw : 4.5;
const maxStarsRaw = Number(props.maxStars);
const maxStars = Number.isFinite(maxStarsRaw)
? Math.min(Math.max(Math.trunc(maxStarsRaw), 0), 50)
: 5;
// Sanitized -- raw string-interpolation sinks in the star glyphs below.
const size = cssValue(props.size) || '24px';
const filledColor = cssValue(props.filledColor) || '#f59e0b';
@@ -125,7 +136,12 @@ StarRating.craft = {
// The star glyphs convey nothing to assistive tech on their own -- wrap
// in role="img" with a textual equivalent, and hide the decorative glyphs
// themselves (aria-hidden above) so AT doesn't announce each icon.
// Belt-and-suspenders: rating/maxStars are already coerced to numbers
// above, but the assembled label is still run through escapeAttr() in
// case a decimal/negative/Infinity edge case produces odd (though no
// longer dangerous) text.
const ariaLabel = escapeAttr(`Rating: ${rating} out of ${maxStars}`);
return {
html: `<span role="img" aria-label="Rating: ${rating} out of ${maxStars}"${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${starsHtml}</span>`,
html: `<span role="img" aria-label="${ariaLabel}"${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${starsHtml}</span>`,
};
};