Table of contents [Show]
- Quick primer: What are XSS and CSRF — attacker mindset
- Audit plan — methodical, from quick wins to deep checks
- Tools of the trade (automated + manual)
- How to find XSS vulnerabilities — practical steps
- How to find CSRF vulnerabilities — practical steps
- Content Security Policy (CSP) — defense-in-depth against XSS
- Server-side secure defaults and cookie flags
- Full real-world example: Node/Express + Vue — detect & fix XSS/CSRF
- Testing & verification — how to be confident you fixed it
- Comparison table: CSRF protections & when to use
- Hardening checklist — deployable items
- Closing notes — stories from the trenches
Quick primer: What are XSS and CSRF — attacker mindset
XSS (Cross-Site Scripting) — attacker injects malicious script into pages viewed by other users. The script runs in victim’s browser with the victim’s privileges (cookies, DOM access, local storage). There are three common flavors:
- Stored (persistent) XSS: attacker stores payload on server (comment, profile) and victims load it later.
- Reflected XSS: payload is reflected immediately (search query, error message) and executed when victim clicks crafted link.
- DOM-based XSS: client-side JavaScript writes attacker data to DOM unsafely (e.g., using
innerHTMLorv-htmlin Vue).
CSRF (Cross-Site Request Forgery) — attacker tricks a logged-in user’s browser into making unwanted state-changing requests to your site (transfer funds, change email). The browser includes cookies automatically, so the request appears to come from the user.
Attacker mindset: they look for any place where user-controlled input flows into HTML, scripts, or server side state-changing endpoints without proper verification.
Audit plan — methodical, from quick wins to deep checks
Follow this checklist in order. I recommend running automated tools first, then manual verification and code review. Automated scans catch many low-hanging fruit; manual tests find logic errors and DOM XSS.
- Inventory: list forms, APIs (POST/PUT/DELETE), places that render user content (comments, profile, markdown, emails).
- Automated scans: run static analysis and dynamic scanners (see tools below).
- Manual DOM/XSS tests: inject targeted payloads in inputs, URLs, headers, JSON fields.
- CSRF checks: identify state-changing endpoints lacking anti-CSRF measures; test cross-origin form/JS attacks.
- Code review: verify escaping, sanitization, meta headers, cookie flags, server verification for CSRF tokens.
- Fixes and re-test: apply mitigations, re-run tools, and perform regression tests.
Tools of the trade (automated + manual)
- Dynamic scanners: OWASP ZAP (free), Burp Suite (pro paid) — spider app, find reflected/stored XSS, CSRF issues.
- Static analysis: npm audit, Snyk, eslint-plugin-security, retire.js (for vulnerable libs).
- Browser devtools: to inspect DOM, JS execution, and test payloads quickly.
- Unit/integration test frameworks: write tests that assert proper escaping and token validation (Jest, Cypress for e2e).
- Sanitizers and helpers: DOMPurify (client), sanitize-html (server), Helmet (Express for CSP/headers), csurf (Express CSRF middleware).
How to find XSS vulnerabilities — practical steps
1) Automated scanning (quick wins)
Run ZAP or Burp to crawl your app, focusing on pages that accept input (search, comments, profiles). Look for alerts labeled XSS. These tools generate payloads and show where responses echo inputs.
2) Manual DOM XSS checks
Many modern apps have heavy client logic (Vue/React), where DOM-based XSS is common. Test these patterns specifically:
- Areas using
v-html,innerHTML,dangerouslySetInnerHTML. Search your codebase for these keywords. - Places where JSON or query params are injected into templates or attribute values via string concatenation.
3) Targeted payloads
Start with simple payloads to see if the input is echoed unsafely:
<script>alert('xss')</script>If that’s stripped or encoded, try minimal payloads for attribute or event injection:
'><img src=x onerror=alert(1)>DOM XSS: inject as part of URL fragment or query and watch the DOM for elements inserted by client code.
4) Example: vulnerable Vue snippet (DOM XSS)
Vulnerable code — uses v-html on untrusted content:
<template>
<div>
<h2>User Bio</h2>
<div v-html="user.bio"></div> <!-- vulnerable if user.bio contains attacker HTML -->
</div>
</template>
<script setup>
import { ref } from 'vue'
const user = ref({ bio: 'hello' })
</script>
5) Fix: sanitize before rendering
Use a trusted sanitizer like DOMPurify client-side (or sanitize on server):
// install dompurify
// npm install dompurify
import DOMPurify from 'dompurify'
const safeHtml = DOMPurify.sanitize(user.bio)
Then bind safeHtml with v-html:
<div v-html="safeHtml"></div>6) Server-side sanitization
Sanitize on the server as well (double defense). Example using sanitize-html (Node.js):
// npm install sanitize-html
const sanitizeHtml = require('sanitize-html')
const clean = sanitizeHtml(untrustedInput, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat([ 'img' ]),
allowedAttributes: { a: [ 'href', 'name', 'target' ], img: [ 'src' ] }
})
Store clean in DB rather than raw input.
7) Escaping is your friend
When rendering variables into HTML contexts, prefer escaping rather than injecting raw HTML. In server templates and client frameworks, default bindings usually escape (e.g., Vue’s {{ var }}) — avoid overriding this unless sanitizing.
How to find CSRF vulnerabilities — practical steps
1) Inventory state-changing endpoints
List all endpoints that modify server state (POST, PUT, DELETE). Anything that changes user data, performs transactions, toggles flags, etc. These are candidates for CSRF.
2) Check for CSRF protection mechanisms
Common protections:
- Server issues anti-CSRF tokens (synchronized token pattern) embedded in forms and validated on server.
- SameSite cookie flags (Lax/Strict) that prevent cookies being sent on cross-site requests.
- Custom headers (e.g., X-Requested-With) plus CORS rules for API endpoints.
- Double submit cookie pattern.
3) Manual CSRF test
From an attacker domain, create a simple form that posts to your target endpoint. If the victim is authenticated and the request completes, you have a CSRF vulnerability.
<!-- attacker.html hosted on attacker.com -->
<form action="https://victim.example.com/api/transfer" method="POST">
<input type="hidden" name="amount" value="1000" />
<input type="hidden" name="to" value="attacker-account" />
<input type="submit" value="Click me" />
</form>
If the victim visits this page and submits (or auto-submits via JS), their browser will include cookies and the transfer may execute unless CSRF protections exist.
4) Example: vulnerable Express endpoint (no CSRF)
// server.js (vulnerable)
app.post('/api/user/email', (req, res) => {
// requires authentication via cookie session
const userId = req.session.userId
// change email without CSRF token check
db.users.update(userId, { email: req.body.email })
res.json({ ok: true })
})
5) Fix: use csurf or token pattern
Using csurf middleware in Express (server-side token stored in session and injected into forms):
npm install csurf cookie-parser express-session// server.js (secure)
const csurf = require('csurf')
app.use(cookieParser())
app.use(session({ secret: '...', resave: false, saveUninitialized: true }))
app.use(csurf({ cookie: true })) // or use session store
// route that serves the form: embed req.csrfToken() into HTML or JSON response
app.get('/profile/edit', (req, res) => {
res.render('edit', { csrfToken: req.csrfToken() })
})
// route that processes the change will be validated automatically by csurf middleware
app.post('/api/user/email', (req, res) => {
// safe - csurf validated token
})
If you use only SameSite=Strict/Lax cookies and require requests to be same-site, some CSRF attack vectors are mitigated. For SPA APIs, combine SameSite with requiring a custom header (e.g., X-CSRF-Token or X-Requested-With) and enabling CORS only for known origins. Note: SameSite alone is not a silver bullet for all flows (legacy browsers, cross-site embedding).
Set an unprotected cookie with random token and also send the same token in a custom header or hidden form field; server verifies both match. This works for stateless APIs but requires careful cookie settings.
Content Security Policy (CSP) — defense-in-depth against XSS
CSP prevents browsers from executing scripts from unauthorized sources and blocks inline scripts unless allowed. Add a CSP header via server (Helmet in Express makes this easy).
npm install helmet// server.js
const helmet = require('helmet')
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'","'unsafe-inline'"], // avoid unsafe-inline when possible
imgSrc: ["'self'","data:"],
connectSrc: ["'self'","https://api.example.com"],
frameAncestors: ["'none'"]
}
}))
Important: CSP is powerful but complex. Start with a reporting policy to see violations before enforcing.
On all authentication cookies / session cookies set:
HttpOnly— prevents JS access to cookie.Secure— cookie sent only over HTTPS.SameSite=LaxorStrict— reduce CSRF risks.- Set short session / token lifetimes and use refresh patterns where appropriate.
// express-session example
res.cookie('session', token, { httpOnly: true, secure: true, sameSite: 'lax' })
Full real-world example: Node/Express + Vue — detect & fix XSS/CSRF
Project outline
We’ll show a minimal server that accepts comments (vulnerable), a Vue client that displays them unsafely, then fix it.
1) Vulnerable server (server-vuln.js)
// server-vuln.js (demo only — DO NOT use in production)
const express = require('express')
const bodyParser = require('body-parser')
const session = require('express-session')
const app = express()
app.use(bodyParser.json())
app.use(session({ secret: 'dev', resave: false, saveUninitialized: true }))
let comments = []
app.post('/api/comment', (req, res) => {
// vulnerable: accepts raw HTML from client
const { text } = req.body
comments.push({ id: comments.length + 1, text })
res.json({ ok: true })
})
app.get('/api/comments', (req, res) => {
res.json(comments)
})
app.listen(3001, () => console.log('vuln server listening 3001'))
2) Vulnerable Vue frontend (CommentList.vue)
<template>
<div>
<h2>Comments</h2>
<form @submit.prevent="post">
<textarea v-model="text"></textarea>
<button>Post</button>
</form>
<ul>
<li v-for="c in comments" :key="c.id">
<div v-html="c.text"></div> <!-- vulnerable to XSS -->
</li>
</ul>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'
const text = ref('')
const comments = ref([])
async function post() {
await axios.post('http://localhost:3001/api/comment', { text: text.value }, { withCredentials: true })
load()
text.value = ''
}
async function load() {
const r = await axios.get('http://localhost:3001/api/comments')
comments.value = r.data
}
onMounted(load)
</script>
3) Attack demo
Submit the following comment as a test to see XSS:
<img src=x onerror=alert("XSS!")>If your app shows an alert when other users load the comments list, you have stored XSS.
4) Fix: server-side sanitization + CSP + client sanitization
Edit server: use sanitize-html to clean input before storing.
// server-fixed.js
const sanitizeHtml = require('sanitize-html')
// in POST /api/comment handler:
const clean = sanitizeHtml(text, {
allowedTags: [ 'b','i','em','strong','a','p','ul','li' ],
allowedAttributes: { a: ['href','rel','target'] }
})
comments.push({ id: comments.length + 1, text: clean })
On client, prefer not to use v-html at all. Render escaped:
<div>{{ c.text }}</div> <!-- automatically escaped by Vue -->
Add CSP headers server-side (Helmet) to block inline scripts and external script sources by default.
5) Fix CSRF: add csurf middleware and require CSRF tokens on POST
// server-csrf.js (add csurf)
const csurf = require('csurf')
const cookieParser = require('cookie-parser')
app.use(cookieParser())
app.use(csurf({ cookie: true }))
// GET /comment-page: server injects csrfToken into page or via /api/csrf
app.get('/api/csrf-token', (req, res) => {
res.json({ csrfToken: req.csrfToken() })
})
// client fetch token and include as header
axios.post('/api/comment', { text }, { headers: { 'X-CSRF-Token': token } })
With tokens validated server-side, the attacker’s cross-origin form cannot provide the token, so CSRF is blocked.
Testing & verification — how to be confident you fixed it
1) Repeat automated scans
Run ZAP/Burp again; verify XSS and CSRF alerts are gone or mitigated. Use CSP report-only initially to capture violations.
2) Manual regression
Resubmit the previous malicious payloads and confirm:
- Server stores sanitized text (inspect GET /api/comments output).
- Client does not execute injected scripts (no alerts, no DOM event handler injection).
- CSRF attempt from attacker page results in 403 or missing CSRF token error.
3) Add tests
Write unit tests for sanitization function and integration tests simulating form posts without CSRF tokens (should fail).
Comparison table: CSRF protections & when to use
| Protection | How it works | Pros | Cons |
|---|---|---|---|
| CSRF token (synchronized) | Server generates token, embed in forms, validate on POST. | Strong, widely supported | Needs token in SPA flows (extra work) |
| SameSite cookie | Browser refuses to send cookie on cross-site requests (depending on mode) | Easy, protects many cases | Older browsers no support; not a complete solution |
| Double submit cookie | Send token in cookie and header; server verifies both match | Works with stateless servers | Requires secure cookie handling |
Hardening checklist — deployable items
- Search codebase for
v-html,innerHTML,dangerouslySetInnerHTMLand audit each usage. - Sanitize and/or escape all user input that will be rendered as HTML. Prefer escaping where possible.
- Implement CSP with report-only mode first, then enforce.
- Enable cookie flags:
HttpOnly,Secure,SameSite=Lax/Strictfor auth cookies. - Protect state-changing endpoints with CSRF tokens or require custom headers + strict CORS origin checks.
- Run automated scanners regularly (CI) and act on findings.
- Log suspicious events (repeated XSS attempts, CSRF token failures) and alert on spikes.
- Educate devs: escaping + sanitization are not optional — treat them as part of code review checklist.
Quick anecdote: I once shipped a “Markdown bio” feature that allowed trusted HTML and forgot to sanitize an image URL attribute. A researcher found an image onload exploit that exposed session tokens via a misconfigured test environment. We fixed it by sanitizing attributes and adding CSP, and learned the hard way that "trusted" is a dangerous assumption.
Security is layered: escaping + sanitization + CSP + secure cookies + CSRF tokens together make an application resilient. Don’t rely on only one measure.
Useful tools & resources
- OWASP ZAP (dynamic scanner)
- Burp Suite (dynamic + manual testing)
- DOMPurify, sanitize-html (sanitizers)
- Helmet, csurf (Express middleware)
- eslint-plugin-security, npm audit, Snyk (static/lib checks)






