XSS Prevention
Preventing cross-site scripting vulnerabilities in your PRIV integration.
Overview
Cross-Site Scripting (XSS) is one of the most common web vulnerabilities. This guide covers how PRIV prevents XSS and best practices for your integration.
HTML Escaping
All user content is escaped before rendering to prevent script injection.
CSP Headers
Content Security Policy headers restrict script execution sources.
Types of XSS
Stored XSS
Malicious script is stored on the server and served to other users.
// VULNERABLE: Storing and displaying unescaped user input
const comment = await db.comments.create({
content: userInput, // Contains <script>alert('XSS')</script>
})
// Later, rendered without escaping
return <div dangerouslySetInnerHTML={{ __html: comment.content }} />Reflected XSS
Malicious script is reflected from URL parameters or form inputs.
// VULNERABLE: Reflecting URL parameter without escaping
const searchTerm = new URL(request.url).searchParams.get('q')
return `<p>Results for: ${searchTerm}</p>` // q=<script>evil()</script>DOM-based XSS
Malicious script is injected through client-side JavaScript.
// VULNERABLE: Inserting user data into DOM without escaping
const username = new URLSearchParams(location.search).get('user')
document.getElementById('greeting').innerHTML = `Welcome, ${username}!`HTML Escaping
Always escape user-controlled data before inserting into HTML:
/**
* Escapes HTML special characters to prevent XSS
* @param unsafe - User-controlled string that may contain malicious content
* @returns Safe string with HTML entities escaped
*/
function escapeHtml(unsafe: string | null | undefined): string {
if (!unsafe) return ''
return unsafe
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
// Usage
const safeContent = escapeHtml(userInput)
element.innerHTML = safeContent // SafeWhen to Escape
| Context | Escape Method | Example |
|---|---|---|
| HTML content | escapeHtml() | <div>${escapeHtml(text)}</div> |
| HTML attributes | escapeHtml() | <input value="${escapeHtml(value)}"> |
| JavaScript strings | JSON.stringify | const x = ${JSON.stringify(data)} |
| URLs | encodeURIComponent() | ?q=${encodeURIComponent(query)} |
| CSS | Avoid user input | Use CSS classes instead |
React-Specific Patterns
React automatically escapes content by default, but there are exceptions:
Safe by Default
// React escapes this automatically - SAFE
function SafeComponent({ userInput }: { userInput: string }) {
return <div>{userInput}</div>
}Dangerous Patterns to Avoid
// DANGEROUS: Bypasses React's escaping
function DangerousComponent({ html }: { html: string }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />
}
// If you MUST use dangerouslySetInnerHTML, sanitize first:
import DOMPurify from 'dompurify'
function SaferComponent({ html }: { html: string }) {
const sanitized = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a'],
ALLOWED_ATTR: ['href'],
})
return <div dangerouslySetInnerHTML={{ __html: sanitized }} />
}URL Sanitization
// DANGEROUS: User can inject javascript: URLs
function DangerousLink({ url }: { url: string }) {
return <a href={url}>Click here</a>
}
// SAFE: Validate URL protocol
function sanitizeUrl(url: string): string {
const trimmed = url.trim().toLowerCase()
if (trimmed.startsWith('javascript:') || trimmed.startsWith('data:')) {
return '#'
}
return url
}
function SafeLink({ url }: { url: string }) {
return <a href={sanitizeUrl(url)}>Click here</a>
}Content Security Policy
CSP provides an additional layer of XSS protection by restricting script sources.
Recommended CSP
// next.config.ts
const cspHeader = `
default-src 'self';
script-src 'self' 'unsafe-inline' https://cdn.priv.io;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.priv.io wss://realtime.priv.io;
frame-ancestors 'none';
form-action 'self';
base-uri 'self';
object-src 'none';
`.replace(/\n/g, '')
export default {
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Content-Security-Policy',
value: cspHeader,
},
],
},
]
},
}CSP Directives Explained
| Directive | Purpose |
|---|---|
default-src 'self' | Only load resources from same origin |
script-src | Allowed JavaScript sources |
style-src | Allowed CSS sources |
img-src | Allowed image sources |
connect-src | Allowed fetch/XHR targets |
frame-ancestors 'none' | Prevent clickjacking |
object-src 'none' | Block plugins (Flash, etc.) |
Nonce-based CSP (More Secure)
// middleware.ts
import { NextResponse } from 'next/server'
import crypto from 'crypto'
export function middleware(request: Request) {
const nonce = crypto.randomBytes(16).toString('base64')
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}';
style-src 'self' 'nonce-${nonce}';
`.replace(/\n/g, '')
const response = NextResponse.next()
response.headers.set('Content-Security-Policy', cspHeader)
response.headers.set('x-nonce', nonce)
return response
}// Using nonce in components
import { headers } from 'next/headers'
export default async function Page() {
const nonce = (await headers()).get('x-nonce') ?? ''
return (
<>
<script nonce={nonce} src="/analytics.js" />
</>
)
}PRIV SDK Security
The PRIV SDK implements XSS protections internally:
Event Properties
// SDK automatically sanitizes event properties
priv.track('user_action', {
input: '<script>alert("XSS")</script>', // Escaped before storage
url: 'javascript:alert("XSS")', // Validated and rejected
})Consent Banner
The compliance SDK uses safe DOM methods:
// Internal implementation (simplified)
class ComplianceBanner {
render() {
const container = document.createElement('div')
container.className = 'priv-banner' // Safe
const title = document.createElement('h2')
title.textContent = this.config.title // Safe - textContent escapes
// NOT using innerHTML with user content
container.appendChild(title)
}
}Testing for XSS
Manual Testing
Test these payloads in all user input fields:
<script>alert('XSS')</script>
<img src=x onerror=alert('XSS')>
<svg onload=alert('XSS')>
javascript:alert('XSS')
" onclick="alert('XSS')
' onfocus='alert(1)' autofocus='Automated Testing
// Using a testing library
import { describe, it, expect } from 'vitest'
import { escapeHtml } from '@/lib/security'
describe('XSS Prevention', () => {
const xssPayloads = [
'<script>alert("XSS")</script>',
'<img src=x onerror=alert(1)>',
'<svg onload=alert(1)>',
'"><script>alert(1)</script>',
"'-alert(1)-'",
]
it.each(xssPayloads)('should escape: %s', (payload) => {
const escaped = escapeHtml(payload)
expect(escaped).not.toContain('<script')
expect(escaped).not.toContain('onerror')
expect(escaped).not.toContain('onload')
})
it('should handle null and undefined', () => {
expect(escapeHtml(null)).toBe('')
expect(escapeHtml(undefined)).toBe('')
})
})Browser DevTools
Use the browser's security features:
// Enable CSP violation reporting
document.addEventListener('securitypolicyviolation', (e) => {
console.error('CSP violation:', {
blockedURI: e.blockedURI,
violatedDirective: e.violatedDirective,
originalPolicy: e.originalPolicy,
})
})Common Mistakes
Mistake 1: Escaping on Input
// WRONG: Escaping when storing
const escaped = escapeHtml(userInput)
await db.posts.create({ content: escaped })
// Later, double-escaping when displaying
return <div>{escapeHtml(post.content)}</div> // Shows < instead of <Correct approach: Store original, escape on output.
Mistake 2: Only Escaping Some Characters
// INCOMPLETE: Missing characters
function badEscape(str: string) {
return str.replace(/</g, '<').replace(/>/g, '>')
// Missing: &, ", '
}Mistake 3: Trusting "Internal" Data
// WRONG: Assuming API data is safe
const apiResponse = await fetch('/api/user')
const user = await apiResponse.json()
element.innerHTML = user.bio // API could return malicious contentMistake 4: Incomplete URL Validation
// INCOMPLETE: Doesn't catch all cases
function badUrlCheck(url: string) {
if (url.startsWith('javascript:')) return '#'
return url
}
// Bypassed by: jAvAsCrIpT:alert(1) or %6A%61%76%61script:alert(1)Security Helpers
PRIV provides security utilities you can use:
import {
escapeHtml,
sanitizeUrl,
validateInput,
} from '@priv/sdk/security'
// Escape HTML
const safe = escapeHtml('<script>alert(1)</script>')
// Returns: <script>alert(1)</script>
// Sanitize URLs
const safeUrl = sanitizeUrl('javascript:alert(1)')
// Returns: #
// Validate input against schema
const result = validateInput(userInput, {
type: 'string',
maxLength: 1000,
pattern: /^[a-zA-Z0-9\s]+$/,
})