diff options
Diffstat (limited to 'toolkit/components/extensions/NativeMessaging.jsm')
-rw-r--r-- | toolkit/components/extensions/NativeMessaging.jsm | 443 |
1 files changed, 443 insertions, 0 deletions
diff --git a/toolkit/components/extensions/NativeMessaging.jsm b/toolkit/components/extensions/NativeMessaging.jsm new file mode 100644 index 000000000..3d8658a3f --- /dev/null +++ b/toolkit/components/extensions/NativeMessaging.jsm @@ -0,0 +1,443 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["HostManifestManager", "NativeApp"]; +/* globals NativeApp */ + +const {classes: Cc, interfaces: Ci, utils: Cu} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +const {EventEmitter} = Cu.import("resource://devtools/shared/event-emitter.js", {}); + +XPCOMUtils.defineLazyModuleGetter(this, "AppConstants", + "resource://gre/modules/AppConstants.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown", + "resource://gre/modules/AsyncShutdown.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionChild", + "resource://gre/modules/ExtensionChild.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Schemas", + "resource://gre/modules/Schemas.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Subprocess", + "resource://gre/modules/Subprocess.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "clearTimeout", + "resource://gre/modules/Timer.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "setTimeout", + "resource://gre/modules/Timer.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "WindowsRegistry", + "resource://gre/modules/WindowsRegistry.jsm"); + +const HOST_MANIFEST_SCHEMA = "chrome://extensions/content/schemas/native_host_manifest.json"; +const VALID_APPLICATION = /^\w+(\.\w+)*$/; + +// For a graceful shutdown (i.e., when the extension is unloaded or when it +// explicitly calls disconnect() on a native port), how long we give the native +// application to exit before we start trying to kill it. (in milliseconds) +const GRACEFUL_SHUTDOWN_TIME = 3000; + +// Hard limits on maximum message size that can be read/written +// These are defined in the native messaging documentation, note that +// the write limit is imposed by the "wire protocol" in which message +// boundaries are defined by preceding each message with its length as +// 4-byte unsigned integer so this is the largest value that can be +// represented. Good luck generating a serialized message that large, +// the practical write limit is likely to be dictated by available memory. +const MAX_READ = 1024 * 1024; +const MAX_WRITE = 0xffffffff; + +// Preferences that can lower the message size limits above, +// used for testing the limits. +const PREF_MAX_READ = "webextensions.native-messaging.max-input-message-bytes"; +const PREF_MAX_WRITE = "webextensions.native-messaging.max-output-message-bytes"; + +const REGPATH = "Software\\Mozilla\\NativeMessagingHosts"; + +this.HostManifestManager = { + _initializePromise: null, + _lookup: null, + + init() { + if (!this._initializePromise) { + let platform = AppConstants.platform; + if (platform == "win") { + this._lookup = this._winLookup; + } else if (platform == "macosx" || platform == "linux") { + let dirs = [ + Services.dirsvc.get("XREUserNativeMessaging", Ci.nsIFile).path, + Services.dirsvc.get("XRESysNativeMessaging", Ci.nsIFile).path, + ]; + this._lookup = (application, context) => this._tryPaths(application, dirs, context); + } else { + throw new Error(`Native messaging is not supported on ${AppConstants.platform}`); + } + this._initializePromise = Schemas.load(HOST_MANIFEST_SCHEMA); + } + return this._initializePromise; + }, + + _winLookup(application, context) { + const REGISTRY = Ci.nsIWindowsRegKey; + let regPath = `${REGPATH}\\${application}`; + let path = WindowsRegistry.readRegKey(REGISTRY.ROOT_KEY_CURRENT_USER, + regPath, "", REGISTRY.WOW64_64); + if (!path) { + path = WindowsRegistry.readRegKey(Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE, + regPath, "", REGISTRY.WOW64_64); + } + if (!path) { + return null; + } + return this._tryPath(path, application, context) + .then(manifest => manifest ? {path, manifest} : null); + }, + + _tryPath(path, application, context) { + return Promise.resolve() + .then(() => OS.File.read(path, {encoding: "utf-8"})) + .then(data => { + let manifest; + try { + manifest = JSON.parse(data); + } catch (ex) { + let msg = `Error parsing native host manifest ${path}: ${ex.message}`; + Cu.reportError(msg); + return null; + } + + let normalized = Schemas.normalize(manifest, "manifest.NativeHostManifest", context); + if (normalized.error) { + Cu.reportError(normalized.error); + return null; + } + manifest = normalized.value; + if (manifest.name != application) { + let msg = `Native host manifest ${path} has name property ${manifest.name} (expected ${application})`; + Cu.reportError(msg); + return null; + } + return normalized.value; + }).catch(ex => { + if (ex instanceof OS.File.Error && ex.becauseNoSuchFile) { + return null; + } + throw ex; + }); + }, + + _tryPaths: Task.async(function* (application, dirs, context) { + for (let dir of dirs) { + let path = OS.Path.join(dir, `${application}.json`); + let manifest = yield this._tryPath(path, application, context); + if (manifest) { + return {path, manifest}; + } + } + return null; + }), + + /** + * Search for a valid native host manifest for the given application name. + * The directories searched and rules for manifest validation are all + * detailed in the native messaging documentation. + * + * @param {string} application The name of the applciation to search for. + * @param {object} context A context object as expected by Schemas.normalize. + * @returns {object} The contents of the validated manifest, or null if + * no valid manifest can be found for this application. + */ + lookupApplication(application, context) { + if (!VALID_APPLICATION.test(application)) { + throw new Error(`Invalid application "${application}"`); + } + return this.init().then(() => this._lookup(application, context)); + }, +}; + +this.NativeApp = class extends EventEmitter { + /** + * @param {BaseContext} context The context that initiated the native app. + * @param {string} application The identifier of the native app. + */ + constructor(context, application) { + super(); + + this.context = context; + this.name = application; + + // We want a close() notification when the window is destroyed. + this.context.callOnClose(this); + + this.proc = null; + this.readPromise = null; + this.sendQueue = []; + this.writePromise = null; + this.sentDisconnect = false; + + this.startupPromise = HostManifestManager.lookupApplication(application, context) + .then(hostInfo => { + // Put the two errors together to not leak information about whether a native + // application is installed to addons that do not have the right permission. + if (!hostInfo || !hostInfo.manifest.allowed_extensions.includes(context.extension.id)) { + throw new context.cloneScope.Error(`This extension does not have permission to use native application ${application} (or the application is not installed)`); + } + + let command = hostInfo.manifest.path; + if (AppConstants.platform == "win") { + // OS.Path.join() ignores anything before the last absolute path + // it sees, so if command is already absolute, it remains unchanged + // here. If it is relative, we get the proper absolute path here. + command = OS.Path.join(OS.Path.dirname(hostInfo.path), command); + } + + let subprocessOpts = { + command: command, + arguments: [hostInfo.path], + workdir: OS.Path.dirname(command), + stderr: "pipe", + }; + return Subprocess.call(subprocessOpts); + }).then(proc => { + this.startupPromise = null; + this.proc = proc; + this._startRead(); + this._startWrite(); + this._startStderrRead(); + }).catch(err => { + this.startupPromise = null; + Cu.reportError(err instanceof Error ? err : err.message); + this._cleanup(err); + }); + } + + /** + * Open a connection to a native messaging host. + * + * @param {BaseContext} context The context associated with the port. + * @param {nsIMessageSender} messageManager The message manager used to send + * and receive messages from the port's creator. + * @param {string} portId A unique internal ID that identifies the port. + * @param {object} sender The object describing the creator of the connection + * request. + * @param {string} application The name of the native messaging host. + */ + static onConnectNative(context, messageManager, portId, sender, application) { + let app = new NativeApp(context, application); + let port = new ExtensionChild.Port(context, messageManager, [Services.mm], "", portId, sender, sender); + app.once("disconnect", (what, err) => port.disconnect(err)); + + /* eslint-disable mozilla/balanced-listeners */ + app.on("message", (what, msg) => port.postMessage(msg)); + /* eslint-enable mozilla/balanced-listeners */ + + port.registerOnMessage(msg => app.send(msg)); + port.registerOnDisconnect(msg => app.close()); + } + + /** + * @param {BaseContext} context The scope from where `message` originates. + * @param {*} message A message from the extension, meant for a native app. + * @returns {ArrayBuffer} An ArrayBuffer that can be sent to the native app. + */ + static encodeMessage(context, message) { + message = context.jsonStringify(message); + let buffer = new TextEncoder().encode(message).buffer; + if (buffer.byteLength > NativeApp.maxWrite) { + throw new context.cloneScope.Error("Write too big"); + } + return buffer; + } + + // A port is definitely "alive" if this.proc is non-null. But we have + // to provide a live port object immediately when connecting so we also + // need to consider a port alive if proc is null but the startupPromise + // is still pending. + get _isDisconnected() { + return (!this.proc && !this.startupPromise); + } + + _startRead() { + if (this.readPromise) { + throw new Error("Entered _startRead() while readPromise is non-null"); + } + this.readPromise = this.proc.stdout.readUint32() + .then(len => { + if (len > NativeApp.maxRead) { + throw new this.context.cloneScope.Error(`Native application tried to send a message of ${len} bytes, which exceeds the limit of ${NativeApp.maxRead} bytes.`); + } + return this.proc.stdout.readJSON(len); + }).then(msg => { + this.emit("message", msg); + this.readPromise = null; + this._startRead(); + }).catch(err => { + if (err.errorCode != Subprocess.ERROR_END_OF_FILE) { + Cu.reportError(err instanceof Error ? err : err.message); + } + this._cleanup(err); + }); + } + + _startWrite() { + if (this.sendQueue.length == 0) { + return; + } + + if (this.writePromise) { + throw new Error("Entered _startWrite() while writePromise is non-null"); + } + + let buffer = this.sendQueue.shift(); + let uintArray = Uint32Array.of(buffer.byteLength); + + this.writePromise = Promise.all([ + this.proc.stdin.write(uintArray.buffer), + this.proc.stdin.write(buffer), + ]).then(() => { + this.writePromise = null; + this._startWrite(); + }).catch(err => { + Cu.reportError(err.message); + this._cleanup(err); + }); + } + + _startStderrRead() { + let proc = this.proc; + let app = this.name; + Task.spawn(function* () { + let partial = ""; + while (true) { + let data = yield proc.stderr.readString(); + if (data.length == 0) { + // We have hit EOF, just stop reading + if (partial) { + Services.console.logStringMessage(`stderr output from native app ${app}: ${partial}`); + } + break; + } + + let lines = data.split(/\r?\n/); + lines[0] = partial + lines[0]; + partial = lines.pop(); + + for (let line of lines) { + Services.console.logStringMessage(`stderr output from native app ${app}: ${line}`); + } + } + }); + } + + send(msg) { + if (this._isDisconnected) { + throw new this.context.cloneScope.Error("Attempt to postMessage on disconnected port"); + } + if (Cu.getClassName(msg, true) != "ArrayBuffer") { + // This error cannot be triggered by extensions; it indicates an error in + // our implementation. + throw new Error("The message to the native messaging host is not an ArrayBuffer"); + } + + let buffer = msg; + + if (buffer.byteLength > NativeApp.maxWrite) { + throw new this.context.cloneScope.Error("Write too big"); + } + + this.sendQueue.push(buffer); + if (!this.startupPromise && !this.writePromise) { + this._startWrite(); + } + } + + // Shut down the native application and also signal to the extension + // that the connect has been disconnected. + _cleanup(err) { + this.context.forgetOnClose(this); + + let doCleanup = () => { + // Set a timer to kill the process gracefully after one timeout + // interval and kill it forcefully after two intervals. + let timer = setTimeout(() => { + this.proc.kill(GRACEFUL_SHUTDOWN_TIME); + }, GRACEFUL_SHUTDOWN_TIME); + + let promise = Promise.all([ + this.proc.stdin.close() + .catch(err => { + if (err.errorCode != Subprocess.ERROR_END_OF_FILE) { + throw err; + } + }), + this.proc.wait(), + ]).then(() => { + this.proc = null; + clearTimeout(timer); + }); + + AsyncShutdown.profileBeforeChange.addBlocker( + `Native Messaging: Wait for application ${this.name} to exit`, + promise); + + promise.then(() => { + AsyncShutdown.profileBeforeChange.removeBlocker(promise); + }); + + return promise; + }; + + if (this.proc) { + doCleanup(); + } else if (this.startupPromise) { + this.startupPromise.then(doCleanup); + } + + if (!this.sentDisconnect) { + this.sentDisconnect = true; + if (err && err.errorCode == Subprocess.ERROR_END_OF_FILE) { + err = null; + } + this.emit("disconnect", err); + } + } + + // Called from Context when the extension is shut down. + close() { + this._cleanup(); + } + + sendMessage(msg) { + let responsePromise = new Promise((resolve, reject) => { + this.once("message", (what, msg) => { resolve(msg); }); + this.once("disconnect", (what, err) => { reject(err); }); + }); + + let result = this.startupPromise.then(() => { + this.send(msg); + return responsePromise; + }); + + result.then(() => { + this._cleanup(); + }, () => { + // Prevent the response promise from being reported as an + // unchecked rejection if the startup promise fails. + responsePromise.catch(() => {}); + + this._cleanup(); + }); + + return result; + } +}; + +XPCOMUtils.defineLazyPreferenceGetter(NativeApp, "maxRead", PREF_MAX_READ, MAX_READ); +XPCOMUtils.defineLazyPreferenceGetter(NativeApp, "maxWrite", PREF_MAX_WRITE, MAX_WRITE); |