Cross-site scripting (XSS) is a type of cyberattack in which an attacker injects malicious code into web pages that other users view. This code is often executed in the victim’s browser and can be used to steal data such as session tokens or other personal information. XSS attacks are classified into stored (or persistent), reflected (or reflected), and DOM-based. Protection against XSS includes data sanitization, limiting the execution of client-side scripts, and using content security policies.
In this article, you will find detailed examples of cross-site scripting (XSS) attacks and various mitigation strategies. The article examines the different types of XSS vulnerabilities, such as stored XSS and reflected XSS, and shows how these attacks can be performed. It also provides a detailed overview of security techniques that can be applied to protect web applications from such vulnerabilities. This includes practices such as data cleansing, secure coding practices, and implementation of Content Security Policies (CSP).
Disclaimer: This article is for informational purposes only. It describes how phishing attacks work to help readers understand their essence and learn how to protect themselves from such threats.
Originally, the World Wide Web was a set of static HTML documents that a browser had to display for users to view. As the Internet developed, so did the requirements for documents, which led to the emergence of JavaScript and cookies: scripts are needed to make a document interactive, and cookies are needed so that browsers can store its state.
The advent of these capabilities has led to the fact that browsers not only render HTML, but also store in memory as an API for developers a view called the Document Object Model (DOM). The DOM offers a tree-like structure of HTML tags, as well as access to cookies to obtain state. Over time, the model changed from a structure primarily intended for reading to a read-write structure, updating which leads to re-rendering of the document.
Once documents were able to run code, browsers had to define the execution context for JavaScript programs. The policy that was developed is called Same-Origin and is still one of the fundamental primitives of browser security. It originally stated that JavaScript in a single document could only access its own DOM and the DOM of other documents with the same origin. Later, when XMLHttpRequest and Fetch were added, a modified version of Same-Origin appeared. These APIs cannot issue requests to any source, they can only read response to requests from the same source.
What is origin? This is a tuple of the document’s protocol, hostname, and port.
Snippet 1: A tuple of the scheme, host, and port of this URL.
https://www.example.com:443/app ^^^^^ ^^^^^^^^^^^^^^^ ^^^ Scheme Host Port
Same-Origin is great for mitigating attacks against static sites, as shown in the figure above. However, with attacks on dynamic resources that accept user input, the situation is slightly more complicated due to the mixing of code and data, which allows an attacker to perform controlled input on the source document.
XSS attacks generally come in three types: reflective, persistent, and DOM-based.
Reflective and stored XSS attacks are fundamentally the same in that they rely on malicious input being sent to the backend and presenting that input to the server user. Reflective XSS usually occurs in the form of a maliciously created link by the attacker, which is then followed by the victim. Stored XSS occurs when an attacker uploads malicious input. DOM-based attacks differ in that they occur exclusively on the client side and involve malicious input that manipulates the DOM.
Below you can see a simple Go web application that displays its input (even if it’s a malicious script) back to the user. You can use this program by saving it to xss1.go and running go run xss1.go.
Fragment 2: Example of a web application with a reflective (reflected) XSS attack.
package main import ( "fmt" "log" "net/http" ) func handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-XSS-Protection", "0") messages, ok := r.URL.Query()["message"] if !ok { messages = []string{"hello, world"} } fmt.Fprintf(w, "<html><p>%v</p></html>", messages[0]) } func main() { http.HandleFunc("/", handler) log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil)) }
To see the XSS attack, visit the vulnerable URL below.
http://localhost:8080?message=<script>alert(1)</script>
Take a look at the source: the server returned a document that looks something like the one shown in snippet 4. Notice how the mixing of code and data allowed this attack to happen.
Snippet 3: Example output of an XSS vulnerable web application.
<html> <p> <script>alert(1)</script> </p> </html>
This example may seem implausible because XSS protection has been explicitly disabled. This form of it is based on heuristics with workarounds for different browsers. It has been disabled to create cross-browser examples that illustrate the basic concepts of XSS attacks. Some browsers remove this protection: for example, in Google Chrome 78 and above, you won’t need the w.Header().Set(“X-XSS-Protection”, “0”) line for the attack to work.
Stored XSS attacks are similar to reflective attacks, but the payload comes from the data store rather than from the input directly. For example, an attacker could upload malware to a web application, which would then be exposed to every authorized user.
Below is a simple chat that illustrates this type of attack. You can save the program in an xss2.go file and run it with the go run xss2.go command.
Fragment 4: Stored XSS attack.
package main import ( "fmt" "log" "net/http" "strings" "sync" ) var db []string var mu sync.Mutex var tmpl = ` <form action="/save"> Message: <input name="message" type="text"><br> <input type="submit" value="Submit"> </form> %v ` func saveHandler(w http.ResponseWriter, r *http.Request) { mu.Lock() defer mu.Unlock() r.ParseForm() messages, ok := r.Form["message"] if !ok { http.Error(w, "missing message", 500) } db = append(db, messages[0]) http.Redirect(w, r, "/", 301) } func viewHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-XSS-Protection", "0") w.Header().Set("Content-Type", "text/html; charset=utf-8") var sb strings.Builder sb.WriteString("<ul>") for _, message := range db { sb.WriteString("<li>" + message + "</li>") } sb.WriteString("</ul>") fmt.Fprintf(w, tmpl, sb.String()) } func main() { http.HandleFunc("/", viewHandler) http.HandleFunc("/save", saveHandler) log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil)) }
To see the XSS attack, go to http://localhost:8080 and type <script>alert(1);</script>.
the payload is stored in the data store in the storeHandler function;
If the page is rendered in a ViewHandler, the payload is added to the output.
Such attacks are not related to the backend and occur exclusively on behalf of the client. They are interesting because modern web applications move the logic to the client, and the attacks occur when the user directly manipulates the DOM. The good news for attackers is that the DOM has a wide variety of exploits, the most popular being innerHTML and document.write.
Below is an example of a web application serving static content. The code is the same as in the reflective XSS example, but the attack will be entirely client-side. You can save the program in an xss3.go file and run it with the go run xss3.go command.
Snippet 5: Example of a web application with a DOM-based XSS attack.
const content = ` <html> <head> <script> window.onload = function() { var params = new URLSearchParams(window.location.search); p = document.getElementById("content") p.innerHTML = params.get("message") }; </script> </head> <body> <p id="content"></p> </body> </html> ` func handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-XSS-Protection", "0") fmt.Fprintf(w, content) } func main() { http.HandleFunc("/", handler) log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil)) }
To see this attack, go to http://localhost:8080/?message=”<img src=1 onerror=alert(1);/>” . Note that the attack vector is slightly different and innerHTML will not execute the script directly, however it will add HTML elements that will then execute the JavaScript code. In the given example, an image element is added, which runs the script in case of an error (it always appears, because the attacker substitutes the wrong source).
If you want to add a script element directly, you will have to use a different XSS receiver. Replace the script element from fragment 6 with the script element from fragment 7 and navigate to the following link: http://localhost:8080/?message=”<script>alert(1);</script>”. The attack will work because document.write accepts direct script elements.
Fragment 6 Another example of a DOM-based XSS attack.
<script> window.onload = function() { var params = new URLSearchParams(window.location.search); document.open(); document.write(params.get("message")); document.close(); }; </script>
Wrong setting of content type of HTTP responses is to blame. This can happen both at the backend level (the response has an incorrect set of Content-Type headers) and when the browser tries to sniff the MIME type. Internet Explorer was particularly susceptible to this, and a classic example is an image upload service: an attacker can download JavaScript instead of an image. The browser sees that the content type has been set to image/jpg, but the payload contains a script – it executes, leading to an XSS attack.
The next type of attack is URL activity with a JavaScript scheme. Consider a website that allows the user to control the destination of the link, as shown in Fragment 8. In this case, an attacker would be able to provide a URL that executes JavaScript using our scheme.
To test this type of attack, you can save the program in an xss4.go file, run it with the go run xss4.go command, and go to http://localhost:8080?link=javascript:alert(1).
Fragment 7: An XSS attack introduced via a URL scheme.
package main import ( "fmt" "log" "net/http" ) func handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-XSS-Protection", "0") links, ok := r.URL.Query()["link"] if !ok { messages = []string{"example.com"} } fmt.Fprintf(w, `<html><p><a href="%v">Next</p></html>`, links[0]) } func main() { http.HandleFunc("/", handler) log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil)) }
There is no one-size-fits-all solution to this problem, otherwise XSS would not be such a common problem. The fundamental complexity is caused by the lack of separation between code and data. XSS mitigation typically involves sanitizing input data (you must ensure that it does not contain code), shielding output data (which must also not contain code), and restructuring your application so that the code is loaded from certain endpoints.
The first line of defense is input validation. Make sure that their format meets the expected characteristics – a kind of whitelist that guarantees that the program will not be able to accept the code.
Data validation is a complex problem. There is no one-size-fits-all tool or technique for all situations. It is best to structure the application in such a way that it requires developers to think about the data type and provide a convenient place to place the validator.
A good tone of Go programming is to not have any application logic in HTTP request handlers, but instead use them to parse and validate incoming data. The data then goes into a processing logic structure. Query handlers become simple and provide a convenient centralized location for monitoring the correctness of data cleansing. Fragment 8 shows how saveHandler can be rewritten to accept the ASCII characters [A-Za-z\.]
Snippet 8: An example of using HTTP request handlers to validate data.
func saveHandler(w http.ResponseWriter, r *http.Request) { r.ParseForm() messages, ok := r.Form["message"] if !ok { http.Error(w, "missing message", 500) } re := regexp.MustCompile(`^[A-Za-z\\.]+$`) if re.Find([]byte(messages[0]))) == "" { http.Error(w, "invalid message", 500) } db.Append(messages[0]) http.Redirect(w, r, "/", 301) }
This may seem like an unnecessary concern, but a chat program accepts much more than a limited set of characters. Much of the data accepted by applications is quite structured: addresses, phone numbers, postal codes and the like can and should be checked.
The next step is output shielding. In the case of our chat, everything extracted from the database was included directly in the source document.
The same application can be much safer (even if it has been injected with code) if it shields all dangerous output data. This is what the html/template package does in Go. Using a templating language and a context-sensitive parser to mask data before it is rendered will reduce the likelihood of malicious code being executed.
Below is an example of using the html/template package. Save the program as an xss5.go file and then use the go run xss5.go command.
Excerpt 9: Using shielding to eliminate stored XSS attacks.
package main import ( "bytes" "html/template" "io" "log" "net/http" "sync" ) var db []string var mu sync.Mutex var tmpl = ` <form action="/save"> Message: <input name="message" type="text"><br> <input type="submit" value="Submit"> </form> <ul> {{range .}} <li>{{.}}</li> {{end}} </ul>` func saveHandler(w http.ResponseWriter, r *http.Request) { mu.Lock() defer mu.Unlock() r.ParseForm() messages, ok := r.Form["message"] if !ok { http.Error(w, "missing message", 500) } db = append(db, messages[0]) http.Redirect(w, r, "/", 301) } func viewHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-XSS-Protection", "0") w.Header().Set("Content-Type", "text/html; charset=utf-8") t := template.New("view") t, err := t.Parse(tmpl) if err != nil { http.Error(w, err.Error(), 500) return } var buf bytes.Buffer err = t.Execute(&buf, db) if err != nil { http.Error(w, err.Error(), 500) return } io.Copy(w, &buf) } func main() { http.HandleFunc("/", viewHandler) http.HandleFunc("/save", saveHandler) log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil)) }
Try the XSS attack used by visiting http://localhost:8080 and typing <script>alert(1);</script>. Note that no warning was raised. Open your browser console and look at the li element in the DOM. Two properties are of interest: innerHTML and innerText.
Snippet 10: Validating the DOM when using escaping.
innerHTML: "<script>alert(1);</script>" innerText: "<script>alert(1);</script>"
Notice how the escapement has kept the code and data cleanly separated.
Content Security Policy (CSP) allows web applications to define a set of trusted sources for downloading content (such as scripts). CSP can be used to separate code and data by forgoing built-in scripts and only loading them from specific sources.
Writing a CSP for small stand-alone applications is a simple task – start with a policy that by default disallows all sources, then allow a small set. However, writing an effective CSP for large sites is not so easy. Once a site starts loading content from external sources, the CSP gets bloated and cumbersome. Some developers give up and include the unsafe-inline directive, completely destroying the CSP theory.
To simplify writing CSPs, CSP3 introduces the strict-dynamic directive. Instead of maintaining a large whitelist of trusted sources, the program generates a random number (nonce) each time a page is requested. This nonce is sent with page headers and embedded in the script tag, which forces browsers to trust those scripts with the matching nonce, as well as any scripts they might load. Instead of whitelisting scripts and trying to figure out what scripts they load and then whitelisting recursively, you just need to whitelist the imported top-level script.
Using Google’s suggested Strict CSP approach, consider a simple application that accepts user input. Save it to the xss6.go file and then run the go run xss6.go command.
Fragment 11: An example of a CSP that mitigates an XSS attack.
package main import ( "bytes" "crypto/rand" "encoding/base64" "fmt" "html/template" "log" "net/http" "strings" ) const scriptContent = ` document.addEventListener('DOMContentLoaded', function () { var updateButton = document.getElementById("textUpdate"); updateButton.addEventListener("click", function() { var p = document.getElementById("content"); var message = document.getElementById("textInput").value; p.innerHTML = message; }); }; ` const htmlContent = ` <html> <head> <script src="script.js" nonce="{{ . }}"></script> </head> <body> <p id="content"></p> <div class="input-group mb-3"> <input type="text" class="form-control" id="textInput"> <div class="input-group-append"> <button class="btn btn-outline-secondary" type="button" id="textUpdate">Update</button> </div> </div> <blockquote class="twitter-tweet" data-lang="en"> <a href="https://twitter.com/jack/status/20?ref_src=twsrc%5Etfw">March 21, 2006</a> </blockquote> <script async src="https://platform.twitter.com/widgets.js" nonce="{{ . }}" charset="utf-8"></script> </body> </html> ` func generateNonce() (string, error) { buf := make([]byte, 16) _, err := rand.Read(buf) if err != nil { return "", err } return base64.StdEncoding.EncodeToString(buf), nil } func generateHTML(nonce string) (string, error) { var buf bytes.Buffer t, err := template.New("htmlContent").Parse(htmlContent) if err != nil { return "", err } err = t.Execute(&buf, nonce) if err != nil { return "", err } return buf.String(), nil } func generatePolicy(nonce string) string { s := fmt.Sprintf(`'nonce-%v`, nonce) var contentSecurityPolicy = []string{ `object-src 'none';`, fmt.Sprintf(`script-src %v 'strict-dynamic';`, s), `base-uri 'none';`, } return strings.Join(contentSecurityPolicy, " ") } func scriptHandler(w http.ResponseWriter, r *http.Request) { nonce, err := generateNonce() if err != nil { returnError() return } w.Header().Set("X-XSS-Protection", "0") w.Header().Set("Content-Type", "application/javascript; charset=utf-8") w.Header().Set("Content-Security-Policy", generatePolicy(nonce)) fmt.Fprintf(w, scriptContent) } func htmlHandler(w http.ResponseWriter, r *http.Request) { nonce, err := generateNonce() if err != nil { returnError() return } w.Header().Set("X-XSS-Protection", "0") w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Security-Policy", generatePolicy(nonce)) htmlContent, err := generateHTML(nonce) if err != nil { returnError() return } fmt.Fprintf(w, htmlContent) } func returnError() { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } func main() { http.HandleFunc("/script.js", scriptHandler) http.HandleFunc("/", htmlHandler) log.Fatal(http.ListenAndServe(":8080", nil)) }
To try using the app, go to: http://localhost:8080 and try sending <img src=1 onerror”alert(1)”/> as before. This attack would work without CSP, but since CSP doesn’t allow inline scripting, you should see something like this in your browser console:
“Execution of the inline event handler is denied because it violates the following CSP directive: “script-src ‘nonce-XauzABRw9QtE0bzoiRmslQ==’ ‘unsafe-inline’ ‘unsafe-eval’ ‘strict-dynamic’ https: http:” ‘unsafe-inline’ ‘ is ignored if the source list contains either a hash or a nonce value.”
Part 12: Basic CSP. The nonce is generated repeatedly for each request
script-src 'strict-dynamic' 'nonce-XauzABRw9QtE0bzoiRmslQ=='; object-src 'none'; base-uri 'none';
What does this policy do? The script-src directive includes strict-dynamic and a nonce value used to load scripts. This means that the only scripts that will be loaded are in script elements, where the nonce is included in the attribute, which means that inline scripts will not be loaded. The last two directives prevent plugins from being downloaded and the application’s base URL from being changed.
The main difficulty with this approach is the need to generate the nonce and inject it into the headers every time the page is loaded. The template can then be applied to all pages that are loaded.
You should not only set your Content-Type, but also ensure that browsers do not attempt to automatically determine the content-type. For this, use the header: X-Content-Type-Options: nosniff.
Although virtual domains are not a security feature, their modern frameworks (React and Vue) can help mitigate DOM-based XSS attacks.
These frameworks create the DOM in parallel with what is in the browser and compare them. They update a great part of the browser’s DOM. This requires creating a virtual DOM, which will reduce clients’ use of innerHTML and encourage developers to switch to innerText.
React requires using the dangerouslySetInnerHTML attribute, while Vue’s creators warn that using innerHTML can lead to vulnerabilities.
If you’ve read all the way to the end, you might want to understand how browsers work, what XSS errors are, and how important it is to understand how to get rid of them. XSS is difficult to eradicate as applications become larger and more complex. Using the methods mentioned in the article, you can make life difficult for criminals.