/* -*- 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/. */ "use strict"; const { XPCOMUtils } = ChromeUtils.import( "resource://gre/modules/XPCOMUtils.jsm" ); const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); XPCOMUtils.defineLazyGlobalGetters(this, ["XMLHttpRequest"]); 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; // We don't want to follow _any_ redirects xhr.channel.QueryInterface(Ci.nsIHttpChannel).redirectionLimit = 0; // 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([ Ci.nsIHttpActivityObserver, Ci.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; } 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([Ci.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 { 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) {}; } var EXPORTED_SYMBOLS = ["CaptivePortalDetector"];