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.
The short version
- A third-party chat widget, embedded on a furniture retailer's country storefronts, listens for
postMessagefrom "trusted" origins - The trust check is
~event.origin.indexOf(SERVER_URL), which is true for any hostname that begins with the vendor's - Behind the check: a route to
innerHTML(DOM XSS), a route todocument.cookie(arbitrary cookie injection, session fixation),localStoragewrites, and co-browse steering - A trusted-looking origin costs $0 and two minutes: wildcard DNS plus a free ACME certificate. RFCs 1034, 1035, 6454, 8555 and the CA/Browser Forum rules forbid none of it
- The XSS payload had to be valid SVG with an HTML integration point, because the sink was an
<svg:text>element - No CSP on the storefronts. Ultimately fixed by removing the widget entirely
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:
| Expression | Value | Boolean |
|---|---|---|
~(-1), not found | 0 | false |
~(0), found at index 0 | -1 | true |
~(k), any k ≥ 0 | negative | true |
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_id | Sink | Consequence |
|---|---|---|
agent_unread_message | innerHTML of the notification badge, an SVG <text> | DOM XSS on the storefront origin |
set_cookie | easychat_set_cookie(name, value, expiry, path) | Arbitrary cookie injection, including the widget's JS-managed chat session ID. Session fixation. |
set_local_storage | Arbitrary localStorage write | Persistence, state poisoning |
initialize-cobrowse and friends | Co-browse and agent flows | Victim 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:
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
| Mechanism | Governs | Why it does not apply |
|---|---|---|
| UDRP, trademark law | Registrable second-level domains | I registered nothing. The lookalike string is labels inside a zone I already control |
| Registrar reserved-word lists | New registrations under some TLDs | The wildcard DNS path involves no registration at all |
| Bug bounty program policy | In-scope testing behavior | The 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
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
- RFC 1034, Domain Names: Concepts and Facilities. Sections 3.1, 4.2 (name space authority), 4.3.3 (wildcards).
- RFC 1035, Domain Names: Implementation and Specification. Section 2.3.1, preferred name syntax.
- RFC 5737, IPv4 Address Blocks Reserved for Documentation. TEST-NET-1, 192.0.2.0/24.
- RFC 6454, The Web Origin Concept. Section 6.2, ASCII serialization of an origin.
- RFC 8499, DNS Terminology. Section 7, zones.
- RFC 8555, Automatic Certificate Management Environment (ACME). Sections 8.3 and 9, validation challenges.
- CA/Browser Forum Baseline Requirements. Section 3.2.2, domain validation.
Origin of Evil. Every character was exactly where the specification said it should be. That is what made it evil.