Skip to content

Harden config colour/size values against style-attribute injection#65

Merged
nicosandller merged 5 commits into
nicosandller:mainfrom
amadeobriones:harden/style-attr-injection
Jul 17, 2026
Merged

Harden config colour/size values against style-attribute injection#65
nicosandller merged 5 commits into
nicosandller:mainfrom
amadeobriones:harden/style-attr-injection

Conversation

@amadeobriones

Copy link
Copy Markdown
Collaborator

🤖 Disclosure — written with an AI agent (Claude Code) under my direction. I run this card on a live HA instance daily. Addresses #64.

Config is shareable/importable, so its colour and size fields are effectively attacker-controlled. Lit doesn't escape ; or } inside a style-attribute expression, so a shared/imported config can break a value out of its declaration — e.g. background: "red;position:fixed;inset:0;z-index:99999" paints a full-viewport overlay over the HA UI, and a colour containing url(…) becomes an IP-leaking remote fetch.

What this does

  • Adds src/css-safe.ts: an allowlist cssColor/cssColorOr (hex, named + CSS-wide keywords, rgb/hsl/hwb/lab/lch/oklab/oklch/color(...), and var(--x[, fallback])) plus a cssNumber coercion for numeric fields. Allowlist over denylist on purpose — a denylist invites the one vector you forgot.
  • Wires it at every style="…" sink in the card, the editor preview, and the shared render helpers: stage background, text color/size, item badge size/angle, opening accent (the fill/stroke in style=), and ripple color/size.
  • Leaves SVG fill=/stroke=/transform= presentation attributes alone — there's no CSS declaration there to break out of, so they're not this bug class.

Honest scoping

  • The colour allowlist is the part I've run hard; the size/angle numeric coercion is a simple finite-number guard (new, not previously battle-tested, but small and covered by tests).
  • Top-level width/height/grid already go through your setConfig numeric check, so those are left as-is.
  • Items: Show name toggle + adjustable label size — closes #61, #59 #62 adds a labelSize field of the same shape; it's not on main so it's out of scope here — it just needs the same cssNumber wrap wherever it lands.

Backward-compatible: every legitimate value renders unchanged — I tested the allowlist against the colours real configs use (HA theme vars with/without fallback, hex, named, rgb/rgba in comma and modern space syntax, hsl/hsla, oklch/lab/lch/hwb/color). Only invalid/malicious values fall back to the existing defaults. tsc clean; full suite green (adds sanitiser unit + adversarial/fuzz cases).

Marked as a draft — happy to adjust scope, naming, or split it if you'd prefer smaller.

