Securing SFMC Cloud Pages with SSJS


Securing SFMC Cloud Pages with SSJS

In Salesforce Marketing Cloud (SFMC), Cloud Pages are commonly used for developing and testing scripts. But once these pages are published, they become accessible to anyone, which can pose a security threat if they contain sensitive info like API tokens or personal information.

Additionally, if Cloud Pages are used as HTML forms without proper security measures, they can be exploited by spammers. This article focuses on securing Cloud Pages for development and internal use, not on other technical aspects like SSL, HTML headers, logging or captcha.

Options at hand

To mitigate these risks and bolster security, developers can implement various measures. These require a trade-off between security and convenience, as the more secure options often require additional steps both during development and during testing.

Another thing to consider is, that there are multiple scenarios to cover. From normal Cloud Page (forms & unsubcribe pages), Script Activities and Custom Cloud Page Resources APIs. Each of the following options is suitable for different scenarios.

Referer header

Referer header is a favourite technique. It allows access from a specific site only (or collection of sites).

It goes like this:

<script runat=server language="JavaScript">
	Platform.Load("core", "1.1.1");

	var ALLOWED_DOMAIN = 'my-cloudpages.com';

	try {
		var referer = Platform.Request.GetRequestHeader('referer') || 'unknown';

		// get only your domain, e.g.:
		var a = referer.split('/');
		var fqdn = a[a.length - 2]; // cloud pages cannot have slashed in the path, so the second to last item

		// check the referer:
		Platform.Variable.SetValue('allowed', fqdn == ALLOWED_DOMAIN);
	} catch (e) {
		Write('500: ' + e.message);
		Platform.Variable.SetValue('allowed', false);
	}
</script>
%%[ IF @allowed == true THEN ]%%
	You are authenticated.
%%[ ELSE ]%%
	You are not authenticated.
%%[ ENDIF ]%%

The issue with this is, that the referrer is a header configurably by client, and thus provides security only by obscurity.

You can get in rather simply:

  1. open postman
  2. enter a new request with target URL
  3. configure referer header to the cloud page.
  4. and send…

Hacking Referer header

This might be sufficient for some usecases and is definitely better than no security. However, it will not stop any real attacker. So I would advise agains using it as a single security measure.

We can do better!

IP Whitelisting

This is probably the easiest approach to securing Cloud Pages. By whitelisting the IP addresses of the developers, the pages can only be accessed from the specified IP addresses. Let’s take a look at how this can be implemented as you unfortunatelly cannot “just reuse” the Allowlists from SFMC.

Proposed Data Extension:

Name and Extension Key: devIPWhitelist Fields:

Example SSJS Script:

<script runat=server language="JavaScript">
	Platform.Load("core", "1.1.1");

	var IP_WHITELIST_DE = "devIPWhitelist";

	try {
		// get user IP:
		var clientIp = Platform.Request.ClientIP;
		// check whitelist:
		var allowed = Platform.Function.Lookup("devIPWhitelist", 'ip', ['ip', 'allowed'], [clientIp, true]) ? true : false;
		// set allow or not:
		Platform.Variable.SetValue('allowed', allowed);
	} catch(err) {
		Write("An error has occurred: " + String(err));
		Platform.Variable.SetValue('allowed', false);
	}
</script>
%%[ IF @allowed == true THEN ]%%
	You are authenticated.
%%[ ELSE ]%%
	You are not authenticated.
%%[ ENDIF ]%%

As you can see, this is a simple and effective way to secure the pages, but lacks when the developers are changing the location often without using VPN (or your Internet Provider reassigns your IP). Not to forget, your public IP could be shared.

Implementing IP ranges is a small task for you.

URL Token

This approach involves appending a randomly generated token to the URL, effectively creating a simplified authentication. This token can be checked against a stored value in the page to ensure that only authorized users can access the page.

<script runat=server language="JavaScript">
	Platform.Load("core", "1.1.1");

	var TOKEN = "9262b3d2-cb91-4c4a-aca2-6e9f7af8eeb4";

	try {
		// Get the token from the request:
		var allowed = Request.GetQueryStringParameter("dev-token") === TOKEN;
		// Set the allowed:
		Platform.Variable.SetValue('allowed', allowed);
	} catch(err) {
		Write("An error has occurred: " + String(err));
		Platform.Variable.SetValue('allowed', false);
	}
</script>
%%[ IF @allowed == true THEN ]%%
	You are authenticated.
%%[ ELSE ]%%
	You are not authenticated.
%%[ ENDIF ]%%

