A publicly accessible Service Portal turns every widget's server script into an unauthenticated attack surface reachable from the internet. Those server scripts run with elevated platform access, so a widget that trusts client-supplied input can be driven into returning records the visitor should never see, or into executing code. This is a configuration you own rather than a vendor defect, which means you can find it and close it today.
What This Is
ServiceNow Service Portal is a public-facing web framework that renders pages using widgets — self-contained components with both client-side (AngularJS) and server-side (Rhino JavaScript) code. When a portal is configured for public access (no authentication required), its widgets' server scripts become an unauthenticated attack surface exposed to the internet.
Architecture: How Service Portal Processes Requests
Internet User → HTTPS → ServiceNow Portal URL (/sp, /esc, /csm, custom)
→ Portal Page → Contains Widgets → Each Widget Has:
├── Client Script (AngularJS, runs in browser)
├── Server Script (Rhino JS, runs on ServiceNow server)
├── HTML Template
├── CSS
└── Widget Options (configurable parameters)
Server Script Execution Context:
- Runs with portal user's session (or guest/unauthenticated context)
- Has access to GlideRecord, GlideSystem, $sp API
- Can read/write data based on ACLs (or bypass if using GlideRecord)
- Processes `input` object from client → server communication
- Returns `data` object from server → client
Why Public Portals Are Dangerous
| Misconfiguration | Impact |
|---|---|
| Portal has no login requirement | Anyone on the internet can access portal pages and trigger widget server scripts |
Widget server scripts use GlideRecord (not GlideRecordSecure) |
Server-side queries bypass ACLs, returning data the guest user should not see |
Widget server scripts process input without validation |
Client-to-server communication channel becomes an injection vector |
Widget server scripts use GlideEvaluator with input data |
Unauthenticated remote code execution |
| Default widgets expose sensitive data | OOB widgets may return user directories, KB articles, catalog items with sensitive details |
| Multiple portals exist — some forgotten | Organizations create portals for projects, then abandon them without decommissioning |
| Portal public pages list is overly broad | Pages that should require authentication are listed as public |
The Public Pages Configuration
Each Service Portal has a public field listing page IDs accessible without authentication. By default, this typically includes:
- Login page
- Password reset
- KB article view
- Service catalog browsing
If an administrator adds pages with widgets that have powerful server scripts, those server scripts become publicly accessible.
Why This Is Dangerous
Attack Scenario: Unauthenticated Data Extraction via Widget Server Script
Precondition: A public portal includes a widget whose server script queries sensitive tables using GlideRecord (no ACL enforcement).
Attack chain:
Attacker discovers the public portal:
# Common ServiceNow portal URLs https://instance.service-now.com/sp https://instance.service-now.com/esc https://instance.service-now.com/csm https://instance.service-now.com/hrportal https://instance.service-now.com/custom_portal_namePortal names are discoverable through:
- DNS enumeration
- ServiceNow instance scanning tools
- Google dorks:
site:service-now.com inurl:/sp - Default portal names in documentation
Attacker identifies widgets on public pages: By viewing the page source or intercepting network requests, the attacker identifies widget API calls:
POST /api/now/sp/widget/widget_name Content-Type: application/json { "portal_id": "portal_sys_id", "instance_id": "widget_instance_sys_id", "input": { ... } }Attacker calls the widget API directly: The
/api/now/sp/widget/endpoint processes widget server scripts when called with appropriate parameters. Theinputobject is controlled by the attacker:POST /api/now/sp/widget/user_directory_widget { "input": { "search": "", "limit": 10000 } }The widget server script returns sensitive data:
// VULNERABLE: Widget server script with GlideRecord (no ACL) (function() { var search = input.search || ''; var gr = new GlideRecord('sys_user'); if (search) { gr.addQuery('name', 'CONTAINS', search); } gr.setLimit(input.limit || 50); gr.query(); data.users = []; while (gr.next()) { data.users.push({ name: gr.getDisplayValue('name'), email: gr.getValue('email'), phone: gr.getValue('phone'), department: gr.getDisplayValue('department'), manager: gr.getDisplayValue('manager'), title: gr.getValue('title') }); } })();Result: Unauthenticated attacker extracts the full user directory — names, emails, phone numbers, org structure — with a single HTTP request.
Impact: Full user directory exposure to the internet. This data enables targeted phishing, social engineering, and credential stuffing attacks.
Attack Scenario: Server-Side Code Execution via Widget Input Injection
Precondition: A widget server script uses GlideEvaluator or dynamic code construction with values from the input object.
Attack chain:
Attacker finds a widget that evaluates dynamic expressions:
// VULNERABLE: Widget server script with code execution sink (function() { if (input.action === 'calculate') { var expression = input.formula; var evaluator = new GlideEvaluator(); data.result = evaluator.evaluateString(expression); } })();Attacker sends a malicious payload:
POST /api/now/sp/widget/calculator_widget { "input": { "action": "calculate", "formula": "var gr = new GlideRecord('sys_user'); gr.addQuery('user_name', 'admin'); gr.query(); gr.next(); gr.getValue('user_password');" } }Result: Unauthenticated remote code execution. The injected code runs in the ServiceNow server context with the privileges of the portal's server-side execution environment.
Impact: Full unauthenticated RCE. The attacker can:
- Read any table (using
GlideRecord) - Modify data
- Create admin accounts
- Extract credentials and secrets
- Pivot to integrated systems
Attack Scenario: Widget Parameter Manipulation for ACL Bypass
Precondition: A widget uses options or input parameters to determine which table or records to query.
Attack chain:
The widget accepts a table parameter:
// VULNERABLE: Widget queries whatever table the client specifies (function() { var table = input.table || options.table || 'incident'; var query = input.query || ''; var gr = new GlideRecord(table); if (query) gr.addEncodedQuery(query); gr.setLimit(100); gr.query(); data.records = []; while (gr.next()) { data.records.push({ sys_id: gr.getUniqueValue(), display: gr.getDisplayValue() }); } })();Attacker specifies a sensitive table:
POST /api/now/sp/widget/dynamic_list { "input": { "table": "sys_user_has_role", "query": "role.name=admin" } }Result: The attacker queries
sys_user_has_roleto identify all admin users, then targets those accounts.
Attack Scenario: Abandoned Portal Discovery and Exploitation
Precondition: An organization created a portal for a project (e.g., COVID response, M&A integration, contractor onboarding) and abandoned it without decommissioning.
Attack chain:
- Attacker discovers the abandoned portal through DNS enumeration, URL brute-forcing, or Google cache
- The portal uses outdated widgets with known vulnerabilities or insecure patterns
- No monitoring exists for the abandoned portal — exploitation goes undetected
- The portal's service account may have broad permissions from its original purpose
Impact: Abandoned portals are the ServiceNow equivalent of forgotten web applications — full attack surface with zero monitoring.
How to Detect
Quick Manual Check
1. List all Service Portals:
URL: https://<instance>.service-now.com/sp_portal_list.do
2. For each portal, check:
- Is it public-facing? (Does it require authentication?)
- What pages are in the 'public' field?
- When was it last modified? (Abandoned portals)
- Who created it? (Still at the organization?)
3. For each public page, check:
- What widgets are on the page?
- Do widget server scripts use GlideRecord or GlideRecordSecure?
- Do widget server scripts process input without validation?
Detection Script — Portal Exposure Audit
/*
* PORTAL-001 Detection Script
* Audits all Service Portals for public exposure and widget security
*
* Run as: Background script with admin role
* Impact: Read-only, safe for production
* Versions: Helsinki+ (Service Portal introduction)
*/
gs.info('=== PORTAL-001: SERVICE PORTAL SECURITY AUDIT ===');
gs.info('Scan started: ' + new GlideDateTime().getDisplayValue());
gs.info('');
// Step 1: Enumerate all portals
gs.info('--- PORTAL INVENTORY ---');
var portals = new GlideRecord('sp_portal');
portals.query();
var portalCount = 0;
var publicPortals = [];
while (portals.next()) {
portalCount++;
var urlSuffix = portals.getValue('url_suffix');
var title = portals.getValue('title');
var publicPages = portals.getValue('public_pages') || '';
var loginPage = portals.getValue('login_page');
var isPublic = publicPages.length > 0;
var lastUpdated = portals.getValue('sys_updated_on');
gs.info('Portal: ' + title + ' (/' + urlSuffix + ')');
gs.info(' Public pages: ' + (publicPages || 'NONE'));
gs.info(' Login page: ' + (loginPage || 'NOT SET'));
gs.info(' Last updated: ' + lastUpdated);
if (isPublic) {
publicPortals.push({
sys_id: portals.getUniqueValue(),
title: title,
url: urlSuffix,
publicPages: publicPages
});
gs.info(' STATUS: HAS PUBLIC PAGES — REVIEW REQUIRED');
} else {
gs.info(' STATUS: No public pages configured');
}
gs.info('');
}
gs.info('Total portals: ' + portalCount);
gs.info('Portals with public pages: ' + publicPortals.length);
gs.info('');
// Step 2: For each public portal, audit widgets on public pages
gs.info('--- PUBLIC PAGE WIDGET AUDIT ---');
for (var p = 0; p < publicPortals.length; p++) {
var portal = publicPortals[p];
gs.info('Portal: ' + portal.title + ' (/' + portal.url + ')');
var pageIds = portal.publicPages.split(',');
for (var pg = 0; pg < pageIds.length; pg++) {
var pageId = pageIds[pg].trim();
if (!pageId) continue;
// Find the page
var page = new GlideRecord('sp_page');
if (page.get('id', pageId) || page.get(pageId)) {
gs.info(' Public page: ' + page.getValue('title') + ' (id: ' + pageId + ')');
// Find widgets on this page via sp_instance
var instances = new GlideRecord('sp_instance');
instances.addQuery('sp_page', page.getUniqueValue());
instances.query();
while (instances.next()) {
var widgetId = instances.getValue('sp_widget');
var widget = new GlideRecord('sp_widget');
if (widget.get(widgetId)) {
var serverScript = widget.getValue('script') || '';
var widgetName = widget.getValue('name');
var risks = [];
// Check for dangerous patterns in server script
if (serverScript.indexOf('GlideEvaluator') > -1 ||
serverScript.indexOf('evaluateString') > -1) {
risks.push('CRITICAL: Code execution sink (GlideEvaluator)');
}
if (serverScript.indexOf('GlideRecord') > -1 &&
serverScript.indexOf('GlideRecordSecure') === -1) {
risks.push('HIGH: Uses GlideRecord without ACL enforcement');
}
if (serverScript.indexOf('input.') > -1 &&
serverScript.indexOf('addEncodedQuery') > -1) {
risks.push('HIGH: Encoded query with user input (CODE-006)');
}
if (serverScript.indexOf('input.') > -1 &&
(serverScript.indexOf('GlideRecord(input') > -1 ||
serverScript.indexOf("GlideRecord('" + "' + input") > -1)) {
risks.push('CRITICAL: User-controlled table name');
}
// Check for sensitive table access
var sensitiveTables = ['sys_user', 'hr_case', 'sn_hr_core_profile',
'sys_user_has_role', 'discovery_credentials', 'change_request',
'cmdb_ci', 'ast_contract'];
for (var st = 0; st < sensitiveTables.length; st++) {
if (serverScript.indexOf(sensitiveTables[st]) > -1) {
risks.push('MEDIUM: Accesses sensitive table: ' + sensitiveTables[st]);
}
}
if (risks.length > 0) {
gs.info(' WIDGET: ' + widgetName);
for (var r = 0; r < risks.length; r++) {
gs.info(' → ' + risks[r]);
}
gs.info(' URL: sp_widget.do?sys_id=' + widgetId);
}
}
}
}
}
gs.info('');
}
// Step 3: Check for abandoned portals (not updated in 6+ months)
gs.info('--- ABANDONED PORTAL CHECK ---');
var sixMonthsAgo = new GlideDateTime();
sixMonthsAgo.addMonthsLocalTime(-6);
var abandoned = new GlideRecord('sp_portal');
abandoned.addQuery('sys_updated_on', '<', sixMonthsAgo);
abandoned.query();
while (abandoned.next()) {
gs.info('ABANDONED: ' + abandoned.getValue('title') +
' (/' + abandoned.getValue('url_suffix') + ')' +
' — last updated: ' + abandoned.getValue('sys_updated_on'));
}
gs.info('');
gs.info('=== RECOMMENDED ACTIONS ===');
gs.info('1. Review all widgets on public pages for GlideRecord → GlideRecordSecure migration');
gs.info('2. Validate input handling in all widget server scripts');
gs.info('3. Decommission abandoned portals');
gs.info('4. Minimize the public_pages list to only truly public pages');
gs.info('5. Implement rate limiting on portal API endpoints');
External Scan — Test Public Portal from Outside
# Test from outside the network (no VPN, no authentication)
1. Access the portal URL:
curl -v https://<instance>.service-now.com/sp
2. Check if it requires authentication or loads a page
3. If page loads, identify widgets:
- View page source
- Look for Angular widget references
- Check network requests for /api/now/sp/widget/ calls
4. Test widget API directly:
curl -X POST https://<instance>.service-now.com/api/now/sp/widget/<widget_id> \
-H "Content-Type: application/json" \
-d '{"input":{}}'
5. Check if data is returned without authentication
Remediation
Step 1: Immediate — Audit and Restrict Public Pages
1. Navigate to: sp_portal_list.do
2. For EACH portal:
a. Review the "Public pages" field
b. Remove any page that should require authentication
c. Only these pages should typically be public:
- Login page
- Password reset page
- Registration page (if applicable)
- Basic landing/home page (with no sensitive widgets)
3. For pages that MUST be public:
a. Review every widget on the page
b. Ensure widget server scripts use GlideRecordSecure
c. Ensure no sensitive data is returned to unauthenticated users
Step 2: Migrate Widget Server Scripts to GlideRecordSecure
// BEFORE (VULNERABLE): GlideRecord in widget — no ACL enforcement
(function() {
var gr = new GlideRecord('kb_knowledge');
gr.addQuery('workflow_state', 'published');
gr.query();
// Returns ALL published KB articles regardless of ACLs
// AFTER (SECURE): GlideRecordSecure — ACLs enforced
(function() {
var gr = new GlideRecordSecure('kb_knowledge');
gr.addQuery('workflow_state', 'published');
gr.query();
// Returns only KB articles the current user (or guest) can see per ACLs
})();
Step 3: Input Validation for All Widget Server Scripts
/*
* SECURE Widget server script template
* All public-facing widgets MUST follow this pattern
*/
(function() {
// 1. VALIDATE all input from client
if (!input) {
data.error = 'No input provided';
return;
}
// 2. ALLOWLIST valid actions
var VALID_ACTIONS = ['search', 'getDetails'];
if (VALID_ACTIONS.indexOf(input.action) === -1) {
data.error = 'Invalid action';
return;
}
// 3. SANITIZE and TYPE-CHECK input values
var searchTerm = String(input.search || '').substring(0, 100); // Length limit
searchTerm = searchTerm.replace(/[<>'"&;]/g, ''); // Strip dangerous chars
var limit = Math.min(parseInt(input.limit) || 20, 50); // Cap at 50
// 4. USE GlideRecordSecure — always
var gr = new GlideRecordSecure('kb_knowledge');
gr.addQuery('workflow_state', 'published');
if (searchTerm) {
// 5. USE addQuery — never addEncodedQuery with user input
gr.addQuery('short_description', 'CONTAINS', searchTerm);
}
gr.setLimit(limit);
gr.query();
data.articles = [];
while (gr.next()) {
// 6. RETURN only necessary fields — never return sys_id unless needed
data.articles.push({
title: gr.getDisplayValue('short_description'),
category: gr.getDisplayValue('kb_category')
// Do NOT return: text, sys_id, internal fields
});
}
})();
Step 4: Decommission Abandoned Portals
For each portal not updated in 6+ months:
1. Verify with the portal owner if it's still needed
2. If no owner can be identified → decommission
3. Decommission procedure:
a. Clear the "Public pages" field
b. Set the portal's login page to redirect to the main portal
c. Consider deactivating the portal record
d. Document the decommission in change management
4. Do NOT simply delete — there may be bookmarked URLs that should redirect gracefully
Step 5: Implement Portal Rate Limiting and Monitoring
1. Rate limiting:
- Set system property: glide.rest.rate_limit.enabled = true
- Apply rate limits to /api/now/sp/widget/ endpoints
- Lower limits for unauthenticated sessions (e.g., 10 requests/minute)
2. Monitoring:
- Alert on high-volume widget API calls from single IPs
- Alert on widget API calls to unexpected widget IDs
- Alert on widget API calls with suspicious input patterns
- Log all unauthenticated portal access for forensic review
3. WAF/CDN (if applicable):
- If instance is behind a WAF, add rules for:
- Rate limiting on /sp and /api/now/sp/ paths
- Block requests with GlideEvaluator, GlideRecord in POST body
- Geo-blocking if portal audience is known
Regulatory Impact
NIS2 Mapping
| Article | Requirement | How Portal Misconfiguration Violates It | Evidence After Fix |
|---|---|---|---|
| Art.21§2(a) | Risk analysis and IS security policies | Public-facing portals with RCE not in risk assessment | Portal inventory documented, exposure assessed, controls implemented |
| Art.21§2(e) | Security in development and maintenance | Portal widgets developed without secure coding standards | Secure widget template enforced, GlideRecordSecure mandatory, input validation |
| Art.23§1 | Incident notification | Unauthenticated RCE exploitation requires 24h CSIRT notification | Detection monitoring active, incident response playbook references Service Portal Misconfiguration — Server-Side Remote Code Execution |
DORA Mapping
| Article | Requirement | How Portal Misconfiguration Violates It | Evidence After Fix |
|---|---|---|---|
| Art.9§1 | ICT risk management framework | Public-facing attack surface not managed in ICT risk framework | Portal exposure documented in ICT risk register, annual review scheduled |
| Art.9§4(b) | Minimise impact of ICT risk | No controls limiting unauthenticated access to server-side code | GlideRecordSecure enforced, input validation mandatory, rate limiting active |
| Art.17§1 | ICT-related incident classification | Portal compromise constitutes major ICT incident | Incident classification criteria include portal exploitation |
ISO 27001:2022 Mapping
| Control | Requirement | How Portal Misconfiguration Violates It | Evidence After Fix |
|---|---|---|---|
| A.8.3 | Information access restriction | Public portal returns data without access control | GlideRecordSecure enforced, public pages minimized |
| A.8.9 | Configuration management | Portal configurations not managed with security baseline | Portal security baseline documented, annual review |
| A.8.28 | Secure coding | Widget server scripts use unsafe patterns | Secure coding template mandatory for all widgets |
Expert Notes
Practitioner annotations pending — article content has been technically validated.