Request Authentication with Slack Signing Secrets
[UPDATE: In 2022, the main advice of this article (to use a processor instead of Scripted REST) has been deprecated for about the past 5 versions of ServiceNow. Eventually I learned that processors can be subject to performance issues. It is not recommended, especially for beginners, to approach it this way. I haven't tested Slack messaging in a long while so YMMV, but I can also say that Flow Designer and Integration Hub capabilities have progressed sufficiently to accommodate most needs. --Matt]
While there are some good blogs about integrating ServiceNow with Slack, I’m going to bring you a topic that gets very little, if any coverage: request authentication.
If you want proof that an incoming request is coming from Slack, the older method was Verification Tokens. When you first set up a webhook integration with Slack they’ll send you a request and ask you to parse out the token and send it back to them. That’s how you prove you control the endpoint that you’re asking them to notify. Then going forward, they’ll send that token value in every request so it lets you know that the request came from them.
Well, maybe. Technically it could mean that someone knows your token and is sending you a fake request and pretending to be Slack.
Request Authentication
Notice at the end of the image above it says that the Verification Token methodology is deprecated. Slack is moving to a more secure “request signing” idea that many sites are now using. This new method uses a timestamp and computed value that is specific to each request.
So how does it work? Basically, you set up a Signing Secret ahead of time, in the App Credentials settings of your Slack app. Then with each request Slack computes a hash of a current timestamp and the current request body, using your shared secret as the key. They call this computed hash the signature and they send it in the headers. When you receive the request, you extract their timestamp and request body, COMPUTE YOUR OWN HASH using the same algorithm and your secret as the key. If your computed value matches theirs, then you have decent proof that the request came from them. Because this method involves your secret, the exact request body, and a time value it’s a better indication that the request is authentic.
Here is a snapshot of the procedure lifted from the Slack API page, Verifying requests from Slack. That page also has a “Step-by-step walk-through for validating a request” with sample values you can shove into your code for testing, to verify that your hash algorithm works correctly. However at the time of this article, their example was too simplistic--having an empty message text. So their example side-steps any issues of Url encoding that you'll encounter in a request with complex message text.
And from that same page, this reminder for anyone with an existing integration that you need to adopt this technology pretty soon!
The Hashing Algorithm
For the signature Slack has chosen to use a “hash-based message authentication code” called HMAC-SHA256. Wikipedia tells us that an HMAC involves a cryptographic hash function and a secret key, and may be used to simultaneously verify both the data integrity and the authentication of a message. The cryptographic strength depends on the cryptographic strength of the hash function, the size of its hash output, and the size and quality of the key. (HMAC - Wikipedia)
I’m no expert on encryption. Sounds good to me.
Slack requires the “hex digest” version of this hash for use in the final comparison. So you need a library that creates an HMAC-SHA256 hash, accepting a key and a message, and dumping out a hexadecimal string representation.
The Obstacles
Adoption of this technology may not be trivial, depending on your integration architecture and familiarity with the various APIs. When you begin to implement Signing Secrets in your ServiceNow Slack integration, you may find it quite maddening at first. There are several obstacles to adopting this authentication mechanism that you’ll have to overcome.
#Issue 1: Content-Type in Scripted REST
The content-type used by Slack is not fully supported for scripted REST endpoints. Remember how you and Slack are agreeing to both compute a hash from the request body? Well in ServiceNow scripted REST, you don’t get access to the request body for content-type ‘application/x-www-form-urlencoded.’ You just don’t. The request body properties shown below don’t contain anything.
(UPDATE: Servicenow claims this content type is now supported as of London release. If true, then it might no longer be necessary to use a processor. I haven't tried to validate their claim. I added a London documentation link in the comments)
Even though ServiceNow Developer API docs say you can use dataStream in this situation it’s not correct, at least not for Slack.
But you need access to the request body for Signing Secrets to work. This means you must find a way to “rebuild” the raw request, at least the part that Slack uses for its hash. Slack warns us that we’ll have trouble if we don’t handle the request correctly:
#Issue 2: Nothing Native
ServiceNow doesn’t have a native hashing library to compute HmacSHA256 and return a hex digest. So it can’t accommodate Slack request signing. It does have some scoped classes in the vicinity of this topic, but they don’t quite fit. I’ll touch on them quickly.
CertificateEncryption: This class is able to “generate a hash for a certificate, sign data using a private key, and generate a message authentication code.” It accepts a key, and a flag saying that you want “HmacSHA256” algorithm. But it outputs the result in base64 format. The trouble is we need Hex format, and it doesn’t offer that. Performing hex digest on this base64 result is not the same as performing hex digest before making it into base64. Not useful for Slack.
GlideDigest: This class is able to “create a message digest from strings or input streams using MD5, SHA1, or SHA256 hash algorithms.” This does not create an HMAC since it doesn’t accept a secret key. It just runs a string through an algorithm to produce a different version of it. Also not useful for Slack.
#Issue 3: Rearchitecture
Scripted REST API simply cannot access the request body of a Slack request. If you’re already using Scripted REST you’ll have to change that architecture completely and make Slack send its traffic to a Processor, which is a different API and a different endpoint. More details in the Solution section.
The Solution
To make this work in ServiceNow you’ll need to call a consulting company. What? You are a consulting company? Okay, then we better figure out how to do this!
Here is the basic approach you need in order to overcome the obstacles.
- Use a Processor
- Because you cannot get the request body in the scripted REST API (RESTAPIRequest), since it’s not available for the content-type Slack uses. Also, if you enumerate request.queryParams they will come back in the wrong order, blowing any chance of matching a signature from Slack, who computed it from the request params in the correct order!
- A processor uses a different API, g_request (GlideServletRequest). With this API, you can call g_request.getParameterNames() to return the query parameters in their actual order.
- Because you cannot get the request body in the scripted REST API (RESTAPIRequest), since it’s not available for the content-type Slack uses. Also, if you enumerate request.queryParams they will come back in the wrong order, blowing any chance of matching a signature from Slack, who computed it from the request params in the correct order!
- Reconstruct the request body into a string
- concatenate all parameters as param=value¶m=value¶m=value... while doing the following:
- before concatenating, use encodeURIComponent() on the value
- before concatenating, replace some special characters for this content-type (e.g. SPACE with a plus sign). There are several character replacements needed, see the processor sample code. Thanks to Nate Burnett for sample code on this point.
- concatenate all parameters as param=value¶m=value¶m=value... while doing the following:
- Create a “base string” according to Slack specs
- concatenate “v0:” + timestamp header + “:” + request body
- Use CryptoJS library to compute hash
- I downloaded the zip of crypto-js v3.1.2 from Google code archives and made a script include out of the file: 'hmac-sha256.js' from the rollups folder. Once you’ve put CryptoJS into a script include, you can compute the signature with:
- * var hash = CryptoJS.HmacSHA256(base string, slack_signing_secret);
- This value already comes out in a hex digest format!
- This value already comes out in a hex digest format!
- Prepend “v0=” to your hash to make the proper format for a Slack signature
- I downloaded the zip of crypto-js v3.1.2 from Google code archives and made a script include out of the file: 'hmac-sha256.js' from the rollups folder. Once you’ve put CryptoJS into a script include, you can compute the signature with:
- Compare signatures
- Check your computed signature against the 'X-Slack-Signature' header value sent from Slack. If they match then you’ve built your request authentication correctly! Once you’ve tested this out, you can confidently reject a request that doesn’t match your computed signature. The message is not authentic!
- In the sample code section for the scoped script include, see my special note regarding Slack’s recommendation for comparing signatures.
- Check your computed signature against the 'X-Slack-Signature' header value sent from Slack. If they match then you’ve built your request authentication correctly! Once you’ve tested this out, you can confidently reject a request that doesn’t match your computed signature. The message is not authentic!
Sample Code
There are many ways you could implement this but I’ll post an example of a scoped Processor using a scoped Script include to validate the Slack signature, then writing all the request parameters to a table *if* the signature matches. I doubt that folks would want to store the request in a table if it doesn’t validate, but that’s for you to decide on your own. I’ll also post the CryptoJS class, in case it somehow becomes unavailable.
Please Note: This demo code is designed for a Slash command, and I used a table with all the columns necessary to store the parameters. You could also just store the whole request body in a single ‘payload’ column and parse it with a business rule. That would be more a little more versatile if the content of the request changes in the future! To use this example you'll need to create a system property to store your secret, and update the code with your property and table names.
Scoped Processor
A processor is a separate endpoint, an alternative to scripted REST. You have to configure your Slack app/Slash command to point to the Url path of your processor. For a scoped processor, don’t forget to use your full scope prefix! And don’t forget to change the tablename variable to your table name!
// DEMO Processor for Slack
(function process(g_request, g_response, g_processor) {
var app = "my App";
var tablename = "x_44121_slack_slash_command";
var arrayUtil = new global.ArrayUtil();
var paramBody = ""; //we will try to reconstruct the request body from params
var urlParamList = g_request.getParameterNames();
urlParamList = arrayUtil.ensureArray(urlParamList);
for (var p = 0; p < urlParamList.length; p++) {
var param = urlParamList[p];
var value = "" + g_request.getParameter(param);
var currentLine = param + "=" + encodeURIComponent(value);
currentLine = currentLine.replace(/%20/g, "+")
.replace(/!/g, '%21')
.replace(/'/g, '%27')
.replace(/\(/g, '%28')
.replace(/\)/g, '%29')
.replace(/\*/g, '%2A');
paramBody += (paramBody == "" ? currentLine : "&" + currentLine);
}
gs.info("Dump params: " + paramBody);
var timestamp = g_request.getHeader('X-Slack-Request-Timestamp');
var slack_signature = g_request.getHeader('X-Slack-Signature');
var auth = new SlackAuthenticator();
var isAuthentic = auth.validateSigningSecret(timestamp, slack_signature, paramBody);
if (!isAuthentic) {
gs.info('REST /reply: The Slack message is not authentic.');
}else{
gs.info('Storing request.');
var record = new GlideRecord(tablename);
//record.setValue("method",g_request.getMethod()); //fails
record.setValue("querystring",g_request.getQueryString());
record.setValue('x_slack_request_timestamp', timestamp);
record.setValue('x_slack_signature', slack_signature);
// we made this array earlier..
for (var p2 = 0; p2 < urlParamList.length; p2++) {
var parm = urlParamList[p2];
var val = "" + g_request.getParameter(parm);
record.setValue(formatToSNStandards(parm),val);
}
var urlheaderList = g_request.getHeaderNames();
urlheaderList = arrayUtil.ensureArray(urlheaderList);
for (var h = 0; h < urlheaderList.length; h++) {
var header = urlheaderList[h];
var headerValue = g_request.getHeader(header);
record.setValue(formatToSNStandards(header),headerValue);
}
record.insert();
}
// Add your code here
g_response.setStatus(200);
})(g_request, g_response, g_processor);
function formatToSNStandards(str){
str=str.replace("-", "_");
//return "u_"+str; //REM since my scoped app didn't prefix the column names
return str;
}
Scoped Script Include
Don't forget to change the system property name in this code, to match yours! Special note: Although I did a simple comparison of signatures using “”, for even higher security Slack recommends you implement a custom “hmac compare” function instead of just using “”. Apparently how quickly the comparison fails can be measured by a serious attacker, and gives him hints about how good his guesses are. So there exist special comparison functions which ‘pad’ the timing of a failed match, to obscure it.
var SlackAuthenticator = Class.create();
SlackAuthenticator.prototype = {
initialize: function() {
},
log_count: 0,
slack_signing_secret: gs.getProperty('x_44121_slack.slack_app_secret') || "",
validateSigningSecret: function (timestamp, slack_signature, request_body) {
if (this.slack_signing_secret == "") {
this.log("Unable to retrieve property: slack_signing_secret");
return false;
}
this.log("Script include received request body: " + request_body);
var arrSecret = [];
var my_signature;
//basesetring = version + timestamp + body, use an array to join
arrSecret.push("v0");
arrSecret.push(timestamp);
arrSecret.push(request_body);
var sig_basestring = arrSecret.join(":");
this.log("Basestring: " + sig_basestring);
var hash = CryptoJS.HmacSHA256(sig_basestring, this.slack_signing_secret);
if (hash) {
// CryptoJS hashing includes the hexdigest already, just return hash
my_signature = "v0=" + hash;
}
if (my_signature == slack_signature) {
this.log("The signature " + my_signature + " matches Slack sig: " + slack_signature);
return true;
}else{
this.log("The signature " + my_signature + " --FAIL-- match with Slack sig: " + slack_signature);
return false;
}
},
log: function(text) {
this.log_count += 1;
gs.info(this.log_count + ":" + text, "SlackAuthenticator");
},
type: 'SlackAuthenticator'
};
The CryptoJS Class
The source of this code is discussed in ‘The Solution’ section earlier in this document. I put it in a scoped script include called CryptoJS.
/*
CryptoJS v3.1.2
code.google.com/p/crypto-js
(c) 2009-2013 by Jeff Mott. All rights reserved.
code.google.com/p/crypto-js/wiki/License
*/
var CryptoJS=CryptoJS||function(h,s){var f={},g=f.lib={},q=function(){},m=g.Base={extend:function(a){q.prototype=this;var c=new q;a&&c.mixIn(a);c.hasOwnProperty("init")||(c.init=function(){c.$super.init.apply(this,arguments)});c.init.prototype=c;c.$super=this;return c},create:function(){var a=this.extend();a.init.apply(a,arguments);return a},init:function(){},mixIn:function(a){for(var c in a)a.hasOwnProperty(c)&&(this[c]=a[c]);a.hasOwnProperty("toString")&&(this.toString=a.toString)},clone:function(){return this.init.prototype.extend(this)}},
r=g.WordArray=m.extend({init:function(a,c){a=this.words=a||[];this.sigBytes=c!=s?c:4*a.length},toString:function(a){return(a||k).stringify(this)},concat:function(a){var c=this.words,d=a.words,b=this.sigBytes;a=a.sigBytes;this.clamp();if(b%4)for(var e=0;e<a;e++)c[b+e>>>2]|=(d[e>>>2]>>>24-8*(e%4)&255)<<24-8*((b+e)%4);else if(65535<d.length)for(e=0;e<a;e+=4)c[b+e>>>2]=d[e>>>2];else c.push.apply(c,d);this.sigBytes+=a;return this},clamp:function(){var a=this.words,c=this.sigBytes;a[c>>>2]&=4294967295<<
32-8*(c%4);a.length=h.ceil(c/4)},clone:function(){var a=m.clone.call(this);a.words=this.words.slice(0);return a},random:function(a){for(var c=[],d=0;d<a;d+=4)c.push(4294967296*h.random()|0);return new r.init(c,a)}}),l=f.enc={},k=l.Hex={stringify:function(a){var c=a.words;a=a.sigBytes;for(var d=[],b=0;b<a;b++){var e=c[b>>>2]>>>24-8*(b%4)&255;d.push((e>>>4).toString(16));d.push((e&15).toString(16))}return d.join("")},parse:function(a){for(var c=a.length,d=[],b=0;b<c;b+=2)d[b>>>3]|=parseInt(a.substr(b,
2),16)<<24-4*(b%8);return new r.init(d,c/2)}},n=l.Latin1={stringify:function(a){var c=a.words;a=a.sigBytes;for(var d=[],b=0;b<a;b++)d.push(String.fromCharCode(c[b>>>2]>>>24-8*(b%4)&255));return d.join("")},parse:function(a){for(var c=a.length,d=[],b=0;b<c;b++)d[b>>>2]|=(a.charCodeAt(b)&255)<<24-8*(b%4);return new r.init(d,c)}},j=l.Utf8={stringify:function(a){try{return decodeURIComponent(escape(n.stringify(a)))}catch(c){throw Error("Malformed UTF-8 data");}},parse:function(a){return n.parse(unescape(encodeURIComponent(a)))}},
u=g.BufferedBlockAlgorithm=m.extend({reset:function(){this._data=new r.init;this._nDataBytes=0},_append:function(a){"string"==typeof a&&(a=j.parse(a));this._data.concat(a);this._nDataBytes+=a.sigBytes},_process:function(a){var c=this._data,d=c.words,b=c.sigBytes,e=this.blockSize,f=b/(4*e),f=a?h.ceil(f):h.max((f|0)-this._minBufferSize,0);a=f*e;b=h.min(4*a,b);if(a){for(var g=0;g<a;g+=e)this._doProcessBlock(d,g);g=d.splice(0,a);c.sigBytes-=b}return new r.init(g,b)},clone:function(){var a=m.clone.call(this);
a._data=this._data.clone();return a},_minBufferSize:0});g.Hasher=u.extend({cfg:m.extend(),init:function(a){this.cfg=this.cfg.extend(a);this.reset()},reset:function(){u.reset.call(this);this._doReset()},update:function(a){this._append(a);this._process();return this},finalize:function(a){a&&this._append(a);return this._doFinalize()},blockSize:16,_createHelper:function(a){return function(c,d){return(new a.init(d)).finalize(c)}},_createHmacHelper:function(a){return function(c,d){return(new t.HMAC.init(a,
d)).finalize(c)}}});var t=f.algo={};return f}(Math);
(function(h){for(var s=CryptoJS,f=s.lib,g=f.WordArray,q=f.Hasher,f=s.algo,m=[],r=[],l=function(a){return 4294967296*(a-(a|0))|0},k=2,n=0;64>n;){var j;a:{j=k;for(var u=h.sqrt(j),t=2;t<=u;t++)if(!(j%t)){j=!1;break a}j=!0}j&&(8>n&&(m[n]=l(h.pow(k,0.5))),r[n]=l(h.pow(k,1/3)),n++);k++}var a=[],f=f.SHA256=q.extend({_doReset:function(){this._hash=new g.init(m.slice(0))},_doProcessBlock:function(c,d){for(var b=this._hash.words,e=b[0],f=b[1],g=b[2],j=b[3],h=b[4],m=b[5],n=b[6],q=b[7],p=0;64>p;p++){if(16>p)a[p]=
c[d+p]|0;else{var k=a[p-15],l=a[p-2];a[p]=((k<<25|k>>>7)^(k<<14|k>>>18)^k>>>3)+a[p-7]+((l<<15|l>>>17)^(l<<13|l>>>19)^l>>>10)+a[p-16]}k=q+((h<<26|h>>>6)^(h<<21|h>>>11)^(h<<7|h>>>25))+(h&m^~h&n)+r[p]+a[p];l=((e<<30|e>>>2)^(e<<19|e>>>13)^(e<<10|e>>>22))+(e&f^e&g^f&g);q=n;n=m;m=h;h=j+k|0;j=g;g=f;f=e;e=k+l|0}b[0]=b[0]+e|0;b[1]=b[1]+f|0;b[2]=b[2]+g|0;b[3]=b[3]+j|0;b[4]=b[4]+h|0;b[5]=b[5]+m|0;b[6]=b[6]+n|0;b[7]=b[7]+q|0},_doFinalize:function(){var a=this._data,d=a.words,b=8*this._nDataBytes,e=8*a.sigBytes;
d[e>>>5]|=128<<24-e%32;d[(e+64>>>9<<4)+14]=h.floor(b/4294967296);d[(e+64>>>9<<4)+15]=b;a.sigBytes=4*d.length;this._process();return this._hash},clone:function(){var a=q.clone.call(this);a._hash=this._hash.clone();return a}});s.SHA256=q._createHelper(f);s.HmacSHA256=q._createHmacHelper(f)})(Math);
(function(){var h=CryptoJS,s=h.enc.Utf8;h.algo.HMAC=h.lib.Base.extend({init:function(f,g){f=this._hasher=new f.init;"string"==typeof g&&(g=s.parse(g));var h=f.blockSize,m=4*h;g.sigBytes>m&&(g=f.finalize(g));g.clamp();for(var r=this._oKey=g.clone(),l=this._iKey=g.clone(),k=r.words,n=l.words,j=0;j<h;j++)k[j]^=1549556828,n[j]^=909522486;r.sigBytes=l.sigBytes=m;this.reset()},reset:function(){var f=this._hasher;f.reset();f.update(this._iKey)},update:function(f){this._hasher.update(f);return this},finalize:function(f){var g=
this._hasher;f=g.finalize(f);g.reset();return g.finalize(this._oKey.clone().concat(f))}})})();
Table for storing Requests
Set whatever length you think you need for the columns. I just guessed so I won’t bore you with my choices. If you decide to use a table like this, see the link in my Reference section on "How to integrate ServiceNow & Slack’s Slash Commands." That author gives good recommendations for securing this table since it will contain sensitive data!
TABLE:
x_44121_slack_slash_command
COLUMNS:
String token
String team_id
String team_domain
String channel_id
String channel_name
String user_id
String user_name
String command
String text
Url response_url
String trigger_id
String method
String querystring
String host
String user_agent
String accept_encoding
String accept
String content_length
String content_type
String x_forwarded_proto
String x_forwarded_host
String x_forwarded_for
String x_slack_request_timestamp
String x_slack_signature
References
Finally, here are some pages that I used as references. The 'How to integrate..' shows the details of setting up for a processor on the Slack side.
https://www.servicenow.com/community/developer-articles/request-authentication-with-slack-signing-secrets/ta-p/2299187