Stored XSS in a Title Tag: How Emails Leak Into Your HTML

30 March 2026 · 5 min read · Security

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:

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 &lt;. 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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

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

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.

Security built into every layer

Onneta runs continuous security audits on its own codebase. Your business data is protected by defence in depth.

Start building with Onneta