@amadeobriones
amadeobriones marked this pull request as ready for review July 13, 2026 04:54

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bfa089ffc5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/css-safe.ts Outdated
// var(--token) with an optional simple fallback restricted to the characters a
// colour value uses — no `;` `{` `}` `(` `)` and no injection-adjacent punctuation,
// e.g. var(--primary, #03a9f4). Tighter than "anything but delimiters".
const VAR = /^var\(\s*--[a-z0-9-]+\s*(?:,\s*[a-z0-9\s.,%/#-]*)?\)$/i;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve valid nested CSS variable colors

When a user configures a color with a nested theme fallback such as var(--ha-card-background, var(--card-background-color, #fff)), this allowlist rejects it because the fallback part of var() cannot contain parentheses. Nested var() fallbacks are valid CSS and common in Home Assistant theme values, so existing configured backgrounds, text colors, active colors, or ripple colors silently revert to the hard-coded default after this change.

Useful? React with 👍 / 👎.

amadeobriones pushed a commit to amadeobriones/easy-floorplan that referenced this pull request Jul 13, 2026
Addresses the Codex review on nicosandller#65 (nested var() fallbacks) and a wider gap it
pointed at: Home Assistant stores theme colours as bare "r, g, b" triplets read
back as rgb(var(--rgb-primary-color)), and chains fallbacks as
var(--a, var(--b, #fff)). The first-pass allowlist rejected both, so real configs
would have silently reverted to defaults.

Replace the fixed colour-form regexes with a structural, fail-closed check:
allowed characters only (no ; { } " ' : @ \ ! or control chars), balanced
parens, and every function call on a SAFE_FUNCS allowlist (colour/gradient/var/
env/maths). Arbitrary nesting like rgb(var(--x)) and var(--a, var(--b, #fff)) now
passes, while url()/image-set()/expression()/attr()/element()/paint() can never
appear. Verified against real HA theme values and an adversarial + fuzz battery;
O(n), no ReDoS or recursion (200k-char and 5000-deep inputs resolve in <=1ms).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@amadeobriones

Copy link
Copy Markdown
Collaborator Author

Good catch from the Codex pass — the follow-up commit addresses it, and it turned out to point at something broader.

The nested var() fallback it flagged (var(--a, var(--b, #fff))) was indeed being rejected. Digging in, the same limitation also rejected rgb(var(--rgb-primary-color)) / rgba(var(--rgb-*), …) — the standard way HA has expressed theme colours since 2022.12 (stored as bare r, g, b triplets). So the first pass would have silently reverted a lot of real theme values to their defaults.

Rather than special-case those, I swapped the fixed colour-regexes for a structural check: allowed characters only (no ; { } " ' : @ \ !), balanced parens, and every function call on an allowlist of inert ones (colour / gradient / var / env / maths). Arbitrary nesting like rgb(var(--x)) and var(--a, var(--b, #fff)) now passes, while url() / image-set() / expression() / attr() / element() / paint() can never appear, nested or not.

I checked it against theme-colour forms from across HA versions — named, hex, rgb()/hsl() literals, traditional var(--…), the older --paper-* / --mdc-theme-* variables, the 2022.12+ rgb(var(--rgb-*)) idiom, fallback chains, and modern color-mix() / light-dark() — plus an adversarial + fuzz battery. Still O(n), no ReDoS.

One trade-off worth flagging: it's slightly more permissive than a strict grammar — an invalid value like #xyz is accepted and simply ignored by CSS rather than pre-rejected. Safe (no breakout), and it's what lets the full HA colour syntax through.

@amadeobriones

Copy link
Copy Markdown
Collaborator Author

Following up on my own PR with a gap I found while re-auditing this.

The colour/size sanitiser is solid, but width/height still reach the stage's aspect-ratio rawaspect-ratio: ${c.width} / ${c.height} at editor.ts and floorplan-card.ts. The card's setConfig guards those as numbers, but the editor's setConfig doesn't, so a config imported/opened in the editor can break out. Reproduced in the dev harness: setting width to "1 / 600; position: fixed; inset: 0; z-index: 99999; background: rgb(255,0,0)" makes the editor .stage compute position: fixed — the injected CSS takes effect (a full-viewport overlay; swap for url() via the height field and it's a remote fetch).

Fix is ready and verified: coerce both sinks through cssNumber(c.width, DEFAULT_WIDTH) / cssNumber(c.height, DEFAULT_HEIGHT), plus cssNumber should treat a blank string as unset (Number("") is 0). With it applied, the same payload renders aspect-ratio: 1000 / 600 and position stays relative; adds regression tests; full suite green (309). Happy to push it to this branch — flagging here first so it's visible.

petros-double-test1 and others added 3 commits July 13, 2026 21:51
Config is shareable/importable, so its colour and size fields are
attacker-controlled. Lit does not escape `;`/`}` inside a style-attribute
expression, so e.g. `background: "red;position:fixed;inset:0;z-index:99999"`
paints a full-viewport overlay over HA, and a colour containing `url(...)`
becomes an IP-leaking remote fetch. (Addresses nicosandller#64.)

Adds src/css-safe.ts: an allowlist `cssColor`/`cssColorOr` (hex, named/CSS-wide
keywords, rgb/hsl/oklch/... functions, and `var(--x[, fallback])`) and a
`cssNumber` coercion for size/angle fields. Wires it at every `style="…"` sink
in the card, editor preview, and shared render helpers (stage background, text
colour/size, item badge size/angle, opening accent, ripple colour/size). SVG
`fill=`/`stroke=`/`transform=` presentation attributes are intentionally left
alone — there is no declaration there to break out of.

Allowlist verified against the colours real configs use (HA theme vars, hex,
named, rgb/rgba, hsl, oklch/lab/lch/hwb/color) so no legitimate value regresses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the Codex review on nicosandller#65 (nested var() fallbacks) and a wider gap it
pointed at: Home Assistant stores theme colours as bare "r, g, b" triplets read
back as rgb(var(--rgb-primary-color)), and chains fallbacks as
var(--a, var(--b, #fff)). The first-pass allowlist rejected both, so real configs
would have silently reverted to defaults.

Replace the fixed colour-form regexes with a structural, fail-closed check:
allowed characters only (no ; { } " ' : @ \ ! or control chars), balanced
parens, and every function call on a SAFE_FUNCS allowlist (colour/gradient/var/
env/maths). Arbitrary nesting like rgb(var(--x)) and var(--a, var(--b, #fff)) now
passes, while url()/image-set()/expression()/attr()/element()/paint() can never
appear. Verified against real HA theme values and an adversarial + fuzz battery;
O(n), no ReDoS or recursion (200k-char and 5000-deep inputs resolve in <=1ms).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The colour/size sanitiser missed the stage's aspect-ratio: width/height reach
`aspect-ratio: ${c.width} / ${c.height}` raw. The card's setConfig guards them
as numbers, but the editor's setConfig does not, so a config imported and opened
in the editor breaks out of the style attribute (verified in the dev harness:
a crafted width makes .stage compute position:fixed).

Coerce both sinks through cssNumber(c.width, DEFAULT_WIDTH) / (c.height,
DEFAULT_HEIGHT). Also treat a blank string as unset in cssNumber — Number("")
is 0 (finite), which would silently become a 0 ratio. Adds regression tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@amadeobriones
amadeobriones force-pushed the harden/style-attr-injection branch from 0e9ae39 to c75d708 Compare July 14, 2026 02:54
nicosandller added a commit that referenced this pull request Jul 14, 2026
Label span resolution per the review: keep #41's inflow fallback AND this
branch's labelSize — the span now carries both. itemBadgeLabel gains
main's #39 guard (no entity, no state line; an unbound device's label can
only be its configured name).

Review point 2: labelSize is clamped/coerced at the style sink
(itemLabelSize, 8–40, default 12) in both card and editor, so a config
string like "20px;color:red" can never reach the style attribute.
Unit-tested with the review's exact payloads; interim hardening until
#65's cssNumber lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ler#62) and route labelSize through cssNumber

One conflict (badge size line vs nicosandller#58's animation resolver) — union: the
sanitized size and the animation both stay. Post-merge, nicosandller#62's interim
labelSize clamp now delegates its coercion to cssNumber, so every numeric
style sink shares one guard; the 8-40 range clamp stays on top.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@nicosandller nicosandller left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — and we agree with the scope as drawn: style-attribute sinks only, SVG presentation attributes deliberately excluded (no declaration to break out of there), numeric fields coerced rather than pattern-matched, allowlist over denylist. That's the right boundary for this bug class, and the test suite — legit HA colours across every theme era, the hand-written attack corpus, and the 500-case fuzz invariant — is the most thorough in the repo.

I've rebased the branch on main (pushed to your fork — hope that's welcome; it was the same mechanical churn as the other rebases this week):

  • One conflict, the badge size line vs #58's animation resolver — resolved as a union, so the sanitized size and the animation class both stay.
  • #62's labelSize now routes through cssNumber exactly as your PR body anticipated: itemLabelSize keeps its 8–40 range clamp but delegates coercion to your shared guard, so every numeric style sink uses one primitive.
  • Audited the merged tree for style sinks that arrived after your branch (#57/#58/#60/#62) — all covered; no unsanitized interpolation remains.

Verified end-to-end: tsc clean, 341/341 tests, build clean, and a live harness pass — the demo attack (red;position:fixed;inset:0;z-index:99999 as background) falls back to the theme default, a url(…)-smuggling text colour is neutralized, hostile size/labelSize strings coerce to defaults, while var(--primary-color, #03a9f4) and the rgba(var(--rgb-primary-color), 0.5) triplet idiom render byte-identical and the merged features (icon animation, labels, rotation) still work.

Two style sinks in my still-open branches (#63's roll curtain scaleY, #69's editor preview) will adopt these helpers when they rebase after this lands. Ready to merge.

@nicosandller
nicosandller merged commit 2ec0a7d into nicosandller:main Jul 17, 2026
1 check 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.

3 participants