Bagisto (an eCommerce platform on Laravel) accidentally allowed Vue.js to hydrate user-controlled input in the customer profile. By changing their name to a Vue template, an attacker can achieve Stored XSS, executing code in the browser of anyone who views that profile—including administrators.
A Client-Side Template Injection (CSTI) vulnerability in the Bagisto eCommerce platform allows authenticated users to execute arbitrary JavaScript by injecting Vue.js templates into profile fields.
Modern web development often feels like trying to mix oil and water, or in this case, PHP and JavaScript. Bagisto is built on the 'TALL' stack philosophy adjacent—using Laravel for the backend and heavy doses of Vue.js for the frontend interactivity. While powerful, this combination creates a dangerous intersection known as the 'hydration gap.'
In a standard request, Laravel Blade renders the HTML on the server. It pulls data from the database—like your first name—and slaps it into the DOM. Blade is smart enough to escape HTML entities (<, >, &), preventing standard XSS. It thinks it has done its job. It wipes its hands clean and sends the HTML to the browser.
But here enters Vue.js. When the page loads in the browser, Vue 'hydrates' the DOM, scanning for its own delimiters (usually {{ }). It assumes anything inside those curly braces is trusted logic meant for it to evaluate. The vulnerability exists precisely in this handoff: Blade treats curly braces as text, but Vue treats them as code. If you can get your name saved as {{ 7*7 }}, Blade serves it safely, but Vue executes it.
The root cause isn't a failure of input validation in the traditional sense; it's a failure of context isolation. The application blindly trusted that user input rendered by the server would remain static text on the client. This is a classic Client-Side Template Injection (CSTI).
Technically, the issue resides in packages/Webkul/Shop/src/Resources/views/customers/account/profile/index.blade.php. The developer used the standard Blade echo syntax:
<p class="text-sm font-medium text-zinc-500">
{{ $customer->first_name }}
</p>To Blade, $customer->first_name is just a string. If I name myself {{ 7*7 }}, Blade renders <div>{{ 7*7 }}</div>. To the browser, this is valid HTML text content. But because Vue.js is mounted to a parent container of this element, it parses the DOM tree, finds the mustache syntax, and evaluates the mathematical expression inside. The user sees 49 instead of the payload. If the user sees 49, we have code execution.
The fix provided by the Bagisto team in commit 4144931da0014c696f9126132ce44d7cfbdb2761 is a textbook example of how to handle Vue/Blade interoperability, though it feels like a band-aid on a bullet hole.
Here is the vulnerable implementation. Note how the variable is dropped directly into the HTML body:
<!-- VULNERABLE -->
<div class="grid grid-cols-2 gap-y-6">
<div>
<p class="text-sm font-medium text-zinc-500">
{{ $customer->first_name }}
</p>
</div>
</div>The patch forces the data into a Vue directive, specifically v-text. This tells Vue: "Treat the content of this variable strictly as text, never as a template to be parsed."
<!-- FIXED -->
<div class="grid grid-cols-2 gap-y-6">
<div>
<p
class="text-sm font-medium text-zinc-500"
v-text="'{{ $customer->first_name }}'"
>
</p>
</div>
</div>[!NOTE] Notice the wrapping quotes inside
v-text="'...'". The Blade engine renders the name inside the single quotes, creating a JavaScript string literal that Vue then assigns to thetextContentof the paragraph.
To exploit this, we don't need fancy tools. We just need a browser and a penchant for chaos. The attack vector is the Customer Profile Edit page (/customer/account/profile/edit).
Step 1: The Smoke Test
First, we confirm the CSTI. We log in as a low-privileged customer and change our First Name to {{ 7 * 7 }}. Upon saving and viewing the profile, if we see the number 49 rendered on the screen, we have confirmed that the Vue engine is evaluating our input.
Step 2: Escalation
Since this is running inside Vue, we have access to the JavaScript context. However, modern Vue versions act as a sandbox. We can't just type alert(1). We need to reach out of the Vue sandbox to the global window object. A typical payload for this environment looks like this:
{{ _v.container.parentElement.ownerDocument.defaultView.alert('Hacked') }}Step 3: The Impact This is a Stored attack. The payload saves to the database. If an Administrator views the "Customers" list in the backend, and that list uses the same vulnerable Vue rendering (which is common in SPAs/Hybrid apps), the XSS fires in the Admin's session. We steal their cookies, force them to create a new Admin user for us, and take over the shop.
The patch uses v-text="'{{ $var }}'". This relies on the assumption that $var won't break out of the single quotes. Blade escapes HTML entities by default, converting ' to '. Vue should handle this correctly by reading the attribute value safely.
However, if there is any point where the HTML is decoded before Vue parses the directive, or if the developer used {!! !!} (raw output) elsewhere, an attacker could inject ' + alert(1) + ' to break the string literal.
While the current patch seems robust against standard attacks due to Blade's default escaping, a more bulletproof approach would be to pass the data via a data-attribute or a proper JSON prop, rather than interpolating strings inside a directive attribute. String interpolation inside code attributes is always one typo away from disaster.
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:P| Product | Affected Versions | Fixed Version |
|---|---|---|
Bagisto Bagisto | < 2.3.10 | 2.3.10 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-1336 |
| Attack Vector | Network |
| CVSS v4.0 | 7.4 |
| Privileges Required | Low (Authenticated User) |
| Exploit Status | PoC Available |
| Impact | High (Confidentiality/Integrity) |
Improper Neutralization of Special Elements Used in a Template Engine
Get the latest CVE analysis reports delivered to your inbox.