← All security articles
CRITICALWashington+

GlideRecord vs GlideRecordSecure in Widget Server Scripts

Domain 10: Service Portal Security·

Because a widget server script runs with SYSTEM privilege, so a plain GlideRecord query inside one bypasses every table, row and field ACL on the instance and hands the results to the browser. GlideRecordSecure runs the same query as the logged-in user, honouring those ACLs, which is what you almost always want in a portal that unauthenticated visitors can reach. Treat the plain call as a deliberate privilege escalation each time it appears — sometimes it is the right one, but it should never be the default.

What This Is

Service Portal widget server scripts execute on the ServiceNow application server with SYSTEM-level privilege. When a widget server script instantiates new GlideRecord('table_name'), the query runs as the SYSTEM user -- bypassing every table-level, row-level, and field-level ACL configured on the instance. The results are then serialized into the widget's data object and transmitted to the client browser, where they are rendered by the AngularJS template.

GlideRecordSecure is the ACL-aware counterpart. When a widget server script uses new GlideRecordSecure('table_name'), the query respects the ACLs of the currently logged-in portal user (or the guest/anonymous user if unauthenticated). Records and fields that the user's roles do not permit are automatically excluded from the result set.

This is the number one Service Portal vulnerability. It affects every ServiceNow instance that has custom widgets or cloned OOB widgets with server scripts querying sensitive tables. The distinction is not cosmetic -- it is the difference between a portal that respects your ACL architecture and one that renders your entire ACL investment irrelevant.

How Widget Server Script Execution Works

Browser requests portal page
  -> Service Portal framework identifies widgets on the page
  -> For each widget, the server script executes:
       ┌─────────────────────────────────────────────┐
       │  Widget Server Script Context                │
       │                                              │
       │  Execution user: SYSTEM (gs.getUserName()    │
       │    returns the portal user, but GlideRecord  │
       │    queries run with SYSTEM privilege)         │
       │                                              │
       │  GlideRecord('table')                        │
       │    -> Queries as SYSTEM                      │
       │    -> ALL ACLs bypassed                      │
       │    -> Returns every matching record           │
       │    -> Returns every field on every record     │
       │                                              │
       │  GlideRecordSecure('table')                  │
       │    -> Queries as logged-in user              │
       │    -> Table ACLs enforced                    │
       │    -> Row-level ACLs enforced                │
       │    -> Field-level ACLs enforced              │
       │    -> Returns only permitted records/fields   │
       └─────────────────────────────────────────────┘
  -> data object serialized to JSON
  -> JSON sent to browser
  -> AngularJS template renders the data

The Scope of the Problem

Affected Component Risk
Custom sp_widget server scripts Developers default to GlideRecord because it is the standard server-side API; GlideRecordSecure is less familiar
Cloned OOB widgets ServiceNow's own built-in widgets (e.g., Simple List) historically used GlideRecord
Widget data controllers Angular providers that call server.get()/server.update() invoke server scripts with SYSTEM privilege
$sp.getWidget() invocations Client-side calls to load widgets dynamically still execute server scripts as SYSTEM
Scripted REST APIs called from widgets If a widget server script calls a Scripted REST API internally, that API also runs as SYSTEM

Vulnerable vs. Secure Pattern

Vulnerable -- GlideRecord (ACLs bypassed):

// Widget server script -- INSECURE
(function() {
    var gr = new GlideRecord('sys_user');
    gr.addQuery('active', true);
    gr.setLimit(100);
    gr.query();
    data.users = [];
    while (gr.next()) {
        data.users.push({
            name: gr.getDisplayValue('name'),
            email: gr.getValue('email'),
            phone: gr.getValue('phone'),
            manager: gr.getDisplayValue('manager'),
            department: gr.getDisplayValue('department'),
            // An attacker sees ALL active users regardless of ACLs
            cost_center: gr.getValue('cost_center'),
            employee_number: gr.getValue('employee_number')
        });
    }
})();

Secure -- GlideRecordSecure (ACLs enforced):

// Widget server script -- SECURE
(function() {
    var gr = new GlideRecordSecure('sys_user');
    gr.addQuery('active', true);
    gr.setLimit(100);
    gr.query();
    data.users = [];
    while (gr.next()) {
        data.users.push({
            name: gr.getDisplayValue('name'),
            email: gr.getValue('email'),
            // Only fields and records permitted by the user's ACLs are returned
            // Fields the user cannot read return empty strings
            phone: gr.getValue('phone'),
            manager: gr.getDisplayValue('manager'),
            department: gr.getDisplayValue('department')
        });
    }
})();

