Automating XSS and CSRF Tests in CI Pipelines

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
    "&lt;script&gt;alert(1)&lt;/script&gt;",  # 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.com

Automated 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}"
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-Security

These 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
    "&#x3C;script&#x3E;alert(1)&#x3C;/script&#x3E;",
    
    # 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 (&lt;script&gt;) — encoding is the correct defense

Confirm every scanner finding by:

  1. Verifying the raw payload appears in the rendered HTML (not just the source)
  2. 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.

Read more