diff --git a/CHANGELOG.md b/CHANGELOG.md index 51c1cf55..4390b46e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,7 @@ ## 24.4.0 +! Minor breaking change ! For implementations using `salt` the browser compatability is tied to SubtleCrypto's `digest` method support + +- Added the `salt` init config flag to add checksums to requests (for secure contexts only) - Improved Health Check feature stability - Added support for Feedback Widget terms and conditions diff --git a/cypress/integration/bridge_utils.js b/cypress/integration/bridge_utils.js index 957a9a54..03495d34 100644 --- a/cypress/integration/bridge_utils.js +++ b/cypress/integration/bridge_utils.js @@ -16,7 +16,7 @@ function initMain(name, version) { } const SDK_NAME = "javascript_native_web"; -const SDK_VERSION = "23.12.6"; +const SDK_VERSION = "24.4.0"; // tests describe("Bridged SDK Utilities Tests", () => { diff --git a/cypress/integration/salt.js b/cypress/integration/salt.js new file mode 100644 index 00000000..4a7b1356 --- /dev/null +++ b/cypress/integration/salt.js @@ -0,0 +1,87 @@ +/* eslint-disable cypress/no-unnecessary-waiting */ +/* eslint-disable require-jsdoc */ +var Countly = require("../../lib/countly"); +// import * as Countly from "../../dist/countly_umd.js"; +var hp = require("../support/helper.js"); +const crypto = require("crypto"); + +function initMain(salt) { + Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + debug: true, + salt: salt + }); +} +const salt = "salt"; + +/** +* Tests for salt consists of: +* 1. Init without salt +* Create events and intercept the SDK requests. Request params should be normal and there should be no checksum +* 2. Init with salt +* Create events and intercept the SDK requests. Request params should be normal and there should be a checksum with length 64 +* 3. Node and Web Crypto comparison +* Compare the checksums calculated by node crypto api and SDK's web crypto api for the same data. Should be equal +*/ +describe("Salt Tests", () => { + it("Init without salt", () => { + hp.haltAndClearStorage(() => { + initMain(null); + var rqArray = []; + hp.events(); + cy.intercept("GET", "**/i?**", (req) => { + const { url } = req; + rqArray.push(url.split("?")[1]); // get the query string + }); + cy.wait(1000).then(() => { + cy.log(rqArray).then(() => { + for (const rq of rqArray) { + const paramsObject = hp.turnSearchStringToObject(rq); + hp.check_commons(paramsObject); + expect(paramsObject.checksum256).to.be.not.ok; + } + }); + }); + }); + }); + it("Init with salt", () => { + hp.haltAndClearStorage(() => { + initMain(salt); + var rqArray = []; + hp.events(); + cy.intercept("GET", "**/i?**", (req) => { + const { url } = req; + rqArray.push(url.split("?")[1]); + }); + cy.wait(1000).then(() => { + cy.log(rqArray).then(() => { + for (const rq of rqArray) { + const paramsObject = hp.turnSearchStringToObject(rq); + hp.check_commons(paramsObject); + expect(paramsObject.checksum256).to.be.ok; + expect(paramsObject.checksum256.length).to.equal(64); + // TODO: directly check the checksum with the node crypto api. Will need some extra decoding logic + } + }); + }); + }); + }); + it("Node and Web Crypto comparison", () => { + const hash = sha256("text" + salt); // node crypto api + Countly._internals.calculateChecksum("text", salt).then((hash2) => { // SDK uses web crypto api + expect(hash2).to.equal(hash); + }); + }); +}); + +/** + * Calculate sha256 hash of given data + * @param {*} data - data to hash + * @returns {string} - sha256 hash + */ +function sha256(data) { + const hash = crypto.createHash("sha256"); + hash.update(data); + return hash.digest("hex"); +} \ No newline at end of file diff --git a/cypress/support/helper.js b/cypress/support/helper.js index 84afa326..9c59a367 100644 --- a/cypress/support/helper.js +++ b/cypress/support/helper.js @@ -272,6 +272,46 @@ function validateDefaultUtmTags(aq, source, medium, campaign, term, content) { } } +/** + * Check common params for all requests + * @param {Object} paramsObject - object from search string + */ +function check_commons(paramsObject) { + expect(paramsObject.timestamp).to.be.ok; + expect(paramsObject.timestamp.toString().length).to.equal(13); + expect(paramsObject.hour).to.be.within(0, 23); + expect(paramsObject.dow).to.be.within(0, 7); + expect(paramsObject.app_key).to.equal(appKey); + expect(paramsObject.device_id).to.be.ok; + expect(paramsObject.sdk_name).to.equal("javascript_native_web"); + expect(paramsObject.sdk_version).to.be.ok; + expect(paramsObject.t).to.be.within(0, 3); + expect(paramsObject.av).to.equal(0); // av is 0 as we parsed parsable things + if (!paramsObject.hc) { // hc is direct request + expect(paramsObject.rr).to.be.above(-1); + } + expect(paramsObject.metrics._ua).to.be.ok; +} + +/** + * Turn search string into object with values parsed + * @param {String} searchString - search string + * @returns {object} - object from search string + */ +function turnSearchStringToObject(searchString) { + const searchParams = new URLSearchParams(searchString); + const paramsObject = {}; + for (const [key, value] of searchParams.entries()) { + try { + paramsObject[key] = JSON.parse(decodeURIComponent(value)); // try to parse value + } + catch (e) { + paramsObject[key] = decodeURIComponent(value); + } + } + return paramsObject; +} + module.exports = { haltAndClearStorage, sWait, @@ -285,5 +325,7 @@ module.exports = { testNormalFlow, interceptAndCheckRequests, validateDefaultUtmTags, - userDetailObj + userDetailObj, + check_commons, + turnSearchStringToObject }; \ No newline at end of file diff --git a/examples/style/style.css b/examples/style/style.css index b716ee6f..c7d47b4e 100644 --- a/examples/style/style.css +++ b/examples/style/style.css @@ -11,7 +11,6 @@ body { a { text-decoration: none; color: #000; - padding: 20px; } #header { diff --git a/lib/countly.js b/lib/countly.js index 25a71ae3..867cd160 100644 --- a/lib/countly.js +++ b/lib/countly.js @@ -196,7 +196,7 @@ statusCode: "cly_hc_status_code", errorMessage: "cly_hc_error_message" }); - var SDK_VERSION = "23.12.6"; + var SDK_VERSION = "24.4.0"; var SDK_NAME = "javascript_native_web"; // Using this on document.referrer would return an array with 17 elements in it. The 12th element (array[11]) would be the path we are looking for. Others would be things like password and such (use https://regex101.com/ to check more) @@ -349,14 +349,22 @@ * Convert JSON object to URL encoded query parameter string * @memberof Countly._internals * @param {Object} params - object with query parameters + * @param {String} salt - salt to be used for checksum calculation * @returns {String} URL encode query string */ - function prepareParams(params) { + function prepareParams(params, salt) { var str = []; for (var i in params) { str.push(i + "=" + encodeURIComponent(params[i])); } - return str.join("&"); + var data = str.join("&"); + if (salt) { + return calculateChecksum(data, salt).then(function (checksum) { + data += "&checksum256=" + checksum; + return data; + }); + } + return Promise.resolve(data); } /** @@ -470,6 +478,27 @@ return newStr; } + /** + * Calculates the checksum of the data with the given salt + * Uses SHA-256 algorithm with web crypto API + * Implementation based on https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest + * TODO: Turn to async function when we drop support for older browsers + * @param {string} data - data to be used for checksum calculation (concatenated query parameters) + * @param {string} salt - salt to be used for checksum calculation + * @returns {string} checksum in hex format + */ + function calculateChecksum(data, salt) { + var msgUint8 = new TextEncoder().encode(data + salt); // encode as (utf-8) Uint8Array + return crypto.subtle.digest("SHA-256", msgUint8).then(function (hashBuffer) { + // hash the message + var hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array + var hashHex = hashArray.map(function (b) { + return b.toString(16).padStart(2, "0"); + }).join(""); // convert bytes to hex string + return hashHex; + }); + } + /** * Polyfill to get closest parent matching nodeName * @param {HTMLElement} el - element from which to search @@ -914,6 +943,7 @@ this.maxStackTraceLinesPerThread = getConfig("max_stack_trace_lines_per_thread", ob, configurationDefaultValues.MAX_STACKTRACE_LINES_PER_THREAD); this.maxStackTraceLineLength = getConfig("max_stack_trace_line_length", ob, configurationDefaultValues.MAX_STACKTRACE_LINE_LENGTH); this.heatmapWhitelist = getConfig("heatmap_whitelist", ob, []); + self.salt = getConfig("salt", ob, null); self.hcErrorCount = getValueFromStorage(healthCheckCounterEnum.errorCount) || 0; self.hcWarningCount = getValueFromStorage(healthCheckCounterEnum.warningCount) || 0; self.hcStatusCode = getValueFromStorage(healthCheckCounterEnum.statusCode) || -1; @@ -1037,6 +1067,7 @@ if (ignoreReferrers) { log(logLevelEnums.DEBUG, "initialize, referrers to ignore :[" + JSON.stringify(ignoreReferrers) + "]"); } + log(logLevelEnums.DEBUG, "initialize, salt given:[" + !!self.salt + "]"); } catch (e) { log(logLevelEnums.ERROR, "initialize, Could not stringify some config object values"); } @@ -1369,6 +1400,7 @@ self.track_domains = undefined; self.storage = undefined; self.enableOrientationTracking = undefined; + self.salt = undefined; self.maxKeyLength = undefined; self.maxValueSize = undefined; self.maxSegmentationValues = undefined; @@ -4647,53 +4679,54 @@ log(logLevelEnums.DEBUG, "Sending XML HTTP request"); var xhr = new XMLHttpRequest(); params = params || {}; - var data = prepareParams(params); - var method = "GET"; - if (self.force_post || data.length >= 2000) { - method = "POST"; - } - if (method === "GET") { - xhr.open("GET", url + "?" + data, true); - } else { - xhr.open("POST", url, true); - xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); - } - for (var header in self.headers) { - xhr.setRequestHeader(header, self.headers[header]); - } - // fallback on error - xhr.onreadystatechange = function () { - if (this.readyState === 4) { - log(logLevelEnums.DEBUG, functionName + " HTTP request completed with status code: [" + this.status + "] and response: [" + this.responseText + "]"); - // response validation function will be selected to also accept JSON arrays if useBroadResponseValidator is true - var isResponseValidated; - if (useBroadResponseValidator) { - // JSON array/object both can pass - isResponseValidated = isResponseValidBroad(this.status, this.responseText); - } else { - // only JSON object can pass - isResponseValidated = isResponseValid(this.status, this.responseText); - } - if (isResponseValidated) { - if (typeof callback === "function") { - callback(false, params, this.responseText); - } - } else { - log(logLevelEnums.ERROR, functionName + " Invalid response from server"); - if (functionName === "send_request_queue") { - HealthCheck.saveRequestCounters(this.status, this.responseText); + prepareParams(params, self.salt).then(function (saltedData) { + var method = "GET"; + if (self.force_post || saltedData.length >= 2000) { + method = "POST"; + } + if (method === "GET") { + xhr.open("GET", url + "?" + saltedData, true); + } else { + xhr.open("POST", url, true); + xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); + } + for (var header in self.headers) { + xhr.setRequestHeader(header, self.headers[header]); + } + // fallback on error + xhr.onreadystatechange = function () { + if (this.readyState === 4) { + log(logLevelEnums.DEBUG, functionName + " HTTP request completed with status code: [" + this.status + "] and response: [" + this.responseText + "]"); + // response validation function will be selected to also accept JSON arrays if useBroadResponseValidator is true + var isResponseValidated; + if (useBroadResponseValidator) { + // JSON array/object both can pass + isResponseValidated = isResponseValidBroad(this.status, this.responseText); + } else { + // only JSON object can pass + isResponseValidated = isResponseValid(this.status, this.responseText); } - if (typeof callback === "function") { - callback(true, params, this.status, this.responseText); + if (isResponseValidated) { + if (typeof callback === "function") { + callback(false, params, this.responseText); + } + } else { + log(logLevelEnums.ERROR, functionName + " Invalid response from server"); + if (functionName === "send_request_queue") { + HealthCheck.saveRequestCounters(this.status, this.responseText); + } + if (typeof callback === "function") { + callback(true, params, this.status, this.responseText); + } } } + }; + if (method === "GET") { + xhr.send(); + } else { + xhr.send(saltedData); } - }; - if (method === "GET") { - xhr.send(); - } else { - xhr.send(data); - } + }); } catch (e) { // fallback log(logLevelEnums.ERROR, functionName + " Something went wrong while making an XML HTTP request: " + e); @@ -4725,52 +4758,54 @@ }; var body = null; params = params || {}; - if (self.force_post || prepareParams(params).length >= 2000) { - method = "POST"; - body = prepareParams(params); - } else { - url += "?" + prepareParams(params); - } - - // Add custom headers - for (var header in self.headers) { - headers[header] = self.headers[header]; - } - - // Make the fetch request - fetch(url, { - method: method, - headers: headers, - body: body - }).then(function (res) { - response = res; - return response.text(); - }).then(function (data) { - log(logLevelEnums.DEBUG, functionName + " Fetch request completed wit status code: [" + response.status + "] and response: [" + data + "]"); - var isResponseValidated; - if (useBroadResponseValidator) { - isResponseValidated = isResponseValidBroad(response.status, data); + prepareParams(params, self.salt).then(function (saltedData) { + if (self.force_post || saltedData.length >= 2000) { + method = "POST"; + body = saltedData; } else { - isResponseValidated = isResponseValid(response.status, data); + url += "?" + saltedData; } - if (isResponseValidated) { - if (typeof callback === "function") { - callback(false, params, data); + + // Add custom headers + for (var header in self.headers) { + headers[header] = self.headers[header]; + } + + // Make the fetch request + fetch(url, { + method: method, + headers: headers, + body: body + }).then(function (res) { + response = res; + return response.text(); + }).then(function (data) { + log(logLevelEnums.DEBUG, functionName + " Fetch request completed wit status code: [" + response.status + "] and response: [" + data + "]"); + var isResponseValidated; + if (useBroadResponseValidator) { + isResponseValidated = isResponseValidBroad(response.status, data); + } else { + isResponseValidated = isResponseValid(response.status, data); } - } else { - log(logLevelEnums.ERROR, functionName + " Invalid response from server"); - if (functionName === "send_request_queue") { - HealthCheck.saveRequestCounters(response.status, data); + if (isResponseValidated) { + if (typeof callback === "function") { + callback(false, params, data); + } + } else { + log(logLevelEnums.ERROR, functionName + " Invalid response from server"); + if (functionName === "send_request_queue") { + HealthCheck.saveRequestCounters(response.status, data); + } + if (typeof callback === "function") { + callback(true, params, response.status, data); + } } + })["catch"](function (error) { + log(logLevelEnums.ERROR, functionName + " Failed Fetch request: " + error); if (typeof callback === "function") { - callback(true, params, response.status, data); + callback(true, params); } - } - })["catch"](function (error) { - log(logLevelEnums.ERROR, functionName + " Failed Fetch request: " + error); - if (typeof callback === "function") { - callback(true, params); - } + }); }); } catch (e) { // fallback @@ -5215,6 +5250,7 @@ isResponseValidBroad: isResponseValidBroad, secureRandom: secureRandom, log: log, + calculateChecksum: calculateChecksum, checkIfLoggingIsOn: checkIfLoggingIsOn, getMetrics: getMetrics, getUA: getUA, diff --git a/package.json b/package.json index 8b968bb7..7ba38946 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "countly-sdk-web", - "version": "23.12.6", + "version": "24.4.0", "description": "Countly Web SDK", "main": "lib/countly.js", "directories": {