Why This Is Dangerous

Attack Scenario 1: HR Data Exfiltration via Widget Server Script

Precondition: A custom widget on the Employee Service Center portal queries the sn_hr_core_case table using GlideRecord to display the logged-in user's HR cases. The developer used GlideRecord because it was the familiar API and "worked fine in testing" (testing was done with an admin account).

Attack chain:

  1. An attacker authenticates to the portal as any user (or as an anonymous user if the portal page is public).

  2. The widget server script executes:

    // Intended: show MY HR cases. Actual: shows ALL HR cases
    (function() {
        var gr = new GlideRecord('sn_hr_core_case');
        gr.addQuery('opened_for', data.user_sys_id);
        gr.query();
        data.cases = [];
        while (gr.next()) {
            data.cases.push({
                number: gr.getValue('number'),
                subject: gr.getValue('subject'),
                // This field contains salary, disciplinary, medical info
                description: gr.getValue('description'),
                state: gr.getDisplayValue('state')
            });
        }
    })();
    
  3. The filter opened_for = data.user_sys_id limits results to the current user. However, data.user_sys_id is populated from the client-side $sp API. An attacker intercepts the request and replaces data.user_sys_id with another employee's sys_id -- or removes the filter entirely by manipulating the server.update() call.

  4. Because GlideRecord runs as SYSTEM, the modified query returns HR cases for any employee -- including salary disputes, disciplinary actions, medical leave requests, and termination proceedings.

Impact: Complete HR data breach. Salary information, medical records, disciplinary history, and employee grievances exposed to any authenticated portal user.

Attack Scenario 2: Password Hash Exposure via sys_user Widget

Precondition: A "User Profile" widget uses GlideRecord to fetch and display user profile information.

Attack chain:

  1. The widget server script queries sys_user with GlideRecord:

    (function() {
        var gr = new GlideRecord('sys_user');
        if (gr.get(data.sys_id)) {
            data.profile = {};
            // Developer selectively picks fields... but GlideRecord
            // returns the ENTIRE record to the server script context
            data.profile.name = gr.getValue('name');
            data.profile.email = gr.getValue('email');
            data.profile.title = gr.getValue('title');
        }
    })();
    
  2. The developer only pushes select fields to the data object -- but this is a false sense of security. An attacker who can manipulate the server script input (via server.get() with a crafted action parameter) or who finds a code path that serializes additional fields can access gr.getValue('user_password'), gr.getValue('password_needs_reset'), or any other field on sys_user.

  3. Even without direct manipulation, if the widget template uses {{c.data.profile}} to render the entire object (common in debugging or poorly written templates), all pushed fields are visible in the browser.

Impact: If the widget inadvertently exposes password hashes, an attacker can conduct offline brute-force attacks against every user account on the instance.

Attack Scenario 3: CMDB Infrastructure Reconnaissance

Precondition: An IT self-service portal widget queries cmdb_ci_server or cmdb_ci_ip_address using GlideRecord to display the user's assigned assets.

Attack chain:

  1. The widget runs GlideRecord queries against CMDB tables. Because GlideRecord bypasses ACLs, the server script has access to the entire CMDB -- every server, every IP address, every network device, every application, and every dependency relationship.

  2. An attacker manipulates the widget's input parameters to broaden the query, extracting:

    • Internal IP address ranges and network topology
    • Server operating system versions and patch levels
    • Application inventory with version numbers
    • Database server locations and connection strings stored in attributes
    • Cloud resource identifiers and configurations
  3. This data is used to plan targeted attacks against the organization's infrastructure.

Impact: Full infrastructure reconnaissance via CMDB. The attacker maps the organization's entire IT landscape without touching any production system directly.

How to Detect

Widget Server Script GlideRecord Audit

/*
 * PORTAL-001 Detection Script
 * Scans all sp_widget records for GlideRecord usage in server scripts
 * that should be using GlideRecordSecure. Identifies high-risk tables
 * and reports widgets with ACL bypass potential.
 *
 * Run as: Background script with admin role
 * Impact: Read-only, safe for production
 * Versions: Washington+
 */

gs.info('=== PORTAL-001: WIDGET SERVER SCRIPT GLIDERECORD AUDIT ===');
gs.info('Scan started: ' + new GlideDateTime().getDisplayValue());
gs.info('');

