/* 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, "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) { #ifdef MOZ_WIDGET_GTK let platform = "linux"; #elif XP_WIN let platform = "win"; #elif XP_MACOSX let platform = "macosx"; #elif MOZ_WIDGET_ANDROID let platform = "android"; #elif XP_LINUX let platform = "linux"; #else let platform = "other"; #endif 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 ${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 XP_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); #endif 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);