ServiceNow is one of those platforms where people assume the framework will save them. Sometimes it does. Sometimes it absolutely does not.
I’ve seen teams build solid workflows, ACLs, and integrations, then quietly ship stored XSS through a widget, a Jelly page, or a badly handled g_form.addInfoMessage() call. The dangerous part is that ServiceNow mixes platform abstractions with plain old web rendering. If user-controlled data lands in HTML, JavaScript, or the DOM without the right encoding, you still have the same browser-side problem you’d have in any other app.
This is the practical version of XSS in ServiceNow: where it shows up, what vulnerable code looks like, and how I’d fix it.
Why XSS still happens in ServiceNow
ServiceNow gives you a lot of server-side helpers and UI layers:
- UI Pages
- Jelly
- Service Portal widgets
- Catalog Client Scripts
- UI Scripts
- Notifications and email templates
- Form messages and field messages
- Custom processors and scripted endpoints rendering HTML
That variety is exactly why XSS keeps sneaking in. Different contexts need different protections. Escaping for HTML text is not the same as escaping for an HTML attribute, and neither is the same as safely inserting data into JavaScript.
The root cause is simple:
- untrusted input gets stored or reflected
- developers concatenate it into markup or scripts
- the browser executes it
In ServiceNow, “untrusted input” usually means:
- incident descriptions
- catalog variables
- user profile fields
- URL parameters
- query parameters in UI Pages or widgets
- data returned from GlideRecord or GlideAjax
- external integration payloads written into records
The common XSS categories in ServiceNow
You’ll usually hit one of three forms.
Stored XSS
Attacker submits payload into a record field, comment, variable, or profile field. Another user later views it in a portal widget, UI Page, or notification preview.
Example payload:
<img src=x onerror=alert('xss')>
If that lands in a description field and your custom widget renders it with ng-bind-html or raw DOM insertion, game over.
Reflected XSS
Attacker sends a crafted URL with a malicious parameter. A custom page or widget reads it and writes it into the response.
Example:
/nav_to.do?uri=u_custom_page.do%3Fmsg%3D%3Csvg/onload=alert(1)%3E
If the page prints msg directly into HTML, the payload runs.
DOM XSS
This one shows up a lot in Service Portal and custom client scripts. Data may be “safe” on the server but becomes dangerous when JavaScript inserts it with innerHTML, jQuery .html(), or string-built event handlers.
Vulnerable patterns in ServiceNow
1. UI Pages and Jelly output
Jelly is powerful and easy to abuse. The mistake is rendering variables directly into HTML or script blocks without context-aware escaping.
Vulnerable Jelly example
<j:jelly xmlns:j="jelly:core" xmlns:g="glide">
<g:evaluate var="jvar_msg" expression="RP.getParameterValue('msg')" />
<div>Message: ${jvar_msg}</div>
</j:jelly>
If msg contains HTML or script, it gets rendered.
Safer approach
Use output escaping appropriate to the context. If you only need text, render text, not HTML.
<j:jelly xmlns:j="jelly:core" xmlns:g="glide">
<g:evaluate var="jvar_msg" expression="RP.getParameterValue('msg')" />
<div>Message: <g:no_escape>false</g:no_escape>${jvar_msg}</div>
</j:jelly>
That said, Jelly escaping rules can get awkward fast. My rule is simple: if I’m building custom UI in ServiceNow, I avoid clever inline rendering and keep untrusted data out of raw markup wherever possible.
If you must place data into JavaScript, don’t do this:
<script>
var msg = "${jvar_msg}";
</script>
A payload containing quotes can break out of the string and execute code.
Better pattern: fetch data safely and assign it through APIs that treat it as text, not executable source.
2. Service Portal widgets
Service Portal is a frequent XSS hotspot because developers mix Angular templates, server data, and direct DOM updates.
Vulnerable widget template
<div ng-bind-html="c.data.user_comment"></div>
If user_comment is attacker-controlled and not sanitized correctly, stored XSS lands right in the portal.
Safer widget template
If you want plain text, bind plain text:
<div>{{::c.data.user_comment}}</div>
Or:
<div ng-bind="c.data.user_comment"></div>
Angular escapes HTML in normal interpolation and ng-bind. That’s usually what you want.
Vulnerable client controller
function($scope, $element) {
var c = this;
$element.find('.output').html(c.data.message);
}
```text
That is just raw HTML injection.
### Safer client controller
```javascript
function($scope, $element) {
var c = this;
$element.find('.output').text(c.data.message);
}
Use .text() when the content should be displayed, not interpreted.
If you really need to support limited formatting, sanitize on the server and on the client, and define a very narrow allowed subset. Personally, I treat “we need rich HTML from users” as a high-friction feature request for a reason.
3. Client scripts and form messages
Developers often trust record data too much when showing messages.
Vulnerable client script
function onLoad() {
var callerName = g_form.getValue('u_display_name');
g_form.addInfoMessage('Welcome back, ' + callerName);
}
```text
If `u_display_name` contains HTML and the message renderer interprets it as markup, you may have XSS depending on context and platform behavior.
### Safer client script
Encode before output if there’s any chance the sink renders HTML.
```javascript
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function onLoad() {
var callerName = g_form.getValue('u_display_name');
g_form.addInfoMessage('Welcome back, ' + escapeHtml(callerName));
}
I prefer avoiding HTML-capable message sinks entirely unless I know exactly how they render.
4. Scripted endpoints returning HTML
Some teams build lightweight custom pages or processors that return HTML snippets. That’s old-school reflected XSS territory.
Vulnerable Scripted Processor logic
var name = g_request.getParameter('name');
response.getStreamWriter().writeString('<div>Hello ' + name + '</div>');
```text
### Safer version
```javascript
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
var name = g_request.getParameter('name');
response.getStreamWriter().writeString('<div>Hello ' + escapeHtml(name) + '</div>');
Better yet, return JSON and let the client render with safe text binding.
5. URL parameters in portal or UI code
This one gets missed during reviews because developers think “it’s just a filter string” or “it’s only used for display.”
Vulnerable code
function() {
var params = new URLSearchParams(window.location.search);
document.getElementById('msg').innerHTML = params.get('msg');
}
```text
### Safer code
```javascript
function() {
var params = new URLSearchParams(window.location.search);
document.getElementById('msg').textContent = params.get('msg') || '';
}
If your first instinct is innerHTML, stop and ask whether you actually need HTML. Most of the time you do not.
Context-aware encoding matters
Here’s the part developers hate because it’s less convenient: there is no single “XSS escape” function that solves every case.
You need to match the encoding to the output context:
- HTML text node: escape
<,>,&, quotes - HTML attribute: attribute encoding, especially quotes
- JavaScript string: JavaScript string escaping
- URL parameter: URL encoding
- CSS: don’t inject untrusted data into CSS if you can avoid it
This fails:
<div data-name="{{userInput}}"></div>
<script>
var name = "{{userInput}}";
</script>
Even if the first context is handled, the script context has different rules. ServiceNow doesn’t magically flatten these differences.
Input validation helps, but output encoding is the real fix
You should still validate input:
- reject obviously malicious payloads where reasonable
- constrain fields to expected formats
- use choice lists, references, and typed fields instead of free-form HTML
- strip dangerous tags if a business requirement allows only limited markup
But validation is not your main XSS defense. Output encoding is.
Attackers are good at bypassing blacklist-style filters. If the browser sees executable markup or script in the wrong context, the payload wins.
Content Security Policy in ServiceNow
CSP is not a substitute for fixing the code, but I’m a big fan of using it as a backstop. A decent CSP can reduce the blast radius of XSS by blocking inline scripts, limiting script sources, and making data exfiltration harder.
For implementation details and policy design, the only non-official reference I’d point to is https://csp-guide.com. For platform-specific behavior and supported configuration, check official ServiceNow documentation.
A few practical CSP opinions:
- start with report-only if you have a lot of legacy UI code
- remove inline scripts where possible
- avoid
unsafe-inlineandunsafe-evalunless you absolutely cannot - review widgets and UI scripts that rely on dynamic script construction
If your ServiceNow customization depends on unsafe inline JavaScript everywhere, that’s not a CSP problem. That’s a codebase problem.
A practical review checklist
When I review ServiceNow code for XSS, I look for these sinks first:
innerHTML- jQuery
.html() ng-bind-html- string concatenation into
<script>blocks - Jelly variables printed into markup
- URL parameters echoed into UI
- form/info/error messages built from untrusted fields
- custom HTML responses from processors or scripted endpoints
Then I trace the source:
- request parameters
- GlideRecord field values
- catalog variables
- external integration payloads
- profile fields
- comments and work notes copied into custom UI
If untrusted data reaches a dangerous sink without the correct encoding, I flag it.
Safer defaults that save time
These defaults prevent a lot of pain:
- use
textContent, notinnerHTML - use jQuery
.text(), not.html() - use Angular interpolation or
ng-bindfor plain text - return JSON, not HTML snippets
- keep untrusted data out of inline JavaScript
- treat every record field as attacker-controlled unless proven otherwise
- enable CSP where supported and practical
That last one matters. ServiceNow is often treated like an internal platform, and internal apps are notorious for relaxed security assumptions. If an attacker gets a foothold through a low-privilege account, stored XSS in an internal workflow app can become a very efficient privilege escalation path.
XSS in ServiceNow is not exotic. It’s the same browser security bug wearing enterprise platform branding. The fix is also the same: validate inputs where useful, encode on output for the correct context, avoid dangerous DOM APIs, and don’t trust the platform to rescue unsafe custom code.