Safeguard
AppSec

How to Fix Cross-Site Scripting Vulnerabilities in Java (With Examples)

A practical walkthrough of how to fix cross site scripting vulnerabilities in Java: context-aware output encoding, template auto-escaping, and where servlet code goes wrong.

Aisha Rahman
Security Analyst
6 min read

The way to fix a cross-site scripting vulnerability in Java is to apply context-aware output encoding at the point where untrusted data is written into a response, so the browser renders it as text rather than as HTML, JavaScript, or a URL. Input validation helps and a Content-Security-Policy header helps, but neither is the fix. The fix is encoding output for the exact context it lands in, and in Java that usually means letting a mature template engine do it, or calling the OWASP Java Encoder when you are building markup by hand.

This post walks through a concrete Java vulnerability, then the corrected versions for the contexts you actually hit in production.

The Bug: A Reflected XSS in a Servlet

Here is a reflected cross site scripting example that has shipped in more servlets than anyone would admit. A search endpoint echoes the query back to the user:

protected void doGet(HttpServletRequest req, HttpServletResponse resp)
        throws IOException {
    String query = req.getParameter("q");
    resp.setContentType("text/html");
    PrintWriter out = resp.getWriter();
    out.println("<h1>Results for: " + query + "</h1>");   // vulnerable
}

Request ?q=<script>alert(document.domain)</script> and the servlet writes the script tag straight into the HTML body. The browser parses and executes it in your origin. This is textbook reflected XSS: the payload comes in on the request and bounces back in the response, unencoded.

The instinct is to "sanitize the input", stripping <script> or angle brackets. That is a losing game (event-handler attributes, javascript: URLs, SVG, mixed encodings all route around blocklists) and it is fixing the problem in the wrong place. The data is only dangerous at output, and the same value may be safe in one context and dangerous in another. Fix it where it is written.

Fix 1: Context-Aware Output Encoding With OWASP Java Encoder

The OWASP Java Encoder provides encoders per output context. The corrected servlet:

import org.owasp.encoder.Encode;

String query = req.getParameter("q");
out.println("<h1>Results for: " + Encode.forHtml(query) + "</h1>");

Encode.forHtml turns < into &lt;, > into &gt;, and so on, so the payload displays as literal text. The critical word is context-aware. HTML body content, HTML attribute values, JavaScript string literals, and URL parameters each need different encoding:

// HTML element content
Encode.forHtml(data);
// Inside a quoted HTML attribute
Encode.forHtmlAttribute(data);
// Inside a <script> block, as a JS string
Encode.forJavaScript(data);
// As a URL query parameter value
Encode.forUriComponent(data);

Using the wrong encoder is a real cross site scripting vulnerability fix that fails silently: HTML-encoding a value that lands inside a JavaScript string does nothing to stop ';alert(1);//. Match the encoder to the sink.

Fix 2: Let the Template Engine Escape by Default

Hand-concatenating HTML in servlets is the root cause more often than any single missing encoder call. Modern Java view layers escape by default, and the fix is frequently just "stop building HTML with +".

Thymeleaf escapes th:text automatically:

<h1>Results for: <span th:text="${query}">placeholder</span></h1>

The danger is the escape hatch: th:utext (unescaped text) is Thymeleaf's innerHTML. Every th:utext in a codebase is a finding to justify or remove.

JSP with JSTL, the c:out tag escapes by default:

<h1>Results for: <c:out value="${query}"/></h1>

The trap here is that a bare ${query} EL expression in JSP does not escape. Teams that adopted c:out still ship XSS through the one developer who wrote ${query} directly. A lint rule or code-search gate on raw EL in JSPs pays for itself.

The general rule: prefer a template engine that escapes by default, and treat every unescaped-output construct (th:utext, raw ${}, <%= %>) as requiring the same review scrutiny as raw SQL.

Fix 3: Encode for the Right Context in JSON and JavaScript

APIs that build JavaScript or JSON by string concatenation reintroduce the bug. Do not assemble JSON with String.format; serialize with Jackson or Gson, which handle escaping:

// Safe: the serializer escapes control and markup characters
String json = new ObjectMapper().writeValueAsString(payload);

If you are injecting server-side data into an inline <script> (an anti-pattern, but common), the value needs JavaScript-context encoding and HTML-context awareness, because </script> in the data closes the block early. This is exactly the kind of subtle sink where a scanner earns its keep.

Fix 4: Add Defense in Depth, But Do Not Rely on It

Two backstops worth having, neither a substitute for encoding:

Content-Security-Policy. A strict CSP with nonced scripts and no unsafe-inline means an injected <script> will not execute even if it slips into the markup:

Content-Security-Policy: script-src 'nonce-{random}' 'strict-dynamic'; object-src 'none'; base-uri 'none'

HttpOnly session cookies. Marking session cookies HttpOnly stops injected script from reading them via document.cookie, limiting one common goal of an XSS attack. It does nothing to stop the script from acting as the user in-page, so it is mitigation, not a fix.

Fixing It Across an Existing Codebase

Fixing one servlet is easy. Finding all of them is the job. A practical sequence:

  1. Inventory the sinks. Grep for getWriter().print, response.getOutputStream, th:utext, raw ${} in JSPs, <%=, and manual JSON assembly. Each is a candidate output point.
  2. Run static analysis with taint tracking. Tools that trace request parameters (getParameter, getHeader, path variables) to output sinks will find the flows that grep misses because source and sink live in different classes.
  3. Confirm at runtime. A DAST scan that submits benign markers and checks whether they render unencoded gives you exploitable-vs-theoretical ground truth, which is what you take to sprint planning.
  4. Watch the dependencies. Vulnerable versions of markdown renderers, template libraries, and rich-text sanitizers are a Java vulnerability you inherit; an SCA tool such as Safeguard flags those transitively so a stale sanitizer in a sub-dependency does not become your XSS.

Treat XSS remediation as an ongoing control, not a one-time cleanup: new endpoints introduce new sinks every sprint.

FAQ

What is the correct way to fix cross-site scripting in Java?

Apply context-aware output encoding where untrusted data is written into the response, using the OWASP Java Encoder (Encode.forHtml, forHtmlAttribute, forJavaScript, forUriComponent) or a template engine that escapes by default. Input filtering and CSP are supporting controls, not the fix.

Is input validation enough to stop XSS?

No. Validation reduces the attack surface and is good practice, but the same input can be safe in one output context and dangerous in another, and blocklist filters are routinely bypassed. Encoding at the output sink is what actually neutralizes the payload.

How do I fix XSS in a JSP page?

Use <c:out value="${...}"/> instead of writing ${...} directly, since bare EL expressions are not escaped. For attribute or JavaScript contexts, use the matching OWASP Java Encoder method rather than assuming HTML encoding covers every case.

Does a Content-Security-Policy fix XSS by itself?

No. A strict CSP blocks execution of injected inline scripts and raises the cost of exploitation, but it is a mitigation layer. The vulnerability remains until the output is correctly encoded; treat CSP as the safety net beneath the fix, not the fix.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.