This is yet another effective way to secure the pages, but the token should be changed regularly to increase security. This is a great approach to use, if you want to handle your custom Cloud-page APIs, as you can switch the Platform.Request.GetQueryStringParameter() for Platform.Request.GetRequestHeader() and use it similar to API tokes. It becomes more complicated, once you start using this for multi-page forms as the token should be passed to all of the form pages.

Login Form:

This approach requires users to authenticate themselves before accessing the page via a custom login form (similar to HTTP Basic Auth).

<script runat="server">
  Platform.Load("core","1.1.1");

  var AUTH_DEFAULT = 'user:my-password-123';

  function getAuthFormValues() {
    var username = Platform.Request.GetFormField('username');
    var password = Platform.Request.GetFormField('password');
    return username + ':' + password;
  }

	try {
    var allowed = getAuthFormValues() === AUTH_DEFAULT;
		Variable.SetValue("allowed", allowed);
  } catch(err) {
		Write("An error has occurred: " + String(err));
		Platform.Variable.SetValue('allowed', false);
  }
</script>
%%[ IF @allowed == true THEN ]%%
	You are authenticated.
%%[ ELSE ]%%
	<h2>Login:</h2>
	<form method="post">
		<div>
			<label for="username">Username:</label>
			<input type="text" id="username" name="username" required>
		</div>
		<div>
			<label for="password">Password:</label>
			<input type="password" id="password" name="password" required>
		</div>
		<div>
			<input type="submit" value="Login">
		</div>
	</form>
%%[ ENDIF ]%%

This is also a rather simple solution, but without storing the credentials in the page, it will require developers to enter the credentials with every new page open. While it’s rather simple and secure, without those additional steps it will get tedious. Also, SSJS does not seem to offer any function to work with session storage, so it would require handling for example in cookies. Use Platform.Response.SetCookie() & Platform.Request.GetCookieValue(), but do not forget to hash the secrets!

Server-to-Server login

You might be asking: “why I wouldn’t use SFMC API credentials to do the heavy-lifting?” And that’s exaclty this (and the next) method. When using this proposed method, you use the server-to-server credentials to validate requests. In the context of the custom APIs, you could handle this as:

  1. Authenticate with Auth API of SFMC: the process begins with authenticating through the SFMC Auth endpoint to obtain a Bearer token, which serves as the token for subsequent step.
  2. Passing Token to Custom SFMC API: Once authenticated, the acquired token is transmitted as an Authentication Header in requests made to the custom SFMC API.
  3. Token Validation Check: A critical step involves validating the received token by initiating a basic request, such as GET /platform/v1/configcontext, to confirm its authenticity and validity.
  4. Authentication Handling: Based on the validation result, the system proceeds with the intended actions if the token is deemed valid; otherwise, it rejects unauthorized access attempts, safeguarding against potential security threats.

In following example, let’s take a look at steps 2-4:

<script runat=server language="JavaScript">
	Platform.Load("core", "1.1.1");

	var SFMC_SETUP = {
		// only the organization part of the api path ('https://{subdomain}.auth.marketingcloud.com)
		'subdomain': '{{MC_SUBDOMAIN}}'
	};

	var mcApiTest = {
		setup: function (token) {
			this.authUrl = 'https://' + SFMC_SETUP.subdomain + '.auth.marketingcloudapis.com';
			this.restUrl = 'https://' + SFMC_SETUP.subdomain + '.rest.marketingcloudapis.com';

			this.token = token;
		},

		request: function (httpMethod, path, body, urlParams, useAuthUrl) {
			var result = {
				'ok': false,
				'body': 'API request ' + path + ' failed.'
			};

			try {
				var u = useAuthUrl === true ? this.authUrl : this.restUrl;
				var requestUrl = this.buildUrl(u, path, urlParams, false);

				var req = new Script.Util.HttpRequest(requestUrl);
				req.emptyContentHandling = 0;
				req.retries = 2;
				req.continueOnError = true;
				req.contentType = "application/json";
				var token = 'Bearer ' + this.token;
				req.setHeader("Authorization", token);
				req.setHeader("Accept", "application/json");
				req.method = httpMethod;

				if (body !== undefined) {
					if (typeof (body) === 'string') {
						req.postData = body;
					} else {
						req.postData = Stringify(body);
					}
				}
				
				var httpResult = req.send();
				
				var status = Number(httpResult.statusCode);
				if (status == 200 || status == 201 || status == 202 || status == 204) {
					result.ok = true;
				}
				result.body = httpResult.content + '';
				result.status = status;
			} catch (err) {
				throw "Error in request(): " + err + " - message: " + err.message;
			}
			return result;
		},

		buildUrl: function (fqdn, path, params, encodeSpaces) {
			encodeSpaces = encodeSpaces === undefined ? true : encodeSpaces;
			if (!fqdn || !path) {
				throw 'BuildPath() missing fqdn or path.';
			}
			fqdn = fqdn.replace(/\/$/, '');
			path = path.replace(/^\//, '');
			path = path.replace(/\/$/, '');
			var url = fqdn + '/' + path;
			if (params && Object.items(params).length > 0) {
					var list = [];
					for (var key in params) {
							list.push(key + '=' + params[key]);
					}
					url += '?' + list.join('&');
					url = Platform.Function.UrlEncode(url, encodeSpaces);
			}
			return url;
		}
	}

	var apiResult = {
		status: 500,
		'body': 'API request failed.'
	};
	try {
		// Get the token from the request:
		var tokenHeader = Platform.Request.GetRequestHeader('Authorization');
		if (tokenHeader) {
			var token = tokenHeader.split(' ')[1];
			// Test the token:
			mcApiTest.setup(token);
			var result = mcApiTest.request('GET', '/platform/v1/tokenContext');

			apiResult.status = result.ok ? 200 : 401;
			apiResult.body = result.body;
			if (result.ok) {
				// TODO: continue your logic here and return the result in the apiResult object:
				apiResult.body = 'Hello World!';
			}
		} else {
			// not allowed:
			apiResult.status = 401;
			apiResult.body = 'No token provided.';
		}
		Platform.Variable.SetValue('apiResult', Stringify(apiResult));
	} catch(err) {
		apiResult.body = String(err);
		Platform.Variable.SetValue('apiResult', Stringify(apiResult));
	}
</script>
%%=v(@apiResult)=%%

This method is well-suited for scenarios where server-to-server communication is involved like custom SFMC APIs. However, it may not be ideal for user logins due to potential issues with sharing of client credentials or the need for individual user Installed Packages. Additionally, it’s essential to note that while this approach handles authentication for REST API calls made behind the scenes, it may not address other security considerations such as package scopes. And it requires an additional API call (most of the time).

Web App

This approach requires users to authenticate themselves using the SFMC login and Web App API Credentials behind the scenes. Following code lets you set you handle the Web Auth within a single Cloud Page. You also will need to set up Web App Installed Package.

<script runat=server language="JavaScript">
	Platform.Load("core", "1.1.1");

	var SFMC_SETUP = {
		// only the organization part of the api path ('https://{subdomain}.auth.marketingcloud.com)
		'subdomain': '{{MC_SUBDOMAIN}}',
		'clientId': '{{WEB_APP_CLIENT_ID}}',
		'clientSecret': '{{WEB_APP_CLIENT_SECRET}}',
		'redirectUrl': '{{WEB_APP_REDIRECT_URL}}' // can be itself
	};

	// UTILITIES:
	if (!Object.items) {
		Object.items = function (obj) {
			var arr = [], key;
			for (key in obj) {
				if (obj.hasOwnProperty(key)) {
					arr.push(obj[key]);
				}
			}
			return arr;
		};
	}

	var mcWebApp = {
		credentials: {
			grant_type: 'authorization_code',
			client_id: '',
			client_secret: ''
		},

		setup: function (code) {
			this.authUrl = 'https://' + SFMC_SETUP.subdomain + '.auth.marketingcloudapis.com';
			this.restUrl = 'https://' + SFMC_SETUP.subdomain + '.rest.marketingcloudapis.com';

			this.credentials.client_id = SFMC_SETUP.clientId;
			this.credentials.client_secret = SFMC_SETUP.clientSecret;
			this.credentials.code = code;
			this.credentials.redirect_uri = SFMC_SETUP.redirectUrl;
			// get the token:
			if (code && !this.getToken(code)) {
				throw 'Marketing Cloud API token was not found.';
			}
		},

		getToken: function (code) {
			var loginUrl = this.authUrl + '/v2/token';
			var body = this.credentials;
			var result = false;

			var req = new Script.Util.HttpRequest(loginUrl);
			req.emptyContentHandling = 0;
			req.retries = 2;
			req.continueOnError = true;
			req.contentType = "application/json; charset=utf-8";
			req.method = "POST";
			req.postData = Stringify(body);

			var result = req.send();

			if (Number(result.statusCode) === 200) {
				var responseObj = Platform.Function.ParseJSON(result.content + '');
				this.token = responseObj['access_token'];
				return true;
			} else {
				throw 'API token not obtained: ' + result.statusCode + '.';
				return false;
			}
		},

		request: function (httpMethod, path, body, urlParams, useAuthUrl) {
			var result = {
				'ok': false,
				'body': 'API request ' + path + ' failed.'
			};

			try {
				var u = useAuthUrl === true ? this.authUrl : this.restUrl;
				var requestUrl = this.buildUrl(u, path, urlParams, false);

				var req = new Script.Util.HttpRequest(requestUrl);
				req.emptyContentHandling = 0;
				req.retries = 2;
				req.continueOnError = true;
				req.contentType = "application/json";
				var token = 'Bearer ' + this.token;
				req.setHeader("Authorization", token);
				req.setHeader("Accept", "application/json");
				req.method = httpMethod;

				if (body !== undefined) {
					if (typeof (body) === 'string') {
						req.postData = body;
					} else {
						req.postData = Stringify(body);
					}
				}
				
				var httpResult = req.send();
				
				var status = Number(httpResult.statusCode);
				if (status == 200 || status == 201 || status == 202 || status == 204) {
					result.ok = true;
				}
				result.body = httpResult.content + '';
				result.status = status;
			} catch (err) {
				throw "Error in request(): " + err + " - message: " + err.message;
			}
			return result;
		},

		redirectToAuth: function() {
			var url = this.authUrl + '/v2/authorize?response_type=code&client_id=' + this.credentials.client_id + '&redirect_uri=' + mcWebApp.credentials.redirect_uri;
			url = Platform.Function.UrlEncode(url);
			Platform.Response.Redirect(url);
		},

		buildUrl: function (fqdn, path, params, encodeSpaces) {
			encodeSpaces = encodeSpaces === undefined ? true : encodeSpaces;
			if (!fqdn || !path) {
				throw 'BuildPath() missing fqdn or path.';
			}
			fqdn = fqdn.replace(/\/$/, '');
			path = path.replace(/^\//, '');
			path = path.replace(/\/$/, '');
			var url = fqdn + '/' + path;
			if (params && Object.items(params).length > 0) {
					var list = [];
					for (var key in params) {
							list.push(key + '=' + params[key]);
					}
					url += '?' + list.join('&');
					url = Platform.Function.UrlEncode(url, encodeSpaces);
			}
			return url;
		}
	}

	// AUTOMATION CODE:
	try {
		var code = Platform.Request.GetQueryStringParameter('code');
		
		if (code) {
			// verify code:
			mcWebApp.setup(code);
			/* *** APP LOGIC START *** */
			var body = undefined;
			var qParams = undefined;

			var res = mcWebApp.request('GET', '/v2/userinfo', body, undefined, true);

			if (res.ok) {
				var body = Platform.Function.ParseJSON(res.body);
				if (body && body.user && body.user.name) {
					Platform.Variable.SetValue('username', body.user.name);
				} else {
					Platform.Variable.SetValue('username', 'unknown');
				}
				Platform.Variable.SetValue('allowed', true);
			} else {
				throw "Error - API call failed:" + Stringify(res);
			}
			/* *** APP LOGIC END *** */
		} else {
			mcWebApp.setup();
			mcWebApp.redirectToAuth();
		}
	} catch (err) {
		Write("An error has occurred: " + String(err));
		Platform.Variable.SetValue('allowed', false);
	}
</script>
%%[ IF @allowed == true THEN ]%%
	You are authenticated as %%=v(@username)=%%.
%%[ ELSE ]%%
	You are not authenticated.
%%[ ENDIF ]%%

This is probably the most secure way to access the pages, but the login process is even more complicated to setup. Not to forget, that the auth code (query string code) is valid for much shorter time (and for a single login). It also requires the developers to have the Web App API credentials (and have those in the Cloud Page), which is not always practical.

A big advantage of this approach is, that the developers can use the same credentials as they use for the SFMC login and they do not need to remember another set of credentials for API calls. This cannot however be used for securing a custom API when being used in Server-to-server context.

Conclusion

Each of these methods has its own advantages and disadvantages. Overall, the biggest trade-off is between security and convenience. The more secure options require additional steps during development and testing, which can be a hassle. However, the security they provide is invaluable, especially when dealing with sensitive data.

One more thing that we need to mention is, that the developers should not forget to remove the security measures from some of the production scripts, as they are not necessary there and can cause issues with the page functionality (script activities, subscriber forms, etc.).

If you want to secure your Dev Cloud Pages without the hassle of manually implementing these security measures, you can use the SSJS Manager VSCode extension. It provides a simple and effective way to secure your pages, and it also helps with the deployment of the pages. You also do not need to worry about forgetting your security scripts within Script activities. As of v0.3.7 it already supports tokenization and login forms, and future versions will include even more options for securing your pages. Plus, your development environment will be protected with automatic security patches for Dev Pages.