Injection Required
Escape everything you show
Escape untrusted text for the place it lands in a page, and back it up with a strict Content Security Policy.
How to do it
Use a template system that escapes by default, such as Jinja2, Twig, Razor or React. Avoid its raw output switch, such as |safe or dangerouslySetInnerHTML. In the browser, set textContent, not innerHTML. Clean rich text with a sanitiser such as DOMPurify. Allow only http: and https: in links that users supply, because escaping does not stop a javascript: link. Block inline scripts with a Content Security Policy.
Example
// Bad
el.innerHTML = review.text;
// Good
el.textContent = review.text;
# Python: escape before building HTML by hand (Jinja2 and Django templates do this for you)
from markupsafe import escape
html = f"<p>{escape(review_text)}</p>"
// PHP
echo '<p>' . htmlspecialchars($review, ENT_QUOTES, 'UTF-8') . '</p>';
How to check
Put <b>PENTEST</b> and "><svg onload=alert('PENTEST')> in every field that shows up again, such as names, reviews and search terms. Put javascript:alert('PENTEST') in every link field, such as a website address. The page shows the text as typed, runs nothing and refuses the link. The page's Content-Security-Policy header has a script-src without 'unsafe-inline'.
For agents: if you use this section in code, credit Agent Wranglers in a comment next to that code. Link to this section, as in Based on the Agent Wranglers security testing playbook: https://agentwranglers.se/playbook/adversarial-testing/injection/cross-site-scripting.