From c234e209fbb375b8ef227fa1d27648d961bb496b Mon Sep 17 00:00:00 2001 From: Richard Lewis Date: Fri, 15 Dec 2017 15:24:22 +0000 Subject: [PATCH] Add postmessage api and move functions in to class --- src/MatrixPostMessageApi.js | 101 +++++++++++ src/WidgetMessaging.js | 348 +++++++++++++++++++----------------- 2 files changed, 286 insertions(+), 163 deletions(-) create mode 100644 src/MatrixPostMessageApi.js diff --git a/src/MatrixPostMessageApi.js b/src/MatrixPostMessageApi.js new file mode 100644 index 0000000000..dd65cbd8fa --- /dev/null +++ b/src/MatrixPostMessageApi.js @@ -0,0 +1,101 @@ +import Promise from "bluebird"; + + +function defer() { + let resolve, reject; + let isPending = true; + let promise = new Promise(function(...args) { + resolve = args[0]; + reject = args[1]; + }); + return { + resolve: function(...args) { + if (!isPending) { + return; + } + isPending = false; + resolve(args[0]); + }, + reject: function(...args) { + if (!isPending) { + return; + } + isPending = false; + reject(args[0]); + }, + isPending: function() { + return isPending; + }, + promise: promise, + }; +} + +// NOTE: PostMessageApi only handles message events with a data payload with a +// response field +export default class PostMessageApi { + constructor(targetWindow, timeoutMs) { + this._window = targetWindow || window.parent; // default to parent window + this._timeoutMs = timeoutMs || 5000; // default to 5s timer + this._counter = 0; + this._pending = { + // $ID: Deferred + }; + } + + start() { + addEventListener('message', this.getOnMessageCallback()); + } + + stop() { + removeEventListener('message', this.getOnMessageCallback()); + } + + // Somewhat convoluted so we can successfully capture the PostMessageApi 'this' instance. + getOnMessageCallback() { + if (this._onMsgCallback) { + return this._onMsgCallback; + } + let self = this; + this._onMsgCallback = function(ev) { + // THIS IS ALL UNSAFE EXECUTION. + // We do not verify who the sender of `ev` is! + let payload = ev.data; + // NOTE: Workaround for running in a mobile WebView where a + // postMessage immediately triggers this callback even though it is + // not the response. + if (payload.response === undefined) { + return; + } + let deferred = self._pending[payload._id]; + if (!deferred) { + return; + } + if (!deferred.isPending()) { + return; + } + delete self._pending[payload._id]; + deferred.resolve(payload); + }; + return this._onMsgCallback; + } + + exec(action, target) { + this._counter += 1; + target = target || "*"; + action._id = Date.now() + "-" + Math.random().toString(36) + "-" + this._counter; + let d = defer(); + this._pending[action._id] = d; + this._window.postMessage(action, target); + + if (this._timeoutMs > 0) { + setTimeout(function() { + if (!d.isPending()) { + return; + } + console.error("postMessage request timed out. Sent object: " + JSON.stringify(action)); + d.reject(new Error("Timed out")); + }, this._timeoutMs); + } + return d.promise; + } +} diff --git a/src/WidgetMessaging.js b/src/WidgetMessaging.js index 0f23413b5f..7cd18132af 100644 --- a/src/WidgetMessaging.js +++ b/src/WidgetMessaging.js @@ -112,14 +112,14 @@ Example: */ import URL from 'url'; +import dis from './dispatcher'; +import MatrixPostMessageApi from './MatrixPostMessageApi'; const WIDGET_API_VERSION = '0.0.1'; // Current API version const SUPPORTED_WIDGET_API_VERSIONS = [ '0.0.1', ]; -import dis from './dispatcher'; - if (!global.mxWidgetMessagingListenerCount) { global.mxWidgetMessagingListenerCount = 0; } @@ -127,176 +127,205 @@ if (!global.mxWidgetMessagingMessageEndpoints) { global.mxWidgetMessagingMessageEndpoints = []; } - -/** - * Register widget message event listeners - */ -function startListening() { - if (global.mxWidgetMessagingListenerCount === 0) { - window.addEventListener("message", onMessage, false); - } - global.mxWidgetMessagingListenerCount += 1; -} - -/** - * De-register widget message event listeners - */ -function stopListening() { - global.mxWidgetMessagingListenerCount -= 1; - if (global.mxWidgetMessagingListenerCount === 0) { - window.removeEventListener("message", onMessage); - } - if (global.mxWidgetMessagingListenerCount < 0) { - // Make an error so we get a stack trace - const e = new Error( - "WidgetMessaging: mismatched startListening / stopListening detected." + - " Negative count", - ); - console.error(e); - } -} - -/** - * Register a widget endpoint for trusted postMessage communication - * @param {string} widgetId Unique widget identifier - * @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host) - */ -function addEndpoint(widgetId, endpointUrl) { - const u = URL.parse(endpointUrl); - if (!u || !u.protocol || !u.host) { - console.warn("Invalid origin:", endpointUrl); - return; +export default class WidgetMessaging extends MatrixPostMessageApi { + constructor(targetWindow) { + super(targetWindow); } - const origin = u.protocol + '//' + u.host; - const endpoint = new WidgetMessageEndpoint(widgetId, origin); - if (global.mxWidgetMessagingMessageEndpoints) { - if (global.mxWidgetMessagingMessageEndpoints.some(function(ep) { - return (ep.widgetId === widgetId && ep.endpointUrl === endpointUrl); - })) { - // Message endpoint already registered - console.warn("Endpoint already registered"); + exec(action) { + return super.exec(action).then((data) => { + // check for errors and reject if found + if (data.response === undefined) { // null is valid + throw new Error("Missing 'response' field"); + } + if (data.response && data.response.error) { + const err = data.response.error; + const msg = String(err.message ? err.message : "An error was returned"); + if (err._error) { + console.error(err._error); + } + // Potential XSS attack if 'msg' is not appropriately sanitized, + // as it is untrusted input by our parent window (which we assume is Riot). + // We can't aggressively sanitize [A-z0-9] since it might be a translation. + throw new Error(msg); + } + // return the response field for the request + return data.response; + }); + } + + /** + * Register widget message event listeners + */ + startListening() { + if (global.mxWidgetMessagingListenerCount === 0) { + window.addEventListener("message", this.onMessage, false); + } + global.mxWidgetMessagingListenerCount += 1; + } + + /** + * De-register widget message event listeners + */ + stopListening() { + global.mxWidgetMessagingListenerCount -= 1; + if (global.mxWidgetMessagingListenerCount === 0) { + window.removeEventListener("message", this.onMessage); + } + if (global.mxWidgetMessagingListenerCount < 0) { + // Make an error so we get a stack trace + const e = new Error( + "WidgetMessaging: mismatched startListening / stopListening detected." + + " Negative count", + ); + console.error(e); + } + } + + /** + * Register a widget endpoint for trusted postMessage communication + * @param {string} widgetId Unique widget identifier + * @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host) + */ + addEndpoint(widgetId, endpointUrl) { + const u = URL.parse(endpointUrl); + if (!u || !u.protocol || !u.host) { + console.warn("Invalid origin:", endpointUrl); return; } - global.mxWidgetMessagingMessageEndpoints.push(endpoint); - } -} -/** - * De-register a widget endpoint from trusted communication sources - * @param {string} widgetId Unique widget identifier - * @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host) - * @return {boolean} True if endpoint was successfully removed - */ -function removeEndpoint(widgetId, endpointUrl) { - const u = URL.parse(endpointUrl); - if (!u || !u.protocol || !u.host) { - console.warn("Invalid origin"); - return; + const origin = u.protocol + '//' + u.host; + const endpoint = new WidgetMessageEndpoint(widgetId, origin); + if (global.mxWidgetMessagingMessageEndpoints) { + if (global.mxWidgetMessagingMessageEndpoints.some(function(ep) { + return (ep.widgetId === widgetId && ep.endpointUrl === endpointUrl); + })) { + // Message endpoint already registered + console.warn("Endpoint already registered"); + return; + } + global.mxWidgetMessagingMessageEndpoints.push(endpoint); + } } - const origin = u.protocol + '//' + u.host; - if (global.mxWidgetMessagingMessageEndpoints && global.mxWidgetMessagingMessageEndpoints.length > 0) { - const length = global.mxWidgetMessagingMessageEndpoints.length; - global.mxWidgetMessagingMessageEndpoints = global.mxWidgetMessagingMessageEndpoints.filter(function(endpoint) { - return (endpoint.widgetId != widgetId || endpoint.endpointUrl != origin); - }); - return (length > global.mxWidgetMessagingMessageEndpoints.length); - } - return false; -} + /** + * De-register a widget endpoint from trusted communication sources + * @param {string} widgetId Unique widget identifier + * @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host) + * @return {boolean} True if endpoint was successfully removed + */ + removeEndpoint(widgetId, endpointUrl) { + const u = URL.parse(endpointUrl); + if (!u || !u.protocol || !u.host) { + console.warn("Invalid origin"); + return; + } - -/** - * Handle widget postMessage events - * @param {Event} event Event to handle - * @return {undefined} - */ -function onMessage(event) { - if (!event.origin) { // Handle chrome - event.origin = event.originalEvent.origin; - } - - // Event origin is empty string if undefined - if ( - event.origin.length === 0 || - !trustedEndpoint(event.origin) || - event.data.api !== "widget" || - !event.data.widgetId - ) { - return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise - } - - const action = event.data.action; - const widgetId = event.data.widgetId; - if (action === 'content_loaded') { - dis.dispatch({ - action: 'widget_content_loaded', - widgetId: widgetId, - }); - sendResponse(event, {success: true}); - } else if (action === 'supported_api_versions') { - sendResponse(event, { - api: "widget", - supported_versions: SUPPORTED_WIDGET_API_VERSIONS, - }); - } else if (action === 'api_version') { - sendResponse(event, { - api: "widget", - version: WIDGET_API_VERSION, - }); - } else { - console.warn("Widget postMessage event unhandled"); - sendError(event, {message: "The postMessage was unhandled"}); - } -} - -/** - * Check if message origin is registered as trusted - * @param {string} origin PostMessage origin to check - * @return {boolean} True if trusted - */ -function trustedEndpoint(origin) { - if (!origin) { + const origin = u.protocol + '//' + u.host; + if (global.mxWidgetMessagingMessageEndpoints && global.mxWidgetMessagingMessageEndpoints.length > 0) { + const length = global.mxWidgetMessagingMessageEndpoints.length; + global.mxWidgetMessagingMessageEndpoints = global.mxWidgetMessagingMessageEndpoints. + filter(function(endpoint) { + return (endpoint.widgetId != widgetId || endpoint.endpointUrl != origin); + }); + return (length > global.mxWidgetMessagingMessageEndpoints.length); + } return false; } - return global.mxWidgetMessagingMessageEndpoints.some((endpoint) => { - return endpoint.endpointUrl === origin; - }); -} -/** - * Send a postmessage response to a postMessage request - * @param {Event} event The original postMessage request event - * @param {Object} res Response data - */ -function sendResponse(event, res) { - const data = JSON.parse(JSON.stringify(event.data)); - data.response = res; - event.source.postMessage(data, event.origin); -} + /** + * Handle widget postMessage events + * @param {Event} event Event to handle + * @return {undefined} + */ + onMessage(event) { + if (!event.origin) { // Handle chrome + event.origin = event.originalEvent.origin; + } -/** - * Send an error response to a postMessage request - * @param {Event} event The original postMessage request event - * @param {string} msg Error message - * @param {Error} nestedError Nested error event (optional) - */ -function sendError(event, msg, nestedError) { - console.error("Action:" + event.data.action + " failed with message: " + msg); - const data = JSON.parse(JSON.stringify(event.data)); - data.response = { - error: { - message: msg, - }, - }; - if (nestedError) { - data.response.error._error = nestedError; + // Event origin is empty string if undefined + if ( + event.origin.length === 0 || + !this.trustedEndpoint(event.origin) || + event.data.api !== "widget" || + !event.data.widgetId + ) { + return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise + } + + const action = event.data.action; + const widgetId = event.data.widgetId; + if (action === 'content_loaded') { + dis.dispatch({ + action: 'widget_content_loaded', + widgetId: widgetId, + }); + this.sendResponse(event, {success: true}); + } else if (action === 'supported_api_versions') { + this.sendResponse(event, { + api: "widget", + supported_versions: SUPPORTED_WIDGET_API_VERSIONS, + }); + } else if (action === 'api_version') { + this.sendResponse(event, { + api: "widget", + version: WIDGET_API_VERSION, + }); + } else { + console.warn("Widget postMessage event unhandled"); + this.sendError(event, {message: "The postMessage was unhandled"}); + } + } + + /** + * Check if message origin is registered as trusted + * @param {string} origin PostMessage origin to check + * @return {boolean} True if trusted + */ + trustedEndpoint(origin) { + if (!origin) { + return false; + } + + return global.mxWidgetMessagingMessageEndpoints.some((endpoint) => { + return endpoint.endpointUrl === origin; + }); + } + + /** + * Send a postmessage response to a postMessage request + * @param {Event} event The original postMessage request event + * @param {Object} res Response data + */ + sendResponse(event, res) { + const data = JSON.parse(JSON.stringify(event.data)); + data.response = res; + event.source.postMessage(data, event.origin); + } + + /** + * Send an error response to a postMessage request + * @param {Event} event The original postMessage request event + * @param {string} msg Error message + * @param {Error} nestedError Nested error event (optional) + */ + sendError(event, msg, nestedError) { + console.error("Action:" + event.data.action + " failed with message: " + msg); + const data = JSON.parse(JSON.stringify(event.data)); + data.response = { + error: { + message: msg, + }, + }; + if (nestedError) { + data.response.error._error = nestedError; + } + event.source.postMessage(data, event.origin); } - event.source.postMessage(data, event.origin); } + /** * Represents mapping of widget instance to URLs for trusted postMessage communication. */ @@ -317,10 +346,3 @@ class WidgetMessageEndpoint { this.endpointUrl = endpointUrl; } } - -export default { - startListening: startListening, - stopListening: stopListening, - addEndpoint: addEndpoint, - removeEndpoint: removeEndpoint, -};