RemoteAgent.sys.mjs (15436B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 const lazy = {}; 6 7 ChromeUtils.defineESModuleGetters(lazy, { 8 Deferred: "chrome://remote/content/shared/Sync.sys.mjs", 9 HttpServer: "chrome://remote/content/server/httpd.sys.mjs", 10 Log: "chrome://remote/content/shared/Log.sys.mjs", 11 PollPromise: "chrome://remote/content/shared/Sync.sys.mjs", 12 RecommendedPreferences: 13 "chrome://remote/content/shared/RecommendedPreferences.sys.mjs", 14 WebDriverBiDi: "chrome://remote/content/webdriver-bidi/WebDriverBiDi.sys.mjs", 15 }); 16 17 ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); 18 19 const DEFAULT_HOST = "localhost"; 20 const DEFAULT_PORT = 9222; 21 22 // Adds various command-line arguments as environment variables to preserve 23 // their values when the application is restarted internally. 24 const ENV_ALLOW_SYSTEM_ACCESS = "MOZ_REMOTE_ALLOW_SYSTEM_ACCESS"; 25 26 const SHARED_DATA_ACTIVE_KEY = "RemoteAgent:Active"; 27 28 const isRemote = 29 Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT; 30 31 class RemoteAgentParentProcess { 32 #allowHosts; 33 #allowOrigins; 34 #allowSystemAccess; 35 #browserStartupFinished; 36 #enabled; 37 #host; 38 #port; 39 #server; 40 41 #webDriverBiDi; 42 43 constructor() { 44 this.#allowHosts = null; 45 this.#allowOrigins = null; 46 this.#allowSystemAccess = Services.env.exists(ENV_ALLOW_SYSTEM_ACCESS); 47 this.#browserStartupFinished = lazy.Deferred(); 48 this.#enabled = false; 49 50 // Configuration for httpd.js 51 this.#host = DEFAULT_HOST; 52 this.#port = DEFAULT_PORT; 53 this.#server = null; 54 55 // Supported protocols 56 this.#webDriverBiDi = null; 57 } 58 59 get allowHosts() { 60 if (this.#allowHosts !== null) { 61 return this.#allowHosts; 62 } 63 64 if (this.#server) { 65 // If the server is bound to a hostname, not an IP address, return it as 66 // allowed host. 67 const hostUri = Services.io.newURI(`https://${this.#host}`); 68 if (!this.#isIPAddress(hostUri)) { 69 return [RemoteAgent.host]; 70 } 71 72 // Following Bug 1220810 localhost is guaranteed to resolve to a loopback 73 // address (127.0.0.1 or ::1) unless network.proxy.allow_hijacking_localhost 74 // is set to true, which should not be the case. 75 const loopbackAddresses = ["127.0.0.1", "[::1]"]; 76 77 // If the server is bound to an IP address and this IP address is a localhost 78 // loopback address, return localhost as allowed host. 79 if (loopbackAddresses.includes(this.#host)) { 80 return ["localhost"]; 81 } 82 } 83 84 // Otherwise return an empty array. 85 return []; 86 } 87 88 get allowOrigins() { 89 return this.#allowOrigins; 90 } 91 92 get allowSystemAccess() { 93 return this.#allowSystemAccess; 94 } 95 96 set allowSystemAccess(value) { 97 // Return early if system access is already marked being allowed. 98 // There is also no possibility to disallow once it got allowed except 99 // quitting Firefox and starting it again. 100 if (this.#allowSystemAccess || !value) { 101 return; 102 } 103 104 this.#allowSystemAccess = true; 105 Services.env.set(ENV_ALLOW_SYSTEM_ACCESS, "1"); 106 } 107 108 /** 109 * A promise that resolves when the initial application window has been opened. 110 * 111 * @returns {Promise} 112 * Promise that resolves when the initial application window is open. 113 */ 114 get browserStartupFinished() { 115 return this.#browserStartupFinished.promise; 116 } 117 get enabled() { 118 return this.#enabled; 119 } 120 121 get host() { 122 return this.#host; 123 } 124 125 get port() { 126 return this.#port; 127 } 128 129 get running() { 130 return !!this.#server && !this.#server.isStopped(); 131 } 132 133 get scheme() { 134 return this.#server?.identity.primaryScheme; 135 } 136 137 get server() { 138 return this.#server; 139 } 140 141 /** 142 * Syncs the WebDriver active flag with the web content processes. 143 * 144 * @param {boolean} value - Flag indicating if Remote Agent is active or not. 145 */ 146 updateWebdriverActiveFlag(value) { 147 Services.ppmm.sharedData.set(SHARED_DATA_ACTIVE_KEY, value); 148 Services.ppmm.sharedData.flush(); 149 } 150 151 get webDriverBiDi() { 152 return this.#webDriverBiDi; 153 } 154 155 /** 156 * Handle the --remote-debugging-port command line argument. 157 * 158 * @param {nsICommandLine} cmdLine 159 * Instance of the command line interface. 160 * 161 * @returns {boolean} 162 * Return `true` if the command line argument has been found. 163 */ 164 #handleRemoteDebuggingPortFlag(cmdLine) { 165 let enabled = false; 166 167 try { 168 // Catch cases when the argument, and a port have been specified. 169 const port = cmdLine.handleFlagWithParam("remote-debugging-port", false); 170 if (port !== null) { 171 enabled = true; 172 173 // In case of an invalid port keep the default port 174 const parsed = Number(port); 175 if (!isNaN(parsed)) { 176 this.#port = parsed; 177 } 178 } 179 } catch (e) { 180 // If no port has been given check for the existence of the argument. 181 enabled = cmdLine.handleFlag("remote-debugging-port", false); 182 } 183 184 return enabled; 185 } 186 187 #handleAllowHostsFlag(cmdLine) { 188 try { 189 const hosts = cmdLine.handleFlagWithParam("remote-allow-hosts", false); 190 return hosts.split(","); 191 } catch (e) { 192 return null; 193 } 194 } 195 196 #handleAllowOriginsFlag(cmdLine) { 197 try { 198 const origins = cmdLine.handleFlagWithParam( 199 "remote-allow-origins", 200 false 201 ); 202 return origins.split(","); 203 } catch (e) { 204 return null; 205 } 206 } 207 208 #handleAllowSystemAccessFlag(cmdLine) { 209 try { 210 return cmdLine.handleFlag("remote-allow-system-access", false); 211 } catch (e) { 212 return false; 213 } 214 } 215 216 /** 217 * Check if the provided URI's host is an IP address. 218 * 219 * @param {nsIURI} uri 220 * The URI to check. 221 * @returns {boolean} 222 */ 223 #isIPAddress(uri) { 224 try { 225 // getBaseDomain throws an explicit error if the uri host is an IP address. 226 Services.eTLD.getBaseDomain(uri); 227 } catch (e) { 228 return e.result == Cr.NS_ERROR_HOST_IS_IP_ADDRESS; 229 } 230 return false; 231 } 232 233 async #listen(port) { 234 if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) { 235 throw Components.Exception( 236 "May only be instantiated in parent process", 237 Cr.NS_ERROR_LAUNCHED_CHILD_PROCESS 238 ); 239 } 240 241 if (this.running) { 242 return; 243 } 244 245 // Try to resolve localhost to an IPv4 and / or IPv6 address so that the 246 // server can be started on a given IP. Only fallback to use localhost if 247 // the hostname cannot be resolved. 248 // 249 // Note: This doesn't force httpd.js to use the dual stack support. 250 let isIPv4Host = false; 251 try { 252 const addresses = await this.#resolveHostname(DEFAULT_HOST); 253 lazy.logger.trace( 254 `Available local IP addresses: ${addresses.join(", ")}` 255 ); 256 257 // Prefer IPv4 over IPv6 addresses. 258 const addressesIPv4 = addresses.filter(value => !value.includes(":")); 259 isIPv4Host = !!addressesIPv4.length; 260 if (isIPv4Host) { 261 this.#host = addressesIPv4[0]; 262 } else { 263 this.#host = addresses.length ? addresses[0] : DEFAULT_HOST; 264 } 265 } catch (e) { 266 this.#host = DEFAULT_HOST; 267 268 lazy.logger.debug( 269 `Failed to resolve hostname "localhost" to IP address: ${e.message}` 270 ); 271 } 272 273 // nsIServerSocket uses -1 for atomic port allocation 274 if (port === 0) { 275 port = -1; 276 } 277 278 try { 279 this.#server = new lazy.HttpServer(); 280 const host = isIPv4Host ? DEFAULT_HOST : this.#host; 281 282 let error; 283 await lazy.PollPromise( 284 (resolve, reject) => { 285 try { 286 this.server._start(port, host); 287 this.#port = this.server._port; 288 resolve(); 289 } catch (e) { 290 error = e; 291 lazy.logger.debug(`Could not bind to port ${port} (${error.name})`); 292 reject(); 293 } 294 }, 295 { interval: 250, timeout: 5000 } 296 ); 297 298 if (!this.#server._socket) { 299 throw new Error(`Failed to start HTTP server on port ${port}`); 300 } 301 302 if (isIPv4Host) { 303 // Bug 1783938: httpd.js refuses connections when started on a IPv4 304 // address. As workaround start on localhost and add another identity 305 // for that IP address. 306 this.server.identity.add("http", this.#host, this.#port); 307 } 308 309 this.updateWebdriverActiveFlag(true); 310 311 Services.obs.notifyObservers(null, "remote-listening", true); 312 313 await this.#webDriverBiDi?.start(); 314 } catch (e) { 315 await this.#stop(); 316 lazy.logger.error( 317 `Unable to start the RemoteAgent: ${e.message}, closing`, 318 e 319 ); 320 321 Services.startup.quit(Ci.nsIAppStartup.eForceQuit); 322 } 323 } 324 325 /** 326 * Resolves a hostname to one or more IP addresses. 327 * 328 * @param {string} hostname 329 * 330 * @returns {Array<string>} 331 */ 332 #resolveHostname(hostname) { 333 return new Promise((resolve, reject) => { 334 let originalRequest; 335 336 const onLookupCompleteListener = { 337 onLookupComplete(request, record, status) { 338 if (request === originalRequest) { 339 if (!Components.isSuccessCode(status)) { 340 reject({ message: ChromeUtils.getXPCOMErrorName(status) }); 341 return; 342 } 343 344 record.QueryInterface(Ci.nsIDNSAddrRecord); 345 346 const addresses = []; 347 while (record.hasMore()) { 348 let addr = record.getNextAddrAsString(); 349 if (addr.includes(":") && !addr.startsWith("[")) { 350 // Make sure that the IPv6 address is wrapped with brackets. 351 addr = `[${addr}]`; 352 } 353 if (!addresses.includes(addr)) { 354 // Sometimes there are duplicate records with the same IP. 355 addresses.push(addr); 356 } 357 } 358 359 resolve(addresses); 360 } 361 }, 362 }; 363 364 try { 365 originalRequest = Services.dns.asyncResolve( 366 hostname, 367 Ci.nsIDNSService.RESOLVE_TYPE_DEFAULT, 368 Ci.nsIDNSService.RESOLVE_BYPASS_CACHE, 369 null, 370 onLookupCompleteListener, 371 null, //Services.tm.mainThread, 372 {} /* defaultOriginAttributes */ 373 ); 374 } catch (e) { 375 reject({ message: e.message }); 376 } 377 }); 378 } 379 380 async #stop() { 381 if (!this.running) { 382 return; 383 } 384 385 // Stop each protocol before stopping the HTTP server. 386 await this.#webDriverBiDi?.stop(); 387 388 try { 389 await this.#server.stop(); 390 this.#server = null; 391 392 this.updateWebdriverActiveFlag(false); 393 394 Services.obs.notifyObservers(null, "remote-listening"); 395 } catch (e) { 396 // this function must never fail 397 lazy.logger.error("Unable to stop listener", e); 398 } 399 } 400 401 handle(cmdLine) { 402 // All supported command line arguments have to be consumed in 403 // nsICommandLineHandler:handle to avoid issues on macos. 404 // See Marionette.sys.mjs::handle() for more details. 405 // TODO: remove after Bug 1724251 is fixed. 406 try { 407 cmdLine.handleFlagWithParam("remote-debugging-port", false); 408 } catch (e) { 409 cmdLine.handleFlag("remote-debugging-port", false); 410 } 411 412 cmdLine.handleFlag("remote-allow-system-access", false); 413 cmdLine.handleFlagWithParam("remote-allow-hosts", false); 414 cmdLine.handleFlagWithParam("remote-allow-origins", false); 415 } 416 417 async observe(subject, topic) { 418 if (this.#enabled) { 419 lazy.logger.trace(`Received observer notification ${topic}`); 420 } 421 422 switch (topic) { 423 case "profile-after-change": 424 Services.obs.addObserver(this, "command-line-startup"); 425 break; 426 427 case "command-line-startup": 428 Services.obs.removeObserver(this, topic); 429 430 this.#allowHosts = this.#handleAllowHostsFlag(subject); 431 this.#allowOrigins = this.#handleAllowOriginsFlag(subject); 432 this.allowSystemAccess = this.#handleAllowSystemAccessFlag(subject); 433 434 this.#enabled = this.#handleRemoteDebuggingPortFlag(subject); 435 436 if (this.#enabled) { 437 // Add annotation to crash report to indicate whether the 438 // Remote Agent was active. 439 Services.appinfo.annotateCrashReport("RemoteAgent", true); 440 441 Services.obs.addObserver(this, "final-ui-startup"); 442 Services.obs.addObserver(this, "browser-idle-startup-tasks-finished"); 443 Services.obs.addObserver(this, "mail-idle-startup-tasks-finished"); 444 Services.obs.addObserver(this, "quit-application"); 445 446 // Apply the common set of preferences for all supported protocols 447 lazy.RecommendedPreferences.applyPreferences(); 448 449 this.#webDriverBiDi = new lazy.WebDriverBiDi(this); 450 lazy.logger.debug("WebDriver BiDi enabled"); 451 } 452 break; 453 454 case "final-ui-startup": 455 Services.obs.removeObserver(this, topic); 456 457 try { 458 await this.#listen(this.#port); 459 } catch (e) { 460 throw Error(`Unable to start remote agent: ${e}`); 461 } 462 463 break; 464 465 // Used to wait until the initial application window has been opened. 466 case "browser-idle-startup-tasks-finished": 467 case "mail-idle-startup-tasks-finished": 468 Services.obs.removeObserver( 469 this, 470 "browser-idle-startup-tasks-finished" 471 ); 472 Services.obs.removeObserver(this, "mail-idle-startup-tasks-finished"); 473 this.#browserStartupFinished.resolve(); 474 break; 475 476 // Listen for application shutdown to also shutdown the Remote Agent 477 // and a possible running instance of httpd.js. 478 case "quit-application": 479 Services.obs.removeObserver(this, topic); 480 this.#stop(); 481 break; 482 } 483 } 484 485 receiveMessage({ name }) { 486 switch (name) { 487 case "RemoteAgent:IsRunning": 488 return this.running; 489 490 default: 491 lazy.logger.warn("Unknown IPC message to parent process: " + name); 492 return null; 493 } 494 } 495 496 // XPCOM 497 498 helpInfo = ` --remote-debugging-port [<port>] Start the Firefox Remote Agent, 499 which is a low-level remote debugging interface used for WebDriver 500 BiDi. Defaults to port 9222. 501 --remote-allow-hosts <hosts> Values of the Host header to allow for incoming requests. 502 Please read security guidelines at https://firefox-source-docs.mozilla.org/remote/Security.html 503 --remote-allow-origins <origins> Values of the Origin header to allow for incoming requests. 504 Please read security guidelines at https://firefox-source-docs.mozilla.org/remote/Security.html 505 --remote-allow-system-access Enable privileged access to the application's parent process\n`; 506 507 QueryInterface = ChromeUtils.generateQI([ 508 "nsICommandLineHandler", 509 "nsIObserver", 510 "nsIRemoteAgent", 511 ]); 512 } 513 514 class RemoteAgentContentProcess { 515 get running() { 516 return Services.cpmm.sharedData.get(SHARED_DATA_ACTIVE_KEY) ?? false; 517 } 518 519 // XPCOM 520 521 QueryInterface = ChromeUtils.generateQI(["nsIRemoteAgent"]); 522 } 523 524 export var RemoteAgent; 525 if (isRemote) { 526 RemoteAgent = new RemoteAgentContentProcess(); 527 } else { 528 RemoteAgent = new RemoteAgentParentProcess(); 529 } 530 531 // This is used by the XPCOM codepath which expects a constructor 532 export var RemoteAgentFactory = function () { 533 return RemoteAgent; 534 };