When we hear "XSS", we picture script tags in comment forms or malicious URLs with JavaScript payloads. We rarely think about the title tag. But that's exactly where we found a stored XSS vulnerability in Onneta's multi-tenant workspace system.
The Setup
Onneta creates a workspace subdomain for each customer. Part of that setup generates an HTML page with the workspace name in the <title> tag. The workspace name was derived from the user's email:
const sub = email.split('@')[0];
// Later, in HTML template:
<title>${sub} — Workspace</title>
This looks harmless. An email like sarah@example.com produces sarah — Workspace. But what about an email like <script>alert(1)</script>@evil.com?
The Vulnerability
The email local part — everything before the @ — was injected directly into HTML without escaping. A <title> tag doesn't execute scripts on its own, but the same sub variable appeared in three more places:
- An
<h1>heading on the subdomain page - A
<meta>description tag - A visible paragraph describing the workspace
The <h1> injection is the dangerous one. Inside an <h1>, a <script> tag will execute. A carefully crafted email address becomes a stored XSS payload that fires every time someone visits that subdomain page.
Why sanitise() Wasn't Enough
We already had a sanitise() function that strips control characters and truncates input at the database boundary. It's designed to protect the database — ensuring no oversized or malformed data gets stored.
But sanitise() doesn't escape HTML entities. It doesn't convert < to <. That's a different job for a different context.
// sanitise() — protects the DATABASE
// Strips control chars, truncates to length limit
function sanitise(str, maxLen = 500) { ... }
// escapeHtml() — protects the DOM
// Converts & < > " ' to HTML entities
function escapeHtml(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
These two functions serve different security contexts. Confusing them — or assuming one covers both — is how XSS slips through.
The Fix
We added an escapeHtml() helper to server.js and applied it everywhere user-derived data appears in HTML output. Five interpolation points were patched in total:
- The
<title>tag (workspace name) - Three visible text locations using the
subvariable - A vision field that used manual
.replace()chains — replaced with the centralised helper
The key insight: every template interpolation that produces HTML must use escapeHtml(). No exceptions. Even if the data "should" be clean, even if another layer "already" validated it.
How We Found It
Not through a penetration test. Not through a bug bounty. We found it with five grep commands:
grep -n 'split.*@' server.js # Where emails are parsed
grep -n '\${.*sub' server.js # Where sub is interpolated
grep -n 'escapeHtml\|sanitise' server.js # What protection exists
grep -n '<title>\|<h1>' server.js # HTML output contexts
grep -n 'innerHTML' server.js # Client-side injection
The first two commands revealed the attack surface. The third showed the protection gap. Total time: under ten minutes.
Lessons
- Input sanitisation and output escaping are separate concerns.
sanitise()at the boundary,escapeHtml()at the template. - Title tags are overlooked vectors. They don't execute scripts themselves, but the same variable often appears in executable contexts nearby.
- Systematic grep audits find what scanners miss. Automated tools look for known patterns. A targeted grep for your specific data flow catches custom vulnerabilities.
- Email addresses are user input. The local part of an email can contain almost any character. Never trust it in HTML output.
The Broader Pattern
This was the 54th consecutive single-file security patch we've shipped at Onneta without a failure. The pattern is always the same: grep audit, write a spec, execute the patch, verify on production. No guessing. No hoping. Just systematic inspection of every data flow from input to output.
If you're building a multi-tenant application, audit every place where tenant data appears in HTML. Especially the boring places — title tags, meta descriptions, error messages. That's where the XSS hides.