← saladin.su

Security Research · Redacted Case File · DOM XSS

Origin of Evil

A chat widget on a furniture retailer's storefronts guarded its most dangerous functions with an origin check that was true for almost everyone. One missing negation opened it. Behind it sat an XSS sink inside an SVG, a bridge to document.cookie, and an attack whose every layer was fully compliant with the RFCs that govern the internet. No standard was violated. That was the problem.

Salah-Eddine Elouali October 22, 2026 ~9 min read

The short version

01The widget

Third-party chat widgets are the appendix of the modern storefront. Embedded by procurement, forgotten by engineering, and capable of spectacular inflammation. This one came from a chat vendor and was embedded on a furniture retailer's country storefronts. It ships a postMessage listener so that the vendor's iframe and the host page can talk to each other.

Here is the listener, from the live bundle. Read the comment slowly. The comment understands the assignment:

window.addEventListener('message', function(event) {
    // IMPORTANT: Check the origin of the data!
    if (~event.origin.indexOf(SERVER_URL)) {   // e.g. https://chat.vendor.example

The comment says: check the origin. The code performs a ritual that resembles checking the origin. The two agree the way a CLOSED sign agrees with an open door: textually, not functionally.

02One character

Old JavaScript hands will recognize the species. Before String.prototype.includes existed, the idiom was:

if (!~haystack.indexOf(needle)) {
    // not found
}

The tilde is bitwise NOT. In two's complement arithmetic, ~x === -x - 1. indexOf returns -1 on miss and a position ≥ 0 on hit, so:

ExpressionValueBoolean
~(-1), not found0false
~(0), found at index 0-1true
~(k), any k ≥ 0negativetrue

The negation ! was the entire point of the idiom. It flipped "found" into the branch. Someone kept the tilde, kept the incantation, and dropped the meaning:

if (~event.origin.indexOf(SERVER_URL))

This is false in exactly one case: when the vendor's URL appears nowhere in the origin string. Any origin that contains it anywhere passes. And since origins serialize as scheme://host[:port] (RFC 6454, section 6.2), "contains it at index 0" means "any hostname that begins with the vendor's."

One character. The entire access-control model of a widget deployed on production storefronts was one missing !.

Notice the asymmetry, because it is delicious. indexOf needs the needle at position zero, so the vendor's own sibling subdomain, api.chat.vendor.example, fails the check: its origin begins with "api.", not with the needle. The check is simultaneously too permissive for strangers and too strict for its own relatives. That is not a security policy. That is a mood.

03Behind the gate

The handler dispatches on event.data.event_id. The event catalog is a wishlist:

event_idSinkConsequence
agent_unread_messageinnerHTML of the notification badge, an SVG <text>DOM XSS on the storefront origin
set_cookieeasychat_set_cookie(name, value, expiry, path)Arbitrary cookie injection, including the widget's JS-managed chat session ID. Session fixation.
set_local_storageArbitrary localStorage writePersistence, state poisoning
initialize-cobrowse and friendsCo-browse and agent flowsVictim steered into attacker-chosen sessions

A message meant to carry a notification count, literally a number, the most harmless datatype in computing, was wired to innerHTML. Neither storefront sends a Content-Security-Policy header. HSTS only. Once script executes, nothing on the page argues with it.

04The payload crosses namespaces

Here is the part most bug hunters have not met. The badge is not an HTML node. It lives inside an inline SVG:

<svg>…<text id="new-notification-count"></text>…</svg>

Assign to an SVG element's innerHTML and the fragment parser continues in the SVG namespace. SVG has no <img>; it has <image>, and raw HTML inside SVG is inert. To get executable HTML you must route through an HTML integration point, <foreignObject>, and re-declare the HTML namespace explicitly:

event_id: 'agent_unread_message',
data: { message_count:
  '<foreignObject width="120" height="50">' +
    '<div xmlns="http://www.w3.org/1999/xhtml">' +
      '<img src=x onerror="alert(document.domain)">' +
    '</div>' +
  '</foreignObject>' }

The payload is itself a small standards document. The xmlns declaration is load-bearing. If you ever wondered whether understanding XML namespaces would ever make you money: occasionally, yes.

05Manufacturing a trusted origin, legally

The check demands an origin beginning with the vendor's URL. Surely you cannot just obtain https://chat.vendor.example.anything. Surely someone, somewhere, checks whether you are entitled to a string.

Nobody checks. Not because a system failed, but because the systems work exactly as specified. Every layer was designed this way on purpose. With citations:

DNS: your zone, your labels

RFC 1035, section 2.3.1, constrains a DNS label's charset (letters, digits, hyphens) and length (63 octets). That is the complete list of constraints. There is no notion of reserved strings, no cross-zone semantics, no committee that asks whether your label spells someone else's hostname. RFC 1034, sections 3.1 and 4.2, defines the name space as a tree where authority begins at zone cuts and flows downward only. RFC 8499, section 7, the DNS terminology BCP, says the same thing in modern words: a zone is a contiguous subtree under one authority. Dots inside your own zone are just label separators:

chat.vendor.example.192.0.2.10.sslip.io └── lives entirely inside the sslip.io zone └── which answers by wildcard (RFC 1034 §4.3.3) └── resolves to 192.0.2.10 → my server

No registration. No policy gate. sslip.io and nip.io are ordinary DNS doing ordinary wildcard things; RFC 1034 section 4.3.3 is the whole magic trick. And 192.0.2.0/24 is TEST-NET-1, reserved for documentation by RFC 5737, so this article documents an attack using an address the IETF set aside for examples. Peak compliance.

Origins: the serialization guarantees the prefix

RFC 6454, section 6.2, defines the ASCII serialization of a web origin: scheme "://" host [":" port]. A hostname I control that begins with the vendor string produces an origin that begins with the vendor URL by construction, not by coincidence. The web's own origin format is the delivery mechanism for the bypass.

TLS: certificate authorities validate control, never entitlement

A matching hostname is useless over HTTPS without a certificate, and here the last hope of "surely someone checks" dies politely. RFC 8555, sections 8.3 and 9, define ACME, the Let's Encrypt protocol. Its challenges prove operational control of an identifier: http-01 answers a request on the name, dns-01 writes a TXT record for the name. Nowhere in the protocol is there a concept of a right to a string. The CA/Browser Forum Baseline Requirements, section 3.2.2, require domain validation for the certificate's names; trademark screening is not part of domain validation, and subdomains of a zone you control are simply your names. A free, valid certificate for the lookalike hostname arrives in about ninety seconds.

Where a restriction would live, and why it does not apply

MechanismGovernsWhy it does not apply
UDRP, trademark lawRegistrable second-level domainsI registered nothing. The lookalike string is labels inside a zone I already control
Registrar reserved-word listsNew registrations under some TLDsThe wildcard DNS path involves no registration at all
Bug bounty program policyIn-scope testing behaviorThe PoC ran on my own infrastructure, my own browser, benign payloads

06The attack, assembled

Total cost: zero dollars. Total time: about two minutes. Prerequisites: none. No vendor account, no dashboard access, no writes to anyone's infrastructure.

// attacker page, served from the matching origin
var victim = window.open('https://storefront.example-country/');

function fire() {
  // Stage 1: arbitrary cookie injection, works as soon as the listener exists
  victim.postMessage({
    event_id: 'set_cookie',
    data: { cookie_name: 'f2_poc', cookie_value: 'injected', path: '/' }
  }, '*');
  // Stage 2: namespaced DOM XSS into the SVG badge
  victim.postMessage({
    event_id: 'agent_unread_message',
    data: { message_count: foreignObjectPayload }
  }, '*');
}
setTimeout(function(){ fire(); setInterval(fire, 4000); }, 3000);
// retries, because the badge element only exists after the widget's XHRs settle
Victim window, storefront origin alert("storefront.example-country | …cookies…") DevTools, Application, Cookies f2_poc = injected ← written by the storefront origin itself

Script execution on the storefront origin, plus the ability to plant and overwrite cookies, including the chat session identifier the widget itself sets from JavaScript. Session fixation stops being a theory and becomes a straight line. From there: DOM keylogging of anything typed on the page, in-place credential phishing, forced redirects. The storefront's own origin does the reputation laundering.

07Not a duplicate

A month earlier I had reported a sibling issue in the same widget family: a server-side flaw that let a vendor-side account inject JavaScript into other accounts' widgets. Different vulnerability class, different prerequisites. That one needed an account and performed writes. This one needs a DNS label and performs nothing on vendor systems. Both live bundles still contained this message handler at the time of reporting, comfortably surviving the earlier fix.

I documented the distinction with the patience of a man who has been told "duplicate" before. The report passed triage. Six weeks later the vendor asked me to retest. I checked the storefronts: the widget integration had been removed entirely. No iframe, no script reference, nothing left to audit. I replied that if removing the gadget was their way of securing it, they were welcome to close the report, because the vulnerability was certainly no longer present.

I mean that without irony. For a third-party widget, widget-ectomy is the most reliable fix there is. Every other option, the strict equality check, the textContent badge, the event allowlist, the CSP, requires the vendor to understand the bug. Removal requires only the ability to delete. Defense in depth, meet offense by subtraction.

For the record, the remediation wishlist:

// 1. Strict origin equality; the whole fix is this line
if (event.origin !== SERVER_URL) return;
// 2. Never route postMessage data into innerHTML.
//    A message count is a number: badge.textContent = String(count)
// 3. Whitelist event_id values; type-check every field
// 4. Delete the set_cookie bridge, or allowlist names
//    and drop attacker-supplied path and expiry
// 5. Audit every listener in the bundle family for the same tilde
// 6. Storefront-side: deploy CSP (script-src 'self', no unsafe-inline)

08What I keep

Lesson one is ancient and true: origins are not substrings. indexOf, startsWith, includes: all of them are stringly-typed trust. The only correct comparison for an origin is full-string equality, because an origin is not a string that happens to contain a host. It is a tuple, and tuples do not have prefixes.

Lesson two: code that comments its own security is aspirational literature. The comment knew. The comment always knows.

Lesson three is the one I will actually keep. When someone tells you an attack "surely cannot be allowed," you can often answer with the standards themselves. DNS does not police semantics; RFC 1035 polices charset. TLS does not police entitlement; RFC 8555 proves control. Origin serialization does not police prefixes; RFC 6454 just prints the tuple. Each layer worked exactly as documented, and the composition of three correct systems was an open door with a comment on it saying IMPORTANT. The attack violated no RFC. The RFCs were never asked to prevent it. Only the missing ! was ever supposed to.

References

Origin of Evil. Every character was exactly where the specification said it should be. That is what made it evil.