diff options
Diffstat (limited to 'toolkit/components/captivedetect/CaptiveDetect.sys.mjs')
-rw-r--r-- | toolkit/components/captivedetect/CaptiveDetect.sys.mjs | 545 |
1 files changed, 545 insertions, 0 deletions
diff --git a/toolkit/components/captivedetect/CaptiveDetect.sys.mjs b/toolkit/components/captivedetect/CaptiveDetect.sys.mjs new file mode 100644 index 0000000000..9008fe8a08 --- /dev/null +++ b/toolkit/components/captivedetect/CaptiveDetect.sys.mjs @@ -0,0 +1,545 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +const DEBUG = false; // set to true to show debug messages + +const kCAPTIVEPORTALDETECTOR_CID = Components.ID( + "{d9cd00ba-aa4d-47b1-8792-b1fe0cd35060}" +); + +const kOpenCaptivePortalLoginEvent = "captive-portal-login"; +const kAbortCaptivePortalLoginEvent = "captive-portal-login-abort"; +const kCaptivePortalLoginSuccessEvent = "captive-portal-login-success"; +const kCaptivePortalCheckComplete = "captive-portal-check-complete"; + +function URLFetcher(url, timeout) { + let self = this; + let xhr = new XMLHttpRequest(); + xhr.open("GET", url, true); + // Prevent the request from reading from the cache. + xhr.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE; + // Prevent the request from writing to the cache. + xhr.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING; + // Prevent privacy leaks + xhr.channel.loadFlags |= Ci.nsIRequest.LOAD_ANONYMOUS; + // Use the system's resolver for this check + xhr.channel.setTRRMode(Ci.nsIRequest.TRR_DISABLED_MODE); + // We except this from being classified + xhr.channel.loadFlags |= Ci.nsIChannel.LOAD_BYPASS_URL_CLASSIFIER; + // Prevent HTTPS-Only Mode from upgrading the request. + xhr.channel.loadInfo.httpsOnlyStatus |= Ci.nsILoadInfo.HTTPS_ONLY_EXEMPT; + // Allow deprecated HTTP request from SystemPrincipal + xhr.channel.loadInfo.allowDeprecatedSystemRequests = true; + + // We don't want to follow _any_ redirects + xhr.channel.QueryInterface(Ci.nsIHttpChannel).redirectionLimit = 0; + + // bug 1666072 - firefox.com returns a HSTS header triggering a https upgrade + // but the upgrade triggers an internal redirect causing an incorrect locked + // portal notification. We exclude CP detection from STS. + xhr.channel.QueryInterface(Ci.nsIHttpChannel).allowSTS = false; + + // The Cache-Control header is only interpreted by proxies and the + // final destination. It does not help if a resource is already + // cached locally. + xhr.setRequestHeader("Cache-Control", "no-cache"); + // HTTP/1.0 servers might not implement Cache-Control and + // might only implement Pragma: no-cache + xhr.setRequestHeader("Pragma", "no-cache"); + + xhr.timeout = timeout; + xhr.ontimeout = function () { + self.ontimeout(); + }; + xhr.onerror = function () { + self.onerror(); + }; + xhr.onreadystatechange = function (oEvent) { + if (xhr.readyState === 4) { + if (self._isAborted) { + return; + } + if (xhr.status === 200) { + self.onsuccess(xhr.responseText); + } else if (xhr.status) { + self.onredirectorerror(xhr.status); + } else if ( + xhr.channel && + xhr.channel.status == Cr.NS_ERROR_REDIRECT_LOOP + ) { + // For some redirects we don't get a status, so we need to check it + // this way. This only works because we set the redirectionLimit to 0. + self.onredirectorerror(300); + // No need to invoke the onerror callback, we handled it here. + xhr.onerror = null; + } + } + }; + xhr.send(); + this._xhr = xhr; +} + +URLFetcher.prototype = { + _isAborted: false, + ontimeout() {}, + onerror() {}, + abort() { + if (!this._isAborted) { + this._isAborted = true; + this._xhr.abort(); + } + }, +}; + +function LoginObserver(captivePortalDetector) { + const LOGIN_OBSERVER_STATE_DETACHED = 0; /* Should not monitor network activity since no ongoing login procedure */ + const LOGIN_OBSERVER_STATE_IDLE = 1; /* No network activity currently, waiting for a longer enough idle period */ + const LOGIN_OBSERVER_STATE_BURST = 2; /* Network activity is detected, probably caused by a login procedure */ + const LOGIN_OBSERVER_STATE_VERIFY_NEEDED = 3; /* Verifing network accessiblity is required after a long enough idle */ + const LOGIN_OBSERVER_STATE_VERIFYING = 4; /* LoginObserver is probing if public network is available */ + + let state = LOGIN_OBSERVER_STATE_DETACHED; + + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + let activityDistributor = Cc[ + "@mozilla.org/network/http-activity-distributor;1" + ].getService(Ci.nsIHttpActivityDistributor); + let urlFetcher = null; + + let pageCheckingDone = function pageCheckingDone() { + if (state === LOGIN_OBSERVER_STATE_VERIFYING) { + urlFetcher = null; + // Finish polling the canonical site, switch back to idle state and + // waiting for next burst + state = LOGIN_OBSERVER_STATE_IDLE; + timer.initWithCallback( + observer, + captivePortalDetector._pollingTime, + timer.TYPE_ONE_SHOT + ); + } + }; + + let checkPageContent = function checkPageContent() { + debug("checking if public network is available after the login procedure"); + + urlFetcher = new URLFetcher( + captivePortalDetector._canonicalSiteURL, + captivePortalDetector._maxWaitingTime + ); + urlFetcher.ontimeout = pageCheckingDone; + urlFetcher.onerror = pageCheckingDone; + urlFetcher.onsuccess = function (content) { + if (captivePortalDetector.validateContent(content)) { + urlFetcher = null; + captivePortalDetector.executeCallback(true); + } else { + pageCheckingDone(); + } + }; + urlFetcher.onredirectorerror = pageCheckingDone; + }; + + // Public interface of LoginObserver + let observer = { + QueryInterface: ChromeUtils.generateQI([ + "nsIHttpActivityObserver", + "nsITimerCallback", + ]), + + attach: function attach() { + if (state === LOGIN_OBSERVER_STATE_DETACHED) { + activityDistributor.addObserver(this); + state = LOGIN_OBSERVER_STATE_IDLE; + timer.initWithCallback( + this, + captivePortalDetector._pollingTime, + timer.TYPE_ONE_SHOT + ); + debug("attach HttpObserver for login activity"); + } + }, + + detach: function detach() { + if (state !== LOGIN_OBSERVER_STATE_DETACHED) { + if (urlFetcher) { + urlFetcher.abort(); + urlFetcher = null; + } + activityDistributor.removeObserver(this); + timer.cancel(); + state = LOGIN_OBSERVER_STATE_DETACHED; + debug("detach HttpObserver for login activity"); + } + }, + + /* + * Treat all HTTP transactions as captive portal login activities. + */ + observeActivity: function observeActivity( + aHttpChannel, + aActivityType, + aActivitySubtype, + aTimestamp, + aExtraSizeData, + aExtraStringData + ) { + if ( + aActivityType === + Ci.nsIHttpActivityObserver.ACTIVITY_TYPE_HTTP_TRANSACTION && + aActivitySubtype === + Ci.nsIHttpActivityObserver.ACTIVITY_SUBTYPE_RESPONSE_COMPLETE + ) { + switch (state) { + case LOGIN_OBSERVER_STATE_IDLE: + case LOGIN_OBSERVER_STATE_VERIFY_NEEDED: + state = LOGIN_OBSERVER_STATE_BURST; + break; + default: + break; + } + } + }, + + /* + * Check if login activity is finished according to HTTP burst. + */ + notify: function notify() { + switch (state) { + case LOGIN_OBSERVER_STATE_BURST: + // Wait while network stays idle for a short period + state = LOGIN_OBSERVER_STATE_VERIFY_NEEDED; + // Fall through to start polling timer + case LOGIN_OBSERVER_STATE_IDLE: + // Just fall through to perform a captive portal check. + case LOGIN_OBSERVER_STATE_VERIFY_NEEDED: + // Polling the canonical website since network stays idle for a while + state = LOGIN_OBSERVER_STATE_VERIFYING; + checkPageContent(); + break; + + default: + break; + } + }, + }; + + return observer; +} + +export function CaptivePortalDetector() { + // Load preference + this._canonicalSiteURL = null; + this._canonicalSiteExpectedContent = null; + + try { + this._canonicalSiteURL = Services.prefs.getCharPref( + "captivedetect.canonicalURL" + ); + this._canonicalSiteExpectedContent = Services.prefs.getCharPref( + "captivedetect.canonicalContent" + ); + } catch (e) { + debug("canonicalURL or canonicalContent not set."); + } + + this._maxWaitingTime = Services.prefs.getIntPref( + "captivedetect.maxWaitingTime" + ); + this._pollingTime = Services.prefs.getIntPref("captivedetect.pollingTime"); + this._maxRetryCount = Services.prefs.getIntPref( + "captivedetect.maxRetryCount" + ); + debug( + "Load Prefs {site=" + + this._canonicalSiteURL + + ",content=" + + this._canonicalSiteExpectedContent + + ",time=" + + this._maxWaitingTime + + "max-retry=" + + this._maxRetryCount + + "}" + ); + + // Create HttpObserver for monitoring the login procedure + this._loginObserver = LoginObserver(this); + + this._nextRequestId = 0; + this._runningRequest = null; + this._requestQueue = []; // Maintain a progress table, store callbacks and the ongoing XHR + this._interfaceNames = {}; // Maintain names of the requested network interfaces + + debug( + "CaptiveProtalDetector initiated, waiting for network connection established" + ); +} + +CaptivePortalDetector.prototype = { + classID: kCAPTIVEPORTALDETECTOR_CID, + QueryInterface: ChromeUtils.generateQI(["nsICaptivePortalDetector"]), + + // nsICaptivePortalDetector + checkCaptivePortal: function checkCaptivePortal(aInterfaceName, aCallback) { + if (!this._canonicalSiteURL) { + throw Components.Exception("No canonical URL set up."); + } + + // Prevent multiple requests on a single network interface + if (this._interfaceNames[aInterfaceName]) { + throw Components.Exception( + "Do not allow multiple request on one interface: " + aInterfaceName + ); + } + + let request = { interfaceName: aInterfaceName }; + if (aCallback) { + let callback = aCallback.QueryInterface(Ci.nsICaptivePortalCallback); + request.callback = callback; + request.retryCount = 0; + } + this._addRequest(request); + }, + + abort: function abort(aInterfaceName) { + debug("abort for " + aInterfaceName); + this._removeRequest(aInterfaceName); + }, + + finishPreparation: function finishPreparation(aInterfaceName) { + debug('finish preparation phase for interface "' + aInterfaceName + '"'); + if ( + !this._runningRequest || + this._runningRequest.interfaceName !== aInterfaceName + ) { + debug("invalid finishPreparation for " + aInterfaceName); + throw Components.Exception( + "only first request is allowed to invoke |finishPreparation|" + ); + } + + this._startDetection(); + }, + + cancelLogin: function cancelLogin(eventId) { + debug('login canceled by user for request "' + eventId + '"'); + // Captive portal login procedure is canceled by user + if ( + this._runningRequest && + this._runningRequest.hasOwnProperty("eventId") + ) { + let id = this._runningRequest.eventId; + if (eventId === id) { + this.executeCallback(false); + } + } + }, + + _applyDetection: function _applyDetection() { + debug("enter applyDetection(" + this._runningRequest.interfaceName + ")"); + + // Execute network interface preparation + if (this._runningRequest.hasOwnProperty("callback")) { + this._runningRequest.callback.prepare(); + } else { + this._startDetection(); + } + }, + + _startDetection: function _startDetection() { + debug( + "startDetection {site=" + + this._canonicalSiteURL + + ",content=" + + this._canonicalSiteExpectedContent + + ",time=" + + this._maxWaitingTime + + "}" + ); + let self = this; + + let urlFetcher = new URLFetcher( + this._canonicalSiteURL, + this._maxWaitingTime + ); + + let mayRetry = this._mayRetry.bind(this); + + urlFetcher.ontimeout = mayRetry; + urlFetcher.onerror = mayRetry; + urlFetcher.onsuccess = function (content) { + if (self.validateContent(content)) { + self.executeCallback(true); + } else { + // Content of the canonical website has been overwrite + self._startLogin(); + } + }; + urlFetcher.onredirectorerror = function (status) { + if (status >= 300 && status <= 399) { + // The canonical website has been redirected to an unknown location + self._startLogin(); + } else if (status === 511) { + // Got a RFC 6585 "Network Authentication Required" error page + self._startLogin(); + } else { + mayRetry(); + } + }; + + this._runningRequest.urlFetcher = urlFetcher; + }, + + _startLogin: function _startLogin() { + let id = this._allocateRequestId(); + let details = { + type: kOpenCaptivePortalLoginEvent, + id, + url: this._canonicalSiteURL, + }; + this._loginObserver.attach(); + this._runningRequest.eventId = id; + this._sendEvent(kOpenCaptivePortalLoginEvent, details); + }, + + _mayRetry: function _mayRetry() { + if ( + this._runningRequest && + this._runningRequest.retryCount++ < this._maxRetryCount + ) { + debug( + "retry-Detection: " + + this._runningRequest.retryCount + + "/" + + this._maxRetryCount + ); + this._startDetection(); + } else { + this.executeCallback(false); + } + }, + + executeCallback: function executeCallback(success) { + if (this._runningRequest) { + debug("callback executed"); + if (this._runningRequest.hasOwnProperty("callback")) { + this._runningRequest.callback.complete(success); + } + + // Only when the request has a event id and |success| is true + // do we need to notify the login-success event. + if (this._runningRequest.hasOwnProperty("eventId") && success) { + let details = { + type: kCaptivePortalLoginSuccessEvent, + id: this._runningRequest.eventId, + }; + this._sendEvent(kCaptivePortalLoginSuccessEvent, details); + } + + // Continue the following request + this._runningRequest.complete = true; + this._removeRequest(this._runningRequest.interfaceName); + } + }, + + _sendEvent: function _sendEvent(topic, details) { + debug('sendEvent "' + JSON.stringify(details) + '"'); + Services.obs.notifyObservers(this, topic, JSON.stringify(details)); + }, + + validateContent: function validateContent(content) { + debug("received content: " + content); + let valid = content === this._canonicalSiteExpectedContent; + // We need a way to indicate that a check has been performed, and if we are + // still in a captive portal. + this._sendEvent(kCaptivePortalCheckComplete, !valid); + return valid; + }, + + _allocateRequestId: function _allocateRequestId() { + let newId = this._nextRequestId++; + return newId.toString(); + }, + + _runNextRequest: function _runNextRequest() { + let nextRequest = this._requestQueue.shift(); + if (nextRequest) { + this._runningRequest = nextRequest; + this._applyDetection(); + } + }, + + _addRequest: function _addRequest(request) { + this._interfaceNames[request.interfaceName] = true; + this._requestQueue.push(request); + if (!this._runningRequest) { + this._runNextRequest(); + } + }, + + _removeRequest: function _removeRequest(aInterfaceName) { + if (!this._interfaceNames[aInterfaceName]) { + return; + } + + delete this._interfaceNames[aInterfaceName]; + + if ( + this._runningRequest && + this._runningRequest.interfaceName === aInterfaceName + ) { + this._loginObserver.detach(); + + if (!this._runningRequest.complete) { + // Abort the user login procedure + if (this._runningRequest.hasOwnProperty("eventId")) { + let details = { + type: kAbortCaptivePortalLoginEvent, + id: this._runningRequest.eventId, + }; + this._sendEvent(kAbortCaptivePortalLoginEvent, details); + } + + // Abort the ongoing HTTP request + if (this._runningRequest.hasOwnProperty("urlFetcher")) { + this._runningRequest.urlFetcher.abort(); + } + } + + debug("remove running request"); + this._runningRequest = null; + + // Continue next pending reqeust if the ongoing one has been aborted + this._runNextRequest(); + return; + } + + // Check if a pending request has been aborted + for (let i = 0; i < this._requestQueue.length; i++) { + if (this._requestQueue[i].interfaceName == aInterfaceName) { + this._requestQueue.splice(i, 1); + + debug( + "remove pending request #" + + i + + ", remaining " + + this._requestQueue.length + ); + break; + } + } + }, +}; + +var debug; +if (DEBUG) { + // eslint-disable-next-line no-global-assign + debug = function (s) { + dump("-*- CaptivePortalDetector component: " + s + "\n"); + }; +} else { + // eslint-disable-next-line no-global-assign + debug = function (s) {}; +} |