Modern vulnerability resolution (Cross-Site Scripting). Validation vs. Encoding

Let’s look at the most basic Cross-Site Scripting example (XSS). We’ll use examples in a couple of different popular programming languages and frameworks for that.

A basic reflected Cross-Site Scripting injection example:

We will take the most basic example in a couple of programming languages and frameworks from here: https://github.com/samoylenko?tab=repositories&q=vulnerable-app

explain01

We clone the repositories to run our experiment:

explain02

All these applications have a /hello endpoint that will respond Hello, $name to a parameter in the URL. For example,

http://localhost:8080/hello?name=Michael will return Hello, Michael

hello

So what happens when we try to inject the classic <script>alert('xss')</script> in that parameter?

Let’s find out!

NodeJS - Express Application

Language Framework

JavaScript (Node)

Express

The first example in NodeJS is obvious enough:

app.get('/hello', function (req, res) {
  res.send(`Hello, ${req.query.name}`)
})

As expected, we got an XSS:

xss-node-express

No surprise here.

Java - Spring Application

Language Framework

Java

Spring

Looks pretty much the same, right? Classic:

@RestController
public class HelloController {
    @GetMapping("/hello")
    public String hello(@RequestParam String name) {
        return "Hello, " + name;
    }
}

Of course, there’s an XSS if we run that!

xss-java-spring

Java - Micronaut Application

Language Framework

Java

Micronaut

Same code, right? Would you expect a different result with this code? Only code annotations differ because the framework is different:

@Controller("/hello")
class HelloController {
    @Get
    String getName(@QueryValue String name) {
        return "Hello, " + name;
    }
}

Let’s try it:

xss-java-micronaut

Wait, what? No XSS! The injected code was escaped? Or validated? Was it? But how? It’s literally the same code as the previous example!

Let’s finish our experiment before we investigate what is really going on

I’ve also been planning to extend this article with deserialization vulnerabilities for a while - all out of my love for Kotlin. I never got back to it, but the table below also contains Kotlin apps.
Language Framework Did we get the XSS as expected with the same operation (add user input to output as is)?

NodeJS

Express

Yes

Java

Spring

Yes

Java

Micronaut

No!

Kotlin

Spring

Yes

Kotlin

Micronaut

No!

Kotlin

Ktor

No!

Let’s investigate

So what is going on? Does the string get encoded in HTML? Is it escaped? Is there an input validation?

Let’s take a look at the HTTP protocol-level information. In each of the following three request pairs:

  • The first request emulates a browser and instructs the server on the type of content it accepts (HTML)

  • The second request is a plain GET request (no headers) - to see how that affects the server setting the Content-Type server in the response.

NodeJS + Express (default request headers)

explain07 01 express
Figure 1. The browser is instructed to render the output as HTML. We also see that the output is identical in all examples and is never changed.

NodeJS + Express (no extra headers)

explain07 02 express
Figure 2. If the browser doesn’t provide a preferred content type, Express defaults to HTML.

Java + Spring (default request headers)

explain07 03 spring

Java + Spring (no extra headers)

explain07 04 spring
Figure 3. Spring defaults to plain text, so the injected code wouldn’t be executed if the browser didn’t provide an accepted content type.

Java + Micronaut (default request headers)

explain07 05 micronaut
Figure 4. Micronaut always defaults to JSON for raw outputs.

Java + Micronaut (no extra headers)

explain07 06 micronaut

So what do we get?

We got the following:

  1. The output (the Hello, <script>alert('xss')</script> string) is identical in all cases. And even though nothing is being sanitized or validated, it doesn’t execute when using the Micronaut framework.

  2. The only difference between having XSS and not is the Content-Type header instructing the browser on treating the server output.

  3. The Cross-Site Scripting vulnerability was prevented by the framework not trying to satisfy the browser’s Accept header and setting the Content-Type header explicitly depending on the content it produced.

What is the proper fix for this vulnerability?

Well, that depends on how you define the security of your application. Many people will say that just the fact that the output is encoded correctly (plaintext or JSON, not HTML, that would execute the script) is described as a proper solution in the Application Security Verfication Standard (ASVS).

But most still view it as just the fact that the code doesn’t execute is insufficient and malicious input must be prevented. It may not come as HTML today, but a future change in code or browsers may someday make it executable for any reason. It’s not secure to rely just on the header and hope that behavior never changes.

What do static analysis tools (SAST) say?

Most static security scanners (SAST) indeed don’t check the Content-Type header and would always highlight it as a finding. But since most of them are now suggesting a fix through AI, they sometimes offer a solution doing precisely that - explicitly setting the Content-Type header to something other than text/html.

I rely on my favorite scanner, Snyk, which knows the scenario described here well.

Snyk suggesting the fix

explain07 snyk 01

Snyk accepting the Content-Type header as a solution

explain07 snyk 02

Snyk - same with a Java Spring application

explain07 snyk 03

Just to be thorough, I’ve checked - and setting Content-Type to text/html does bring it back:

explain07 snyk 04

Conclusion

Based on what we just learned, I will try to carefully make the following statements:

  1. In the modern web, the server and browser work together to protect the user.

  2. There may be much better ways to fix injection-type vulnerabilities than implement a custom, often regex-based filtering or escaping functions in code. Even OWASP Application Security Verification Project (ASVS, probably the best source of information on preventing application security issues) supports that:

    With modern web application architecture, output encoding is more important than ever. It is difficult to provide robust input validation in certain scenarios, so the use of safer API such as parameterized queries, auto-escaping templating frameworks, or carefully chosen output encoding is critical to the security of the application.
    — Application Security Verification Standard
    V5 Validation,Sanitization and Encoding
  3. Most modern frameworks and libraries already contain most, if not all, the required functionality to do that already, so there’s often no need to write any extra code or make the code more complex trying to fix security issues. All it usually takes is to read the framework/library documentation carefully.

    • Constructing a raw HTML output in code no longer makes sense - templates and front-end frameworks exist for that.

    • Using a modern framework or a well-known library alone in the first place makes it hard to create a new vulnerability.

  4. Regardless if you see the Content-Type header fix discussed in this article as a final fix for the vulnerability, or just a way to address the risk quickly, the fact that the user input makes it into the output as-is and may be executed under certain circumstances, should at least be considered a code smell and have a plan to fix it.

Post Scriptum

It’s probably also worth discussing why exactly I replace the text/html value in the Content-Type header with text/plain. That’s an excellent topic for writing a separate article, but here are a couple of bullet points. It all depends on how the endpoint is intended to be used.

  • If we intended to return a rich HTML output for our user, we wouldn’t construct the output manually in the code. We’d use templates and front-end frameworks for that. This is usually the recommendation from the Application Security team in the Cross-Site Scripting scenario discussed here. Together with the quick fix using plaintext as we discussed.

  • If we intended to return data in a REST API endpoint, we’d use JSON (or gRPC).

  • Looking at this existing code, it just wants to show a simple string on the screen. We recommend making it plaintext to eliminate the vulnerability as soon as possible using as little resources and time as possible.

The last time I saw an OWASP document recommending input validation instead was an "OWASP Secure Coding Practices Quick Reference Guide" that was not updated since 2010.