Stored XSS via SharePoint list content — React won't save you
React won't block a javascript: href, dangerouslySetInnerHTML, or a CSS url() breakout, so list content any member can write is a stored-XSS vehicle — run every sink through an allowlist safeHref (strip C0 control characters first) plus a DOMParser-based sanitizer for SVG/HTML.
React nezablokuje javascript: v href, dangerouslySetInnerHTML ani únik z CSS url(), takže obsah listu, který může zapsat kterýkoli člen, je nástroj pro stored XSS – každý výstupní bod prožeň přes allowlist safeHref (nejdřív strhni řídicí znaky C0) a SVG/HTML přes sanitizér nad DOMParserem.
Symptom
None — that's the problem. The app works perfectly until a security review (or an attacker) notices that values written into a list by any member or content editor flow into the page as URLs, SVG icons, or HTML.
Cause
Three rendering sinks that people assume React protects, and it doesn't:
<a href={valueFromList}>— React does not blockjavascript:URLs (it only logs a warning). A list column is a stored-XSS delivery vehicle with a friendly UI.dangerouslySetInnerHTML— the name says it, yet "it's just an icon SVG from a config list" ships anyway. SVG can carry<script>,<foreignObject>, event handlers.- Inline CSS
url("...")built by string concatenation — React escapes HTML, not the inside of a CSS string literal. A crafted value breaks out of theurl()and injects arbitrary CSS (up to exfiltration tricks).
Fix
One tiny module, used at every sink:
safeHref(url) — allowlist, not blocklist, and strip control characters first:
export function safeHref(raw: string | undefined): string | undefined {
if (!raw) { return undefined; }
const trimmed = String(raw).trim();
// 1) DECISION copy — strip whitespace + C0 controls (U+0000–U+0020). Browsers
// strip them before parsing, so 'java\tscript:alert(1)' would otherwise sneak past.
let decide = '';
for (let i = 0; i < trimmed.length; i++) { if (trimmed.charCodeAt(i) > 0x20) { decide += trimmed[i]; } }
if (!decide) { return undefined; }
// 2) OUTPUT copy — drop only C0 + DEL and encode the space. That range in step 1
// includes U+0020, and returning it would turn '/Shared Documents/A.docx' into
// '/SharedDocuments/A.docx' — a 404. See url-sanitiser-strips-spaces.md.
let url = '';
for (let i = 0; i < trimmed.length; i++) {
const c = trimmed.charCodeAt(i);
if (c === 0x20) { url += '%20'; }
else if (c > 0x1f && c !== 0x7f) { url += trimmed[i]; }
}
const lower = decide.toLowerCase();
if (/^(javascript|vbscript|data):/.test(lower)) { return undefined; } // hard block
if (/^https?:\/\//.test(lower) || /^(mailto|tel):/.test(lower)) { return url; }
if (lower.indexOf('//') === 0) { return 'https:' + url; } // protocol-relative → https
if (decide.indexOf('/') === 0) { return url; } // site-relative
if (/^[\w.-]+\.[a-z]{2,}(\/|$)/.test(lower)) { return 'https://' + url; } // bare domain → https
return undefined; // unknown scheme → no link
}
⚠ The two copies are not optional. Deciding and returning from the same aggressively stripped string is the single most common way this function gets broken — it passes every attack test and quietly 404s every SharePoint file whose path contains a space. Details and a verification recipe: URL sanitiser strips spaces.
- Render the anchor only when
safeHrefreturns a value —<a href={undefined}>looks like a link and does nothing. - SVG/HTML from lists → a DOMParser-based sanitizer with an element/attribute allowlist (drop
script,foreignObject,iframe, allon*attributes,javascript:/data:inhref/src). Never a regex-only sanitizer —java\tscript:and nested tags walk right through. - CSS
url()— value must passsafeHrefand contain no"or).
Notes
- The two traps that make naive implementations fail in both directions:
- Too loose: checking
indexOf('javascript:')without the C0-strip — bypassable with an embedded tab/newline. - Too strict: rejecting
www.company.com(no scheme) — admins type that constantly; ifsafeHrefreturnsundefinedfor it, your "Quick links" stop being clickable. Normalize bare domains tohttps://instead.
- Too loose: checking
- Constants imported from your own code (a hardcoded icon SVG) don't need the sanitizer — the rule is about data that a list writer controls.
mailto:${email}/tel:${phone}with a fixed scheme prefix are safe by construction — the scheme can't be spoofed by the interpolated part.