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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

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-inline and unsafe-eval unless 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, not innerHTML
  • use jQuery .text(), not .html()
  • use Angular interpolation or ng-bind for 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.