PRIV ProtocolPRIV Docs
Security

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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;')
}

// Usage
const safeContent = escapeHtml(userInput)
element.innerHTML = safeContent // Safe

When to Escape

ContextEscape MethodExample
HTML contentescapeHtml()<div>${escapeHtml(text)}</div>
HTML attributesescapeHtml()<input value="${escapeHtml(value)}">
JavaScript stringsJSON.stringifyconst x = ${JSON.stringify(data)}
URLsencodeURIComponent()?q=${encodeURIComponent(query)}
CSSAvoid user inputUse 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.

// 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

DirectivePurpose
default-src 'self'Only load resources from same origin
script-srcAllowed JavaScript sources
style-srcAllowed CSS sources
img-srcAllowed image sources
connect-srcAllowed 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
})

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 &lt; 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, '&lt;').replace(/>/g, '&gt;')
  // 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 content

Mistake 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: &lt;script&gt;alert(1)&lt;/script&gt;

// 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]+$/,
})

Resources