Security Testing
Intermediate 20 min read Security Testing

XSS Testing

Cross-Site Scripting (XSS) lets an attacker run scripts in another user's browser with your application's trust. Learn the three types of XSS, how to test them safely in sandboxes, and the layered defenses — output encoding, sanitization and Content Security Policy — that stop them.

XSScross-site scriptingCSPoutput encodingCWE-79DOM XSSreflectedstored
Introduction

What is this?

Cross-Site Scripting (XSS) is a client-side injection flaw that lets an attacker inject executable scripts into pages other users view. Because the script runs in the victim's browser, it inherits the application's origin: it can read cookies, steal tokens, rewrite the page and act as the victim. XSS happens when untrusted data reaches the browser without being neutralized in the correct context.

Motivation

Why do you need it?

  • It appears in every OWASP Top 10 and is one of the most commonly reported web vulnerabilities.
  • A single stored XSS on a page admins visit can lead to full account takeover across the whole team.
  • Modern single-page apps remain vulnerable when frameworks render user data without escaping.
  • Defense is cheap: output encoding and a strict Content Security Policy stop most payloads.
  • It is easy to practice safely in authorized sandboxes, so every developer can learn it hands-on.
Think about it

Mental model

Imagine

A letter delivered to your mailbox stamped with your bank's logo

Think of

An XSS payload

The mail arrives through a channel you trust, so you open it and follow its instructions. XSS delivers attacker-controlled code through your trusted application, so the browser executes it with the full privileges of that page.

Mechanics

How it works

  • The application accepts user input and later reflects it (reflected XSS) or stores it for others (stored XSS) without proper encoding.
  • The attacker crafts a payload such as a <script> block or an HTML event handler like onerror.
  • The victim's browser parses the injected markup as executable code in the application's origin.
  • The script runs and can read cookies, call APIs, exfiltrate data or mount further attacks such as CSRF.
  • Prevention: encode output for the right context, sanitize with a library when HTML is required, and set a strict CSP as defense-in-depth.

How a stored XSS payload reaches every visitor.

User submits input
App stores or echoes it
Browser parses the page
Malicious code executes
Data exfiltrated
Reference

Key commands

curl -sI https://sandbox.target.local/ | grep -i content-security-policy

Check whether a Content-Security-Policy header exists before you test further.

Content-Security-Policy: default-src 'self'; script-src 'self'
curl -s "https://sandbox.target.local/search?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E" | grep -i script

Educational check: see whether the encoded payload is echoed back unescaped in the response body.

curl -s "https://sandbox.target.local/profile?name=%3Cimg%20src=x%20onerror=alert(1)%3E"

Educational event-handler variant that does not rely on a <script> tag being allowed.

grep -rn "dangerouslySetInnerHTML" src/

Audit the codebase for React sinks that bypass automatic escaping.

grep -rnE "innerHTML|document.write|eval\(" src/ public/

Find legacy DOM sinks that can become DOM-XSS injection points.

Hands-on

Examples

Escaping output and a strict Content Security Policy

Escaping output and a strict Content Security Policy
import React from "react"
// VULNERABLE pattern — never render user HTML with dangerouslySetInnerHTML:
// <div dangerouslySetInnerHTML={{ __html: userPost }} />
// SAFE: let the framework escape text automatically in every context.
function Post({ author, body }) {
return (
<article>
<h2>{author}</h2>
<p>{body}</p>
</article>
)
}
// SAFE: a strict CSP on every response (Express example).
app.use((req, res, next) => {
res.setHeader(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'"
)
next()
})

React auto-escapes {author} and {body}, so text and attribute injection are neutralized without any extra work — the vulnerability appears only when developers bypass escaping with dangerouslySetInnerHTML or similar sinks. The CSP then acts as a second layer: even if a payload survives encoding, script-src 'self' and object-src 'none' stop remote scripts and plugins from executing. The corresponding educational payload — a reflected <script>alert(document.cookie)</script> echo in a search box — is covered in the WebGoat lab, which is the only place you should run it.

Real world

In production

  • A strict CSP is deployed with every frontend bundle and validated by a header test in CI.
  • Framework auto-escaping is the default, and dangerouslySetInnerHTML is banned by a lint rule.
  • HTML sanitization with DOMPurify is used only where rich text is genuinely required.
  • Stored-XSS surfaces — comments, profiles, admin panels — are covered by automated DAST checks.
Guidelines

Best practices

  • Encode output for the correct context: HTML text, HTML attributes, URLs, CSS and JavaScript each need their own encoder.
  • Default to framework auto-escaping and never disable it without a documented, reviewed exception.
  • Set Content-Security-Policy with script-src 'self' and no unsafe-inline or unsafe-eval.
  • Sanitize with a maintained library like DOMPurify when rich HTML input is required.
  • Test with realistic payloads in an authorized sandbox and confirm the fix in a real browser.
Watch out

Common mistakes

  • Testing with alert(1), which frameworks and WAFs special-case, instead of realistic payloads.
  • Confusing escaping with sanitization — escaping alone does not make attacker HTML safe to inject as markup.
  • Testing only reflected XSS and missing stored XSS in comments and profile fields.
  • Assuming CSP alone is a complete fix instead of treating it as defense-in-depth behind encoding.
Root cause analysis

Common issues

Error
The payload appears as text but does not execute
Root cause

The injection point sits in a context the payload does not match, such as inside a JavaScript string.

Fix

Adjust the payload to the surrounding context (attribute, script or URL) instead of assuming HTML context.

Error
CSP blocked the payload
Root cause

A content security policy is present and doing its job.

Fix

Confirm the script-src and object-src directives, and test the encoding separately from the CSP.

Error
Payload works in curl but not in a browser
Root cause

URL encoding differences or the browser blocking inline handlers.

Fix

Use proper URL encoding and test event handlers in a real browser, not just the raw response.

When things break

Debugging tips

  • Open the browser devtools console to see exactly what the server returned around the injection point.
  • Inspect the raw HTTP response to determine whether the sink is reflected, stored or DOM-based.
  • Validate payload syntax in a sandbox such as PortSwigger Academy before testing a real application.
Interviews

Interview checkpoint

Practice

Hands-on lab

Reflected & Stored XSS in WebGoat
40 min · intermediate
In progress
Objectives
  • Find a reflected XSS sink and confirm it with a safe payload
  • Find a stored XSS sink that persists for a second user
  • Demonstrate that framework escaping and a CSP neutralize both
  • Document the CWE mapping and the defensive fix

Reflected & Stored XSS in WebGoat

40 min · intermediate

0/5 steps
0%
sandbox · Linux sandbox
$
Try these commands
Test yourself

Quick quiz

0/3
1

Which control is the most direct defense-in-depth layer for preventing XSS script execution?

2

React automatically escapes text interpolation, so {body} in a <p> element is safe by default.

3

A payload executes on /search but not on /profile, even though both echo the query string. What is the most likely explanation?

Quick reference

Cheat sheet

XSS defenses
{value}Framework auto-escaping (default)
dangerouslySetInnerHTMLBanned by lint — requires sanitization
DOMPurify.sanitize()Allow-listed HTML sanitization
script-src 'self'CSP: no remote or inline scripts
object-src 'none'CSP: block plugins/flash
innerHTML / document.writeDOM sinks to audit in legacy code
Sandbox payload testing (WebGoat/Academy only)
<script>alert(1)</script>Basic reflected marker
<img src=x onerror=alert(1)>Works without script tags
'-alert(1)-'Breakout in a JS string context
<svg onload=alert(1)>SVG-based execution vector
Go deeper

Official documentation