SharePoint gives you a lot of ways to render user-controlled content, and that’s exactly why XSS keeps showing up in SharePoint customizations. The platform itself has decent guardrails, but the moment you add SPFx components, classic scripts, custom forms, REST-driven UI, or “just a little HTML” from a list field, you can create a mess.
This guide is the practical version: where XSS shows up in SharePoint, what safe code looks like, and what I’d actually recommend in a real tenant.
Where XSS happens in SharePoint
The common sources are boring and predictable:
- List or library fields rendered as HTML
- Query string values written into the DOM
- Search results templates
- Custom Script Editor / Content Editor web parts on classic pages
- SPFx web parts using
innerHTML - Power Apps / JSON formatting / custom forms that inject unsafe strings
- REST or Graph API data inserted into page markup
- Rich text fields that are trusted too much
The root problem is almost always the same: untrusted data reaches an HTML, JavaScript, URL, or CSS sink without the right encoding or sanitization.
The big rule: encode for the output context
A lot of SharePoint XSS bugs happen because developers sanitize vaguely instead of encoding specifically.
Use the right defense for the right sink:
- HTML text node → HTML encode
- HTML attribute → attribute encode
- URL parameter → URL encode
- JavaScript string → JS string encode
- Rich HTML you intentionally allow → sanitize with a trusted HTML sanitizer
If you remember one thing, make it this: don’t use innerHTML unless you absolutely have to.
Unsafe vs safe DOM rendering in SPFx
This is the classic bug.
Unsafe
public render(): void {
const userTitle = this.properties.title; // could come from a list, query string, API, etc.
this.domElement.innerHTML = `
<div class="banner">
Welcome ${userTitle}
</div>
`;
}
If userTitle contains <img src=x onerror=alert(1)>, you’ve got XSS.
Safe: use textContent
public render(): void {
const userTitle = this.properties.title;
const banner = document.createElement("div");
banner.className = "banner";
banner.textContent = `Welcome ${userTitle}`;
this.domElement.replaceChildren(banner);
}
This is the simplest fix and usually the best one.
Safe rendering in React-based SPFx
React helps, as long as you don’t bypass it.
Safe React example
import * as React from "react";
interface IProps {
message: string;
}
export const WelcomeBanner: React.FC<IProps> = ({ message }) => {
return <div className="banner">{message}</div>;
};
React escapes content by default. That’s good.
Unsafe React example
export const WelcomeBanner: React.FC<IProps> = ({ message }) => {
return <div dangerouslySetInnerHTML={{ __html: message }} />;
};
dangerouslySetInnerHTML is where people talk themselves into shipping an incident.
If you truly need to render limited HTML from a rich text source, sanitize first.
Sanitizing rich HTML safely
Sometimes SharePoint solutions really do need to display formatting from a rich text field. In that case, sanitize the HTML and allow only a small set of tags and attributes.
Example with DOMPurify
import DOMPurify from "dompurify";
const dirtyHtml = item.Description; // from SharePoint list rich text field
const cleanHtml = DOMPurify.sanitize(dirtyHtml, {
ALLOWED_TAGS: ["b", "i", "strong", "em", "p", "ul", "ol", "li", "a", "br"],
ALLOWED_ATTR: ["href", "title"],
ALLOW_DATA_ATTR: false
});
container.innerHTML = cleanHtml;
If you do this, keep the allowlist tight. Don’t casually allow style, event attributes, or embedded content.
Query string XSS in SharePoint pages
SharePoint pages often read values from the URL to filter, prefill, or personalize content.
Unsafe
const params = new URLSearchParams(window.location.search);
const category = params.get("category") || "";
document.getElementById("currentCategory")!.innerHTML = category;
Safe
const params = new URLSearchParams(window.location.search);
const category = params.get("category") || "";
document.getElementById("currentCategory")!.textContent = category;
If you need to place the value in a URL, encode it properly.
const url = `/sites/portal/Lists/Products?category=${encodeURIComponent(category)}`;
link.setAttribute("href", url);
Don’t concatenate raw query string values into href, src, or redirect targets.
SharePoint REST API data is not trusted
I still see developers treat data from SharePoint REST as “internal” and therefore safe. That’s a mistake. If a user can edit a field, that field is attacker-controlled.
Unsafe REST rendering
const response = await fetch("/sites/demo/_api/web/lists/getbytitle('Announcements')/items", {
headers: { Accept: "application/json;odata=nometadata" }
});
const data = await response.json();
const list = document.getElementById("announcements")!;
for (const item of data.value) {
list.innerHTML += `<li>${item.Title}</li>`;
}
Two problems here:
- direct HTML injection
- repeated
innerHTML +=parsing
Safe REST rendering
const response = await fetch("/sites/demo/_api/web/lists/getbytitle('Announcements')/items", {
headers: { Accept: "application/json;odata=nometadata" }
});
const data = await response.json();
const list = document.getElementById("announcements")!;
for (const item of data.value) {
const li = document.createElement("li");
li.textContent = item.Title;
list.appendChild(li);
}
That’s the pattern I trust.
Classic SharePoint pages are riskier
Classic pages with Script Editor or Content Editor web parts are where a lot of ugly XSS lives. Old jQuery snippets, inline scripts, and direct DOM manipulation pile up fast.
Unsafe jQuery pattern
var title = GetUrlKeyValue("title");
$("#pageTitle").html(title);
Safer pattern
var title = GetUrlKeyValue("title");
$("#pageTitle").text(title);
If you’re maintaining classic SharePoint, search for these first:
.html(.append(.prepend(innerHTMLdocument.write- inline event handlers like
onclick= - string-built templates containing user data
That quick grep usually finds the worst bugs.
Be careful with links and redirects
A less obvious XSS path is allowing javascript: URLs.
Unsafe
const userUrl = item.Link;
anchor.setAttribute("href", userUrl);
If userUrl is javascript:alert(1), that’s game over.
Safe URL validation
function isSafeHttpUrl(value: string): boolean {
try {
const url = new URL(value, window.location.origin);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
const userUrl = item.Link;
if (isSafeHttpUrl(userUrl)) {
anchor.setAttribute("href", userUrl);
} else {
anchor.removeAttribute("href");
}
I strongly prefer allowlisting protocols instead of trying to blacklist bad ones.
Content Security Policy helps, but it won’t save sloppy code
CSP is useful in SharePoint-integrated apps and custom pages, especially if you host supporting components yourself. It reduces blast radius when someone misses an encoding bug.
A solid starting point:
Content-Security-Policy:
default-src 'self';
script-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'self' https://*.sharepoint.com;
img-src 'self' data: https:;
style-src 'self' 'unsafe-inline';
connect-src 'self' https://*.sharepoint.com https://graph.microsoft.com;
Real SharePoint deployments often need tuning because of Microsoft 365 endpoints, embedded experiences, and legacy inline styles. If you need a deeper CSP rollout reference, csp-guide.com is useful.
Also, verify your headers from the outside. I usually run a quick check with HeaderTest after changes, because broken CSP and missing framing rules are easier to spot there than by eyeballing config.
SharePoint-specific habits that prevent XSS
These are the habits I’d enforce on any team building on SharePoint:
1. Prefer framework escaping over manual HTML
If React can render it as text, let React do it.
2. Ban raw innerHTML by default
Require a code review exception for it.
3. Treat every list field as untrusted
Even “internal only” lists become attack paths.
4. Sanitize rich text at the boundary
If HTML is allowed, clean it before rendering.
5. Validate URLs before assigning href or src
Allow only http: and https: unless there’s a very specific reason.
6. Reduce classic page scripting
If you still rely on Script Editor web parts everywhere, you’re carrying a lot of risk.
7. Add lint rules
Catch dangerous patterns automatically.
Example ESLint idea:
{
"rules": {
"no-unsanitized/property": "error",
"no-unsanitized/method": "error"
}
}
That won’t solve everything, but it catches a surprising amount of nonsense.
Quick review checklist
When I review SharePoint code for XSS, I ask:
- Does any untrusted value reach
innerHTML,.html(), ordangerouslySetInnerHTML? - Are query string values written to the DOM safely?
- Are list fields or REST results treated as attacker-controlled?
- Are rich text fields sanitized before rendering?
- Are links validated against
javascript:or other unsafe schemes? - Is CSP present where we control headers?
- Are classic page customizations still using inline scripts and HTML injection?
If the answer to any of those is bad, I keep digging.
Copy-paste safe utility helpers
These small helpers are worth reusing.
export function setText(el: HTMLElement, value: unknown): void {
el.textContent = String(value ?? "");
}
export function setSafeLink(el: HTMLAnchorElement, value: string): void {
try {
const url = new URL(value, window.location.origin);
if (url.protocol === "http:" || url.protocol === "https:") {
el.href = url.toString();
return;
}
} catch {}
el.removeAttribute("href");
}
import DOMPurify from "dompurify";
export function setSanitizedHtml(el: HTMLElement, dirty: string): void {
el.innerHTML = DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ["p", "br", "b", "i", "strong", "em", "ul", "ol", "li", "a"],
ALLOWED_ATTR: ["href", "title"]
});
}
SharePoint XSS is rarely exotic. It’s usually just untrusted content ending up in the wrong sink because someone wanted to ship quickly. If you avoid HTML injection by default, sanitize the few places that truly need markup, and validate URLs aggressively, you eliminate most of the real-world bugs I see in SharePoint environments.