Automating XSS and CSRF Tests in CI Pipelines
Cross-Site Scripting (XSS) and Cross-Site Request Forgery (CSRF) are among the most exploited vulnerabilities in web applications. Both are automatable — you can write tests that run on every PR and catch regressions before they reach production. This guide covers how to build an automated XSS and CSRF test suite that fits into CI without generating noise.
Key Takeaways
XSS requires a browser to fully detect. Server-side checks miss DOM-based XSS. Use Playwright or Selenium for XSS tests that actually execute JavaScript.
CSRF testing is simpler — it's about checking token presence and validation. A CSRF vulnerability means a state-changing request succeeds without a valid token.
Content Security Policy is your XSS defense layer — test that it's present and correct. An overly permissive CSP (or missing CSP) negates your input sanitization.
Test XSS in the output context, not just the input. The same string <script> is dangerous in HTML context but harmless in a JSON response. Context matters.
Automation catches regressions; it doesn't replace pen testing. New XSS vectors appear constantly. Scheduled pen tests catch what automated suites miss.
Understanding XSS Attack Types
Before automating, understand what you're testing for. XSS comes in three variants:
Reflected XSS — Payload is in the request (URL parameter, form field), reflected back in the response without sanitization.
https://app.example.com/search?q=<script>alert(1)</script>Stored XSS — Payload is persisted in the database (comments, user profiles, messages) and executed when the page loads for other users.
DOM-based XSS — JavaScript reads from document.location, document.cookie, or other DOM sources and writes to the DOM without sanitization. Server-side scanners miss this entirely.
Automated XSS Testing
Server-Side Reflection Tests
The simplest XSS test verifies that user-controlled input is not reflected in the response without encoding:
# test_xss_reflection.py
import requests, pytest
BASE_URL = "https://staging.example.com"
XSS_PAYLOADS = [
"<script>alert(1)</script>",
"javascript:alert(1)",
"<img src=x onerror=alert(1)>",
"'\"><script>alert(1)</script>",
"<svg onload=alert(1)>",
"%-3Cscript%3Ealert(1)%3C/script%3E", # URL-encoded
"<script>alert(1)</script>", # HTML entity (should be safe)
]
@pytest.mark.parametrize("payload", XSS_PAYLOADS)
def test_search_does_not_reflect_xss(payload):
resp = requests.get(f"{BASE_URL}/search", params={"q": payload})
assert resp.status_code == 200
# Raw payload must not appear in response
if "<script>" in payload.lower():
assert "<script>" not in resp.text.lower(), \
f"VULNERABLE: XSS payload reflected: {payload}"
if "onerror=" in payload.lower():
assert "onerror=" not in resp.text.lower(), \
f"VULNERABLE: event handler reflected: {payload}"Browser-Based XSS Detection
Server-side reflection tests miss DOM-based XSS. For real browser execution, use Playwright:
# test_xss_browser.py
import pytest
from playwright.sync_api import sync_playwright
XSS_PAYLOAD = "<img src=x onerror=window.__xss_triggered=true>"
def test_comment_field_xss():
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
# Post a comment with XSS payload
page.goto("https://staging.example.com/login")
page.fill("#email", "testuser@example.com")
page.fill("#password", "TestPassword123")
page.click("#login-btn")
page.wait_for_url("**/dashboard")
page.goto("https://staging.example.com/articles/1")
page.fill("#comment-text", XSS_PAYLOAD)
page.click("#submit-comment")
page.wait_for_selector(".comment-posted")
# Reload the page to see the stored comment
page.reload()
# Check if XSS executed
xss_fired = page.evaluate("() => window.__xss_triggered || false")
assert not xss_fired, "VULNERABLE: XSS payload executed in browser"
browser.close()DOM XSS Detection
def test_search_dom_xss():
"""Test that the client-side search handler sanitizes URL parameters."""
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
# Inject XSS via URL hash (common DOM XSS vector)
page.on("dialog", lambda dialog: dialog.dismiss()) # catch alert()
alert_fired = []
def handle_dialog(dialog):
alert_fired.append(dialog.message)
dialog.dismiss()
page.on("dialog", handle_dialog)
page.goto('https://staging.example.com/search#<img src=x onerror=alert(1)>')
page.wait_for_timeout(2000)
assert not alert_fired, f"VULNERABLE: DOM XSS via URL hash, alert: {alert_fired}"
browser.close()Testing Content Security Policy
A correct CSP prevents XSS from executing even if a payload bypasses input sanitization:
import requests
def test_csp_header_present():
resp = requests.get("https://staging.example.com")
assert "Content-Security-Policy" in resp.headers, \
"FAIL: Content-Security-Policy header missing"
def test_csp_no_unsafe_inline():
resp = requests.get("https://staging.example.com")
csp = resp.headers.get("Content-Security-Policy", "")
assert "unsafe-inline" not in csp, \
f"FAIL: CSP contains unsafe-inline which allows XSS: {csp}"
def test_csp_no_wildcard_source():
resp = requests.get("https://staging.example.com")
csp = resp.headers.get("Content-Security-Policy", "")
# Check script-src specifically
for directive in csp.split(";"):
if "script-src" in directive:
assert "*" not in directive, \
f"FAIL: CSP script-src allows wildcard: {directive}"CI Integration for XSS Tests
# .github/workflows/xss-tests.yml
name: XSS Security Tests
on:
push:
branches: [main, staging]
jobs:
xss-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install pytest requests playwright
- run: playwright install chromium --with-deps
- run: pytest tests/security/test_xss*.py -v
env:
BASE_URL: https://staging.example.comAutomated CSRF Testing
CSRF attacks trick an authenticated user's browser into making a state-changing request to your application — using the browser's existing session cookie. Modern defenses include CSRF tokens, SameSite cookies, and origin header verification.
Testing CSRF Token Presence
import requests, re
def get_csrf_token(session, url):
"""Extract CSRF token from a form page."""
resp = session.get(url)
match = re.search(r'<input[^>]+name="csrf_token"[^>]+value="([^"]+)"', resp.text)
return match.group(1) if match else None
def test_csrf_token_present_on_form():
session = requests.Session()
# Authenticate
session.post("https://staging.example.com/login", data={
"email": "testuser@example.com",
"password": "TestPassword123"
})
token = get_csrf_token(session, "https://staging.example.com/profile/edit")
assert token is not None, "FAIL: CSRF token missing from edit form"
assert len(token) >= 32, f"FAIL: CSRF token too short ({len(token)} chars)"Testing CSRF Token Validation
def test_request_without_csrf_token_rejected():
"""A state-changing request without a CSRF token must be rejected."""
session = requests.Session()
session.post("https://staging.example.com/login", data={
"email": "testuser@example.com",
"password": "TestPassword123"
})
# Attempt state-changing request without CSRF token
resp = session.post("https://staging.example.com/profile/update", data={
"display_name": "Hacker"
# No csrf_token
})
assert resp.status_code in [400, 403], \
f"VULNERABLE: request without CSRF token accepted, got {resp.status_code}"
def test_wrong_csrf_token_rejected():
"""A CSRF token from a different session must be rejected."""
# Session A — legitimate user
session_a = requests.Session()
session_a.post("https://staging.example.com/login", data={
"email": "usera@example.com", "password": "Password123"
})
token_a = get_csrf_token(session_a, "https://staging.example.com/profile/edit")
# Session B — attacker
session_b = requests.Session()
session_b.post("https://staging.example.com/login", data={
"email": "userb@example.com", "password": "Password456"
})
# Session B uses session A's token
resp = session_b.post("https://staging.example.com/profile/update", data={
"display_name": "Hacker",
"csrf_token": token_a # wrong session's token
})
assert resp.status_code in [400, 403], \
f"VULNERABLE: cross-session CSRF token accepted, got {resp.status_code}"Testing SameSite Cookie Attribute
def test_session_cookie_samesite():
resp = requests.get(
"https://staging.example.com/login",
allow_redirects=False
)
# After login, check the session cookie flags
resp2 = requests.post("https://staging.example.com/login", data={
"email": "testuser@example.com",
"password": "TestPassword123"
}, allow_redirects=False)
set_cookie = resp2.headers.get("Set-Cookie", "")
assert "SameSite=Strict" in set_cookie or "SameSite=Lax" in set_cookie, \
f"FAIL: session cookie missing SameSite attribute: {set_cookie}"
def test_session_cookie_httponly():
resp = requests.post("https://staging.example.com/login", data={
"email": "testuser@example.com",
"password": "TestPassword123"
}, allow_redirects=False)
set_cookie = resp.headers.get("Set-Cookie", "")
assert "HttpOnly" in set_cookie, \
f"FAIL: session cookie missing HttpOnly flag: {set_cookie}"
def test_session_cookie_secure():
resp = requests.post("https://staging.example.com/login", data={
"email": "testuser@example.com",
"password": "TestPassword123"
}, allow_redirects=False)
set_cookie = resp.headers.get("Set-Cookie", "")
assert "Secure" in set_cookie, \
f"FAIL: session cookie missing Secure flag (allows non-HTTPS transmission): {set_cookie}"Testing Origin Header Validation
A defense-in-depth approach validates the Origin header for cross-site requests:
def test_cross_origin_post_rejected():
"""Requests from unauthorized origins should be rejected for state-changing operations."""
session = requests.Session()
session.post("https://staging.example.com/login", data={
"email": "testuser@example.com",
"password": "TestPassword123"
})
resp = session.post(
"https://staging.example.com/profile/update",
headers={"Origin": "https://malicious.example.com"},
data={"display_name": "Hacked"}
)
assert resp.status_code in [400, 403], \
f"VULNERABLE: cross-origin request accepted, got {resp.status_code}"Building a CI Security Test Suite
Structure your XSS and CSRF tests to run automatically:
tests/
security/
test_xss_reflection.py # Input/output reflection tests
test_xss_browser.py # Browser execution tests (Playwright)
test_xss_csp.py # Content Security Policy validation
test_csrf_tokens.py # Token presence and validation
test_csrf_cookies.py # Cookie security flags
test_security_headers.py # X-Frame-Options, HSTS, etc.Security test markers in pytest:
# conftest.py
import pytest
def pytest_configure(config):
config.addinivalue_line("markers", "security: mark test as security test")
config.addinivalue_line("markers", "slow: mark test as slow (browser-based)")Run fast tests on every push, browser tests on merge to main:
# Fast security tests — every push
- run: pytest tests/security/ -v -m "security and not slow"
# Browser security tests — on merge
- run: pytest tests/security/ -v -m "slow"End-to-End XSS and CSRF Tests with HelpMeTest
HelpMeTest runs end-to-end security tests in a real browser, letting you verify XSS and CSRF defenses as part of your standard test suite:
*** Test Cases ***
Stored XSS Does Not Execute in Comments
As AuthenticatedUser
Go To https://app.example.com/articles/1
Input Text id=comment-text <script>alert('xss')</script>
Click Button id=submit-comment
Wait Until Page Contains Comment posted
Reload Page
Page Should Not Contain <script>alert
# Verify no alert dialog appeared
No Alert Dialog Should Have Appeared
CSRF Token Present on Profile Edit Form
As AuthenticatedUser
Go To https://app.example.com/profile/edit
Page Should Contain Element input[name="csrf_token"]
${token_value}= Get Element Attribute input[name="csrf_token"] value
Should Not Be Empty ${token_value}
Length Should Be At Least ${token_value} 32
Security Headers Present
Go To https://app.example.com
Response Header Should Be Present Content-Security-Policy
Response Header Should Be Present X-Frame-Options
Response Header Should Be Present Strict-Transport-SecurityThese tests run on every deployment, giving you continuous verification that your XSS and CSRF defenses are active.
XSS Payload Reference
Use these payloads in your test suites. Verify that none execute in a browser:
XSS_PAYLOADS = [
# Basic script injection
"<script>alert(1)</script>",
"<SCRIPT>alert(1)</SCRIPT>",
# Event handlers
"<img src=x onerror=alert(1)>",
"<svg onload=alert(1)>",
"<body onload=alert(1)>",
# JavaScript URLs
"javascript:alert(1)",
"jaVaScRiPt:alert(1)",
# HTML entity bypass
"<script>alert(1)</script>",
# Null byte injection
"<scr\x00ipt>alert(1)</scr\x00ipt>",
# Template injection that can lead to XSS
"{{7*7}}", # Jinja2/Twig SSTI
"${7*7}", # EL injection
]False Positive Management
XSS scanners generate false positives. Avoid acting on:
- Payloads reflected inside
<script>blocks as string literals (safe if properly quoted) - Payloads reflected in JSON responses with
Content-Type: application/json(browsers don't parse HTML there) - HTML-encoded payloads (
<script>) — encoding is the correct defense
Confirm every scanner finding by:
- Verifying the raw payload appears in the rendered HTML (not just the source)
- Opening the URL in a browser and checking if any JavaScript executes
Conclusion
XSS and CSRF testing is automatable — and the investment pays off quickly. A handful of Playwright tests that verify XSS payloads don't execute, combined with CSRF token validation checks, run in seconds and catch the most common vulnerabilities. Add them to your CI pipeline today, run them on every push, and you'll catch regressions before your security scanner does.
The goal isn't to replace a pen test. It's to ensure that the baseline defenses you built last month still work today.