var totalWidgets = 0;
var widgetsWithGR = 0;
var widgetsWithGRS = 0;
var criticalFindings = 0;
var totalFindings = 0;

// High-risk tables that should always use GlideRecordSecure
var highRiskTables = [
    'sys_user', 'sys_user_group', 'sys_user_has_role', 'sys_user_grmember',
    'sn_hr_core_case', 'sn_hr_core_profile', 'sn_hr_le_case',
    'incident', 'change_request', 'problem',
    'cmdb_ci', 'cmdb_ci_server', 'cmdb_ci_ip_address', 'cmdb_ci_database',
    'sc_req_item', 'sc_request', 'sc_task',
    'kb_knowledge', 'sys_attachment',
    'customer_account', 'customer_contact', 'core_company',
    'sys_security_acl', 'sys_properties', 'sys_script',
    'oauth_entity', 'oauth_credential', 'discovery_credentials',
    'sn_customerservice_case', 'csm_consumer'
];

// 1. Scan all widget server scripts
gs.info('--- WIDGET SERVER SCRIPT ANALYSIS ---');
var widget = new GlideRecord('sp_widget');
widget.query();

while (widget.next()) {
    totalWidgets++;
    var serverScript = widget.getValue('script') || '';
    var widgetName = widget.getValue('name');
    var widgetId = widget.getValue('id');
    var sysId = widget.getUniqueValue();

    if (serverScript.length === 0) {
        continue; // No server script
    }

    var usesGR = serverScript.indexOf('new GlideRecord(') > -1 ||
                 serverScript.indexOf('new GlideRecord (') > -1;
    var usesGRS = serverScript.indexOf('GlideRecordSecure') > -1;

    if (usesGR) {
        widgetsWithGR++;

        // Extract table names from GlideRecord calls
        var grMatches = serverScript.match(/new GlideRecord\s*\(\s*['"]([^'"]+)['"]\s*\)/g) || [];
        var tables = [];
        for (var m = 0; m < grMatches.length; m++) {
            var tableMatch = grMatches[m].match(/['"]([^'"]+)['"]/);
            if (tableMatch) {
                tables.push(tableMatch[1]);
            }
        }

        // Check for high-risk tables
        var riskyTables = [];
        for (var t = 0; t < tables.length; t++) {
            for (var h = 0; h < highRiskTables.length; h++) {
                if (tables[t] === highRiskTables[h] ||
                    tables[t].indexOf(highRiskTables[h]) === 0) {
                    riskyTables.push(tables[t]);
                    break;
                }
            }
        }

        var severity = riskyTables.length > 0 ? 'CRITICAL' : 'WARNING';
        if (riskyTables.length > 0) {
            criticalFindings++;
        }
        totalFindings++;

        gs.info('[' + severity + '] Widget: ' + widgetName +
            ' (ID: ' + widgetId + ')');
        gs.info('  sys_id: ' + sysId);
        gs.info('  Uses GlideRecord: YES | Uses GlideRecordSecure: ' +
            (usesGRS ? 'ALSO YES (mixed)' : 'NO'));
        gs.info('  Tables queried via GlideRecord: ' + tables.join(', '));
        if (riskyTables.length > 0) {
            gs.info('  HIGH-RISK TABLES: ' + riskyTables.join(', '));
        }
        gs.info('  URL: sp_widget.do?sys_id=' + sysId);
        gs.info('');
    }

    if (usesGRS && !usesGR) {
        widgetsWithGRS++;
    }
}

// 2. Check widget instances on public pages
gs.info('--- WIDGETS ON PUBLIC PORTAL PAGES ---');
var publicPages = new GlideRecord('sp_page');
publicPages.addQuery('public', true);
publicPages.query();
var publicPageCount = 0;

while (publicPages.next()) {
    publicPageCount++;
    var pageId = publicPages.getUniqueValue();
    var pageTitle = publicPages.getValue('title') || publicPages.getValue('id');

    // Find widget instances on this page
    var instances = new GlideRecord('sp_instance');
    instances.addQuery('sp_page', pageId);
    instances.query();

    while (instances.next()) {
        var instWidget = instances.getValue('widget');
        var wdgt = new GlideRecord('sp_widget');
        if (wdgt.get(instWidget)) {
            var script = wdgt.getValue('script') || '';
            if (script.indexOf('new GlideRecord(') > -1 &&
                script.indexOf('GlideRecordSecure') === -1) {
                gs.info('[CRITICAL] Public page "' + pageTitle +
                    '" contains widget "' + wdgt.getValue('name') +
                    '" using GlideRecord without GlideRecordSecure');
                gs.info('  Page sys_id: ' + pageId +
                    ' | Widget sys_id: ' + instWidget);
                criticalFindings++;
                totalFindings++;
            }
        }
    }
}
gs.info('Public pages scanned: ' + publicPageCount);
gs.info('');

