dkhoa-dev/grafana-sensor-floor-map is a public Grafana panel plugin under the MIT licence. It draws a floor plan from SVG and colours each room by its live sensor readings. Any element with a data-room attribute, or an id of the form room-<name>, is a room. This post covers what the panel does to that SVG before the browser renders it.
Where the markup comes from
The plan is SVG markup stored in the panel options. It can come from the built-in editor, from the SVG source box, or from an AI model. The AI assistant copies a prompt that the user pastes into any AI chat, and the reply is pasted back into the panel. If a Grafana admin set up the Grafana LLM app, the assistant can also generate through it. Either way, the panel renders markup it did not write, for everyone who opens the dashboard.
Scripts and event handlers are the obvious risk, and DOMPurify removes them by default. The quieter risk was a plain resource fetch.
style="fill:url(https://…)" made every viewer’s browser call that server. No script ran. The URL was simply one that the plan’s author chose.PR #14 closed that on 25 Oct 2026.
Tags that never belong in a plan
sanitizeFloorPlan in src/svg/floorPlan.ts runs DOMPurify with its SVG and SVG filter profiles. It uses a private DOMPurify instance, so the hook below never touches other code on the page. It also forbids these tags: script, foreignObject, iframe, style, image, a, animate, animateMotion, animateTransform and set.
Two entries need a reason. A <style> block would apply its rules to the whole Grafana page. The animation elements can rewrite attributes after the sanitizer has checked them.
One hook for every attribute
Every attribute that survives the tag list goes through one hook:
// src/svg/floorPlan.ts
purifier.addHook('uponSanitizeAttribute', (_node, data) => {
const name = data.attrName.toLowerCase();
const value = data.attrValue.trim();
if ((name === 'href' || name.endsWith(':href')) && !value.startsWith('#')) {
data.keepAttr = false;
} else if (loadsExternalResource(value)) {
data.keepAttr = false;
}
});
An href or xlink:href stays only when it points at an id inside the document. Any other attribute goes when its value could load a resource.
- inputPlan markupeditor, paste or AI reply
- tagsDOMPurifySVG profiles, 10 forbidden tags
- attributesHookhref only #id, no loading CSS
- idsscopeIdssfm-<panelId>- prefix
- panelRooms drawncoloured by readings
CSS has more than one way to fetch
loadsExternalResource in src/svg/externalRefs.ts flags a value that contains image(, image-set(, cross-fade(, element(, src( or @import. Vendor-prefixed forms such as -webkit-image-set( end in the same names, so the same pattern catches them. url( has its own rule: the value is kept only if every url( in it points at #id.
The function decodes CSS escapes first. In CSS, \75 rl( means url(, so a check that looks for the literal text misses it. The end-to-end test in tests/panel.spec.ts pastes a plan with both tricks into the SVG source box:
<!-- tests/panel.spec.ts -->
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" style="background-image:image-set('https://example.invalid/a.png' 1x), \75 rl(https://example.invalid/b.png)"><rect data-room="Probe" width="200" height="100" fill="#ccc"/></svg>
It waits 2 seconds after the room appears and expects Chromium to have sent no request to example.invalid. A comment in the test says Chromium fetches both background layers unless the sanitizer drops the style.
Local references still work
Blocking every url() would break normal drawings. fill="url(#gradient)" and <use href="#shape"> point inside the document, and vector editors draw repeated shapes with <use>. So the panel adds use back to the allowed tags and keeps #id references.
Local ids bring their own problem. Two panels on one dashboard can each define #gradient, and one plan then picks up the other’s gradient or clip path. scopeIds prefixes every id with sfm-<panelId>-. It rewrites each url(#…) and href="#…" that points at those ids the same way.
Links built from dashboard variables
A click on a room can open a URL built from dashboard variables plus ${room}, ${sensor} and ${location}. The final URL exists only after the variables are filled in. isSafeLink checks that final string with the browser’s own URL parser and allows http:, https: and relative links. A spelling such as java\tscript: parses as javascript: and is refused, and so are data: links.
Grafana’s plugin review of 0.2.0 then flagged direct window.location access. Release 0.2.1, on 26 Oct 2026, resolves links against document.baseURI instead. A link that leaves Grafana in the same tab now goes through a temporary <a rel="noopener noreferrer">. A first attempt with window.open(url, '_self') failed another review rule, which requires noopener (PR #29).
Tests that fail when a rule is removed
PR #13 added a test for each way a plan could load a resource. It covers href and xlink:href on use, feImage, image, pattern, linearGradient, radialGradient, filter and textPath. It also covers @import in <style> and loaders such as -webkit-cross-fade(). The unit suite had 226 tests after it.
The PR records what happens when each guard is deleted. Without the :href condition, 9 tests fail. Without the whole href rule, 16 fail. Without the #id exception, 4 fail.
The same PR fixed the README, which said the opposite of the code. It now says <use> is kept with #id references, and <image>, links and animations are removed.
Lessons
- Markup that a user pastes or an AI returns is untrusted, even when it only draws rectangles.
- An SVG with no script can still make the browser fetch, through CSS functions or an
href. - Decode CSS escapes before you match function names.
- Keep
#idreferences and scope ids per panel, or ordinary drawings break. - Check a link after the variables are filled in, with the browser’s URL parser.
- Delete each guard once and run the tests. If nothing fails, the guard has no test.