// 3. Check data table controllers
gs.info('--- ANGULAR PROVIDERS / DATA CONTROLLERS ---');
var angProv = new GlideRecord('sp_angular_provider');
angProv.query();
var providerCount = 0;
while (angProv.next()) {
    var provScript = angProv.getValue('script') || '';
    if (provScript.indexOf('GlideRecord') > -1 &&
        provScript.indexOf('GlideRecordSecure') === -1) {
        providerCount++;
        gs.info('[WARNING] Angular provider "' + angProv.getValue('name') +
            '" uses GlideRecord without GlideRecordSecure');
        totalFindings++;
    }
}
gs.info('Providers with GlideRecord (no GRS): ' + providerCount);
gs.info('');

// Summary
gs.info('=== SUMMARY ===');
gs.info('Total widgets scanned: ' + totalWidgets);
gs.info('Widgets using GlideRecord: ' + widgetsWithGR);
gs.info('Widgets using only GlideRecordSecure: ' + widgetsWithGRS);
gs.info('Total findings: ' + totalFindings);
gs.info('Critical findings: ' + criticalFindings);
gs.info('');
gs.info('RECOMMENDED ACTIONS:');
gs.info('1. Replace GlideRecord with GlideRecordSecure in ALL widget server scripts');
gs.info('2. Prioritize widgets on public portal pages (CRITICAL risk)');
gs.info('3. Prioritize widgets querying high-risk tables');
gs.info('4. Clone OOB widgets before modifying; never edit baseline widgets directly');
gs.info('5. Implement widget security review process for all new/modified widgets');
gs.info('6. Test ACL enforcement by accessing widgets as restricted users');

Remediation

Step 1: Inventory All Widgets Using GlideRecord (Immediate)

1. Run the detection script to generate a complete list of affected widgets
2. Categorize findings by severity:
   - CRITICAL: Widgets on public pages querying high-risk tables
   - HIGH: Widgets on authenticated pages querying high-risk tables
   - MEDIUM: Widgets querying non-sensitive tables with GlideRecord
   - LOW: Widgets with GlideRecord on internal-only portals
3. Create a remediation plan starting with CRITICAL findings

Step 2: Replace GlideRecord with GlideRecordSecure in Widget Server Scripts

For each affected widget:

1. Clone the widget (NEVER modify OOB widgets directly):
   - Navigate to: Service Portal > Widgets
   - Open the affected widget
   - Click: Clone (or Insert and Stay for custom widgets)
   - Name the clone: [original-name]-secured

2. In the cloned widget's server script, replace every instance of:
     new GlideRecord('table_name')
   with:
     new GlideRecordSecure('table_name')

3. Review the server script for additional issues:
   - Remove any addEncodedQuery() that relies on client input without validation
   - Remove any setLimit() that allows unlimited results
   - Validate all input.* and data.* parameters from client-side calls
   - Ensure server.get()/server.update() action handlers validate authorization

4. Update all sp_instance records to reference the cloned widget:
   - Navigate to: sp_instance list
   - Filter by: widget = [original widget sys_id]
   - Update each instance to: widget = [cloned widget sys_id]

Step 3: Validate Field-Level Exposure

Even with GlideRecordSecure, only push necessary fields to data:

BEFORE (excessive field exposure):
  data.record = {};
  var fields = gr.getFields();
  // Pushes ALL accessible fields to client

AFTER (selective field exposure):
  data.record = {
      name: gr.getDisplayValue('name'),
      email: gr.getValue('email')
      // Only fields the template actually renders
  };

This defense-in-depth approach limits what data reaches the browser
even if GlideRecordSecure permits access to additional fields.

Step 4: Enforce GlideRecordSecure via Instance Scan

1. Create an Instance Scan check:
   - Navigate to: Instance Scan > Scan Checks
   - Create a new check targeting the sp_widget table
   - Check condition: server script contains 'new GlideRecord('
     AND does NOT contain 'GlideRecordSecure'
   - Severity: Critical
   - Category: Security

2. Schedule the scan to run weekly:
   - Navigate to: Instance Scan > Scan Schedules
   - Add the GlideRecord widget check to the schedule
   - Configure notifications to the security team

3. Add the check to your update set review process:
   - Any update set containing sp_widget changes must pass the scan
   - Block promotion of widgets using GlideRecord without GlideRecordSecure

Post-Remediation Verification

/*
 * Verify GlideRecord has been replaced with GlideRecordSecure
 * in all widget server scripts
 *
 * Run as: Background script with admin role
 * Impact: Read-only, safe for production
 */

gs.info('=== PORTAL-001: POST-REMEDIATION CHECK ===');
var issues = 0;

var widget = new GlideRecord('sp_widget');
widget.query();
while (widget.next()) {
    var script = widget.getValue('script') || '';
    if (script.indexOf('new GlideRecord(') > -1 &&
        script.indexOf('GlideRecordSecure') === -1) {
        gs.info('FAIL: Widget "' + widget.getValue('name') +
            '" still uses GlideRecord without GlideRecordSecure');
        gs.info('  sys_id: ' + widget.getUniqueValue());
        issues++;
    }
}

// Check public page exposure
var pubPage = new GlideRecord('sp_page');
pubPage.addQuery('public', true);
pubPage.query();
while (pubPage.next()) {
    var inst = new GlideRecord('sp_instance');
    inst.addQuery('sp_page', pubPage.getUniqueValue());
    inst.query();
    while (inst.next()) {
        var w = new GlideRecord('sp_widget');
        if (w.get(inst.getValue('widget'))) {
            var s = w.getValue('script') || '';
            if (s.indexOf('new GlideRecord(') > -1 &&
                s.indexOf('GlideRecordSecure') === -1) {
                gs.info('FAIL: Public page "' + pubPage.getValue('title') +
                    '" still has widget with GlideRecord');
                issues++;
            }
        }
    }
}

if (issues === 0) {
    gs.info('PASS: All widgets use GlideRecordSecure');
} else {
    gs.info('FAILED: ' + issues + ' widgets still use GlideRecord');
}

Regulatory Impact

NIS2 Mapping

Article Requirement How GlideRecord in Widgets Violates It Evidence After Fix
Art.21§2(a) Risk analysis and information system security policies Widget server scripts running as SYSTEM user not identified as a risk; ACL bypass via GlideRecord not assessed in risk register Risk assessment documents GlideRecord vs GlideRecordSecure distinction; all widgets audited and remediated

DORA Mapping

Article Requirement How GlideRecord in Widgets Violates It Evidence After Fix
Art.9§4(c) Detection of anomalous activities and access policy violations No mechanism to detect when GlideRecord bypasses ACLs in widget context; data exfiltration via widgets goes unlogged GlideRecordSecure enforces ACLs with audit trail; Instance Scan check alerts on new GlideRecord usage in widgets

ISO 27001:2022 Mapping

Control Requirement How GlideRecord in Widgets Violates It Evidence After Fix
A.8.3 Information access restriction Widget server scripts bypass all ACL layers by using GlideRecord; information access restrictions are not enforced for portal users GlideRecordSecure enforced in all widget server scripts; field-level ACLs apply to portal data queries

Expert Notes

The Developer Trap: GlideRecord is the first API every ServiceNow developer learns. GlideRecordSecure is introduced later, often as an afterthought. The result: developers default to GlideRecord in widget server scripts because it "works" -- the queries return data, the widget renders correctly, and testing with admin accounts never reveals the ACL bypass. This is a training and culture problem as much as a technical one. Every ServiceNow development team must internalize the rule: in widget server scripts, GlideRecordSecure is the default; GlideRecord is the exception that requires explicit justification.

Performance Considerations: GlideRecordSecure is slower than GlideRecord because it evaluates ACLs for every record and field. Developers sometimes cite performance as the reason for using GlideRecord. This is not acceptable. If query performance is an issue, optimize the query (add indexes, narrow filters, reduce fields) rather than bypassing security. The performance overhead of ACL evaluation is the cost of correct access control.

server.get() and server.update() Are Attack Vectors: Widget client scripts call server.get({action: 'doSomething'}) to invoke server script logic. The action parameter and any additional data properties are attacker-controlled. The server script must validate the action, validate all input parameters, and use GlideRecordSecure for any data queries triggered by these calls.