/* 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/. */ // COMPLETE_LENGTH and PARTIAL_LENGTH copied from nsUrlClassifierDBService.h, // they correspond to the length, in bytes, of a hash prefix and the total // hash. const COMPLETE_LENGTH = 32; const PARTIAL_LENGTH = 4; // Upper limit on the server response minimumWaitDuration const MIN_WAIT_DURATION_MAX_VALUE = 24 * 60 * 60 * 1000; const PREF_DEBUG_ENABLED = "browser.safebrowsing.debug"; import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; import { NetUtil } from "resource://gre/modules/NetUtil.sys.mjs"; const lazy = {}; XPCOMUtils.defineLazyServiceGetter( lazy, "gDbService", "@mozilla.org/url-classifier/dbservice;1", Ci.nsIUrlClassifierDBService ); XPCOMUtils.defineLazyServiceGetter( lazy, "gUrlUtil", "@mozilla.org/url-classifier/utils;1", Ci.nsIUrlClassifierUtils ); let loggingEnabled = false; // Log only if browser.safebrowsing.debug is true function log(...stuff) { if (!loggingEnabled) { return; } var d = new Date(); let msg = "hashcompleter: " + d.toTimeString() + ": " + stuff.join(" "); dump(Services.urlFormatter.trimSensitiveURLs(msg) + "\n"); } // Map the HTTP response code to a Telemetry bucket // https://developers.google.com/safe-browsing/developers_guide_v2?hl=en // eslint-disable-next-line complexity function httpStatusToBucket(httpStatus) { var statusBucket; switch (httpStatus) { case 100: case 101: // Unexpected 1xx return code statusBucket = 0; break; case 200: // OK - Data is available in the HTTP response body. statusBucket = 1; break; case 201: case 202: case 203: case 205: case 206: // Unexpected 2xx return code statusBucket = 2; break; case 204: // No Content - There are no full-length hashes with the requested prefix. statusBucket = 3; break; case 300: case 301: case 302: case 303: case 304: case 305: case 307: case 308: // Unexpected 3xx return code statusBucket = 4; break; case 400: // Bad Request - The HTTP request was not correctly formed. // The client did not provide all required CGI parameters. statusBucket = 5; break; case 401: case 402: case 405: case 406: case 407: case 409: case 410: case 411: case 412: case 414: case 415: case 416: case 417: case 421: case 426: case 428: case 429: case 431: case 451: // Unexpected 4xx return code statusBucket = 6; break; case 403: // Forbidden - The client id is invalid. statusBucket = 7; break; case 404: // Not Found statusBucket = 8; break; case 408: // Request Timeout statusBucket = 9; break; case 413: // Request Entity Too Large - Bug 1150334 statusBucket = 10; break; case 500: case 501: case 510: // Unexpected 5xx return code statusBucket = 11; break; case 502: case 504: case 511: // Local network errors, we'll ignore these. statusBucket = 12; break; case 503: // Service Unavailable - The server cannot handle the request. // Clients MUST follow the backoff behavior specified in the // Request Frequency section. statusBucket = 13; break; case 505: // HTTP Version Not Supported - The server CANNOT handle the requested // protocol major version. statusBucket = 14; break; default: statusBucket = 15; } return statusBucket; } class FullHashMatch { constructor(table, hash, duration) { this.tableName = table; this.fullHash = hash; this.cacheDuration = duration; } QueryInterface = ChromeUtils.generateQI(["nsIFullHashMatch"]); } export class HashCompleter { // The current HashCompleterRequest in flight. Once it is started, it is set // to null. It may be used by multiple calls to |complete| in succession to // avoid creating multiple requests to the same gethash URL. #currentRequest = null; // An Array of ongoing gethash requests which is used to find requests for // the same hash prefix. #ongoingRequests = []; // A map of gethashUrls to HashCompleterRequests that haven't yet begun. #pendingRequests = new Map(); // A map of gethash URLs to RequestBackoff objects. #backoffs = new Map(); // Whether we have been informed of a shutdown by the shutdown event. #shuttingDown = false; // A map of gethash URLs to next gethash time in miliseconds #nextGethashTimeMs = new Map(); constructor() { Services.obs.addObserver(this, "quit-application"); Services.prefs.addObserver(PREF_DEBUG_ENABLED, this); loggingEnabled = Services.prefs.getBoolPref(PREF_DEBUG_ENABLED); } classID = Components.ID("{9111de73-9322-4bfc-8b65-2b727f3e6ec8}"); QueryInterface = ChromeUtils.generateQI([ "nsIUrlClassifierHashCompleter", "nsIRunnable", "nsIObserver", "nsISupportsWeakReference", ]); // This is mainly how the HashCompleter interacts with other components. // Even though it only takes one partial hash and callback, subsequent // calls are made into the same HTTP request by using a thread dispatch. complete(aPartialHash, aGethashUrl, aTableName, aCallback) { if (!aGethashUrl) { throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED); } // Check ongoing requests before creating a new HashCompleteRequest for (const r of this.#ongoingRequests) { if (r.find(aPartialHash, aGethashUrl, aTableName)) { log( "Merge gethash request in " + aTableName + " for prefix : " + btoa(aPartialHash) ); r.add(aPartialHash, aCallback, aTableName); return; } } if (!this.#currentRequest) { this.#currentRequest = this.makeHashCompleterRequest( aTableName, aGethashUrl ); } if (this.#currentRequest.gethashUrl == aGethashUrl) { this.#currentRequest.add(aPartialHash, aCallback, aTableName); } else { if (!this.#pendingRequests.has(aGethashUrl)) { this.#pendingRequests.set( aGethashUrl, this.makeHashCompleterRequest(aTableName, aGethashUrl) ); } this.#pendingRequests .get(aGethashUrl) .add(aPartialHash, aCallback, aTableName); } if (!this.#backoffs.has(aGethashUrl)) { // Initialize request backoffs separately, since requests are deleted // after they are dispatched. var jslib = Cc["@mozilla.org/url-classifier/jslib;1"].getService().wrappedJSObject; // Using the V4 backoff algorithm for both V2 and V4. See bug 1273398. this.#backoffs.set( aGethashUrl, new jslib.RequestBackoffV4( 10 /* keep track of max requests */, 0 /* don't throttle on successful requests per time period */, lazy.gUrlUtil.getProvider(aTableName) /* used by testcase */ ) ); } if (!this.#nextGethashTimeMs.has(aGethashUrl)) { this.#nextGethashTimeMs.set(aGethashUrl, 0); } // Start off this request. Without dispatching to a thread, every call to // complete makes an individual HTTP request. Services.tm.dispatchToMainThread(this); } // A helper function to create a HashCompleterRequest based on the table name. makeHashCompleterRequest(aTableName, aGethashUrl) { let provider = lazy.gUrlUtil.getProvider(aTableName); if (provider == "google4") { return new HashCompleterRequestV4(this, aGethashUrl); } else if (provider == "google5") { return new HashCompleterRequestV5(this, aGethashUrl); } else if (provider == "mozilla") { return new HashCompleterRequestV2(this, aGethashUrl); } else if (provider == "test") { // If the table name ends with "-proto", use the V4 request. Otherwise. // We use the v2 request. if (aTableName.endsWith("-proto")) { if (aTableName.includes("google5")) { return new HashCompleterRequestV5(this, aGethashUrl, true); } return new HashCompleterRequestV4(this, aGethashUrl, true); } return new HashCompleterRequestV2(this, aGethashUrl, true); } // We use the V2 request as the fallback. return new HashCompleterRequestV2(this, aGethashUrl); } // This is called after several calls to |complete|, or after the // currentRequest has finished. It starts off the HTTP request by making a // |begin| call to the HashCompleterRequest. run() { // Clear everything on shutdown if (this.#shuttingDown) { this.#currentRequest = null; this.#pendingRequests.clear(); this.#nextGethashTimeMs.clear(); this.#backoffs.clear(); throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED); } // If we don't have an in-flight request, make one let pendingUrls = Array.from(this.#pendingRequests.keys()); if (!this.#currentRequest && pendingUrls.length) { let nextUrl = pendingUrls[0]; this.#currentRequest = this.#pendingRequests.get(nextUrl); this.#pendingRequests.delete(nextUrl); } if (this.#currentRequest) { try { if (this.#currentRequest.begin()) { this.#ongoingRequests.push(this.#currentRequest); } } finally { // If |begin| fails, we should get rid of our request. this.#currentRequest = null; } } } // Pass the server response status to the RequestBackoff for the given // gethashUrl and fetch the next pending request, if there is one. finishRequest(aRequest, aStatus) { this.#ongoingRequests = this.#ongoingRequests.filter(v => v != aRequest); this.#backoffs.get(aRequest.gethashUrl).noteServerResponse(aStatus); Services.tm.dispatchToMainThread(this); } // Returns true if we can make a request from the given url, false otherwise. canMakeRequest(aGethashUrl) { return ( this.#backoffs.get(aGethashUrl).canMakeRequest() && Date.now() >= this.#nextGethashTimeMs.get(aGethashUrl) ); } // Notifies the RequestBackoff of a new request so we can throttle based on // max requests/time period. This must be called before a channel is opened, // and finishRequest must be called once the response is received. noteRequest(aGethashUrl) { return this.#backoffs.get(aGethashUrl).noteRequest(); } // Remove a request from ongoing requests removeFromOngoingRequests(request) { this.#ongoingRequests = this.#ongoingRequests.filter(v => v != request); } // Set the next gethash time for a URL setNextGethashTime(gethashUrl, time) { this.#nextGethashTimeMs.set(gethashUrl, time); } observe(aSubject, aTopic, aData) { switch (aTopic) { case "quit-application": this.#shuttingDown = true; Services.obs.removeObserver(this, "quit-application"); break; case "nsPref:changed": if (aData == PREF_DEBUG_ENABLED) { loggingEnabled = Services.prefs.getBoolPref(PREF_DEBUG_ENABLED); } break; } } } class HashCompleterRequestBase { // HashCompleter object that created this HashCompleterRequestBase. completer; // nsIChannel that the hash completion query is transmitted over. channel = null; // The internal set of hashes and callbacks that this request corresponds to. requests = []; // Response body of hash completion. Created in onDataAvailable. response = ""; // Whether we have been informed of a shutdown by the quit-application event. #shuttingDown = false; constructor(aCompleter, aGethashUrl) { this.completer = aCompleter; this.gethashUrl = aGethashUrl; // Multiple partial hashes can be associated with the same tables // so we use a map here. this.tableNames = new Map(); this.telemetryProvider = ""; this.telemetryClockStart = 0; } QueryInterface = ChromeUtils.generateQI([ "nsIRequestObserver", "nsIStreamListener", "nsIObserver", "nsITimerCallback", ]); // This is called by the HashCompleter to add a hash and callback to the // HashCompleterRequest. It must be called before calling |begin|. add(aPartialHash, aCallback, aTableName) { this.requests.push({ partialHash: aPartialHash, callback: aCallback, tableName: aTableName, response: { matches: [] }, }); if (!aTableName) { return; } let providerFromTableName = lazy.gUrlUtil.getProvider(aTableName); if (providerFromTableName != this.provider) { log( "ERROR: Cannot mix tables with different providers within " + "the same gethash URL." ); } if (!this.tableNames.has(aTableName)) { this.tableNames.set(aTableName); } // Get the telemetry provider from the table name. if (this.telemetryProvider == "") { this.telemetryProvider = lazy.gUrlUtil.getTelemetryProvider(aTableName); } } // This is called by the HashCompleter to find if a partial hash is already // added to the request. find(aPartialHash, aGetHashUrl, aTableName) { if (this.gethashUrl != aGetHashUrl || !this.tableNames.has(aTableName)) { return false; } return this.requests.find(function (r) { return r.partialHash === aPartialHash; }); } // This initiates the HTTP request. It can fail due to backoff timings and // will notify all callbacks as necessary. We notify the backoff object on // begin. begin() { if (!this.completer.canMakeRequest(this.gethashUrl)) { log("Can't make request to " + this.gethashUrl + "\n"); this.notifyFailure(Cr.NS_ERROR_ABORT); return false; } Services.obs.addObserver(this, "quit-application"); this.beginBuildChannel(); return true; } // This should be implemented by the subclasses to build the channel for // completing the find full hash request. beginBuildChannel() { throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); } notify() { // If we haven't gotten onStopRequest, just cancel. This will call us // with onStopRequest since we implement nsIStreamListener on the // channel. if (this.channel && this.channel.isPending()) { log("cancelling request to " + this.gethashUrl + " (timeout)\n"); Glean.urlclassifier.completeTimeout .get(this.telemetryProvider, true) .add(1); this.channel.cancel(Cr.NS_BINDING_ABORTED); } } // Creates an nsIChannel for the request and fills the body. // Enforce bypassing URL Classifier check because if the request is // blocked, it means SafeBrowsing is malfunction. openChannel() { let loadFlags = Ci.nsIChannel.INHIBIT_CACHING | Ci.nsIChannel.LOAD_BYPASS_CACHE | Ci.nsIChannel.LOAD_BYPASS_URL_CLASSIFIER; this.request = { url: this.makeChannelURL(), body: "", }; log("actualGethashUrl: " + this.request.url); let channel = NetUtil.newChannel({ uri: this.request.url, loadUsingSystemPrincipal: true, }); channel.loadFlags = loadFlags; channel.loadInfo.originAttributes = { // The firstPartyDomain value should sync with NECKO_SAFEBROWSING_FIRST_PARTY_DOMAIN // defined in nsNetUtil.h. firstPartyDomain: "safebrowsing.86868755-6b82-4842-b301-72671a0db32e.mozilla", }; // Disable keepalive. let httpChannel = channel.QueryInterface(Ci.nsIHttpChannel); httpChannel.setRequestHeader("Connection", "close", false); this.channel = channel; this.setupChannel(channel); // Set a timer that cancels the channel after timeout_ms in case we // don't get a gethash response. this.timer_ = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); // Ask the timer to use nsITimerCallback (.notify()) when ready let timeout = Services.prefs.getIntPref("urlclassifier.gethash.timeout_ms"); this.timer_.initWithCallback(this, timeout, this.timer_.TYPE_ONE_SHOT); channel.asyncOpen(this); this.telemetryClockStart = Date.now(); } // This should be implemented by the subclasses to build the channel URL. makeChannelURL() { throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); } // This should be implemented by the subclasses to setup the channel. setupChannel(_channel) { throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); } // This should be implemented by the subclasses to handle the response. handleResponse() { throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); } // This adds a complete hash to any entry in |this._requests| that matches // the hash. handleItem(aData) { // Only perform provider check if the table name is provided. The table name // can be missing for V5 because the response doesn't contain a table name. if (aData.tableName) { let provider = lazy.gUrlUtil.getProvider(aData.tableName); if (provider != this.provider) { log( "Ignoring table " + aData.tableName + " since it belongs to " + provider + " while the response came from " + this.provider + "." ); return; } } for (const request of this.requests) { if (aData.completeHash.startsWith(request.partialHash)) { request.response.matches.push(aData); } } } // notifySuccess and notifyFailure are used to alert the callbacks with // results. notifySuccess makes |completion| and |completionFinished| calls // while notifyFailure only makes a |completionFinished| call with the error // code. // This should be implemented by the subclasses to notify the success. notifySuccess() { throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); } notifyFailure(aStatus) { log("notifying failure\n"); for (const request of this.requests) { request.callback.completionFinished(aStatus); } } onDataAvailable(aRequest, aInputStream, aOffset, aCount) { let sis = Cc["@mozilla.org/scriptableinputstream;1"].createInstance( Ci.nsIScriptableInputStream ); sis.init(aInputStream); this.response += sis.readBytes(aCount); } onStartRequest() { // At this point no data is available for us and we have no reason to // terminate the connection, so we do nothing until |onStopRequest|. this.completer.setNextGethashTime(this.gethashUrl, 0); if (this.telemetryClockStart > 0) { let msecs = Date.now() - this.telemetryClockStart; Glean.urlclassifier.completeServerResponseTime[ this.telemetryProvider ].accumulateSingleSample(msecs); } } onStopRequest(aRequest, aStatusCode) { Services.obs.removeObserver(this, "quit-application"); if (this.timer_) { this.timer_.cancel(); this.timer_ = null; } this.telemetryClockStart = 0; if (this.#shuttingDown) { throw Components.Exception("", Cr.NS_ERROR_ABORT); } // Default HTTP status to service unavailable, in case we can't retrieve // the true status from the channel. let httpStatus = 503; if (Components.isSuccessCode(aStatusCode)) { let channel = aRequest.QueryInterface(Ci.nsIHttpChannel); let success = channel.requestSucceeded; httpStatus = channel.responseStatus; if (!success) { aStatusCode = Cr.NS_ERROR_ABORT; } } let success = Components.isSuccessCode(aStatusCode); log( "Received a " + httpStatus + " status code from the " + this.provider + " gethash server (success=" + success + "): " + btoa(this.response) ); Glean.urlclassifier.completeRemoteStatus2[ this.telemetryProvider ].accumulateSingleSample(httpStatusToBucket(httpStatus)); if (httpStatus == 400) { dump( "Safe Browsing server returned a 400 during completion: request= " + this.request.url + ",payload= " + this.request.body + ",response= " + this.response + "\n" ); } Glean.urlclassifier.completeTimeout .get(this.telemetryProvider, false) .add(1); // Notify the RequestBackoff once a response is received. this.completer.finishRequest(this, httpStatus); if (success) { try { this.handleResponse(); } catch (err) { log(err.stack); aStatusCode = err.value; success = false; } } if (success) { this.notifySuccess(); } else { this.notifyFailure(aStatusCode); } } observe(aSubject, aTopic) { if (aTopic == "quit-application") { this.#shuttingDown = true; if (this.channel) { this.channel.cancel(Cr.NS_ERROR_ABORT); this.telemetryClockStart = 0; } Services.obs.removeObserver(this, "quit-application"); } } } class HashCompleterRequestV2 extends HashCompleterRequestBase { constructor(aCompleter, aGethashUrl, aIsTesting = false) { super(aCompleter, aGethashUrl); this.provider = aIsTesting ? "test" : "mozilla"; } beginBuildChannel() { try { this.openChannel(); // Notify the RequestBackoff if opening the channel succeeded. At this // point, finishRequest must be called. this.completer.noteRequest(this.gethashUrl); } catch (err) { this.completer.removeFromOngoingRequests(this); this.notifyFailure(err); throw err; } } makeChannelURL() { return this.gethashUrl; } // Returns a string for the request body based on the contents of // this._requests. buildRequest() { // Sometimes duplicate entries are sent to HashCompleter but we do not need // to propagate these to the server. (bug 633644) let prefixes = []; for (let i = 0; i < this.requests.length; i++) { let request = this.requests[i]; if (!prefixes.includes(request.partialHash)) { prefixes.push(request.partialHash); } } // Sort to make sure the entries are arbitrary mixed in a deterministic way prefixes.sort(); let body; body = PARTIAL_LENGTH + ":" + PARTIAL_LENGTH * prefixes.length + "\n" + prefixes.join(""); log( "Requesting completions for " + prefixes.length + " " + PARTIAL_LENGTH + "-byte prefixes: " + body ); return body; } setupChannel(channel) { let body = this.buildRequest(); let inputStream = Cc[ "@mozilla.org/io/string-input-stream;1" ].createInstance(Ci.nsIStringInputStream); inputStream.setByteStringData(body); let uploadChannel = channel.QueryInterface(Ci.nsIUploadChannel); uploadChannel.setUploadStream(inputStream, "text/plain", -1); let httpChannel = channel.QueryInterface(Ci.nsIHttpChannel); httpChannel.requestMethod = "POST"; } handleResponse() { if (this.response == "") { return; } let start = 0; let length = this.response.length; while (start != length) { start = this.handleTable(start); } } // This parses a table entry in the response body and calls |handleItem| // for complete hash in the table entry. handleTable(aStart) { let body = this.response.substring(aStart); // deal with new line indexes as there could be // new line characters in the data parts. let newlineIndex = body.indexOf("\n"); if (newlineIndex == -1) { throw errorWithStack(); } let header = body.substring(0, newlineIndex); let entries = header.split(":"); if (entries.length != 3) { throw errorWithStack(); } let list = entries[0]; let addChunk = parseInt(entries[1]); let dataLength = parseInt(entries[2]); log("Response includes add chunks for " + list + ": " + addChunk); if ( dataLength % COMPLETE_LENGTH != 0 || dataLength == 0 || dataLength > body.length - (newlineIndex + 1) ) { throw errorWithStack(); } let data = body.substr(newlineIndex + 1, dataLength); for (let i = 0; i < dataLength / COMPLETE_LENGTH; i++) { this.handleItem({ completeHash: data.substr(i * COMPLETE_LENGTH, COMPLETE_LENGTH), tableName: list, chunkId: addChunk, }); } return aStart + newlineIndex + 1 + dataLength; } notifySuccess() { // V2 completion handler let completion = req => { req.response.matches.forEach(m => { req.callback.completionV2(m.completeHash, m.tableName, m.chunkId); }); req.callback.completionFinished(Cr.NS_OK); }; this.requests.forEach(req => { completion(req); }); } } class HashCompleterRequestV4 extends HashCompleterRequestBase { constructor(aCompleter, aGethashUrl, aIsTesting = false) { super(aCompleter, aGethashUrl, aIsTesting); this.provider = aIsTesting ? "test" : "google4"; } fillTableStatesBase64(aCallback) { lazy.gDbService.getTables(aTableData => { aTableData.split("\n").forEach(line => { let p = line.indexOf(";"); if (-1 === p) { return; } // [tableName];[stateBase64]:[checksumBase64] let tableName = line.substring(0, p); if (this.tableNames.has(tableName)) { let metadata = line.substring(p + 1).split(":"); let stateBase64 = metadata[0]; this.tableNames.set(tableName, stateBase64); } }); aCallback(); }); } beginBuildChannel() { // V4 requires table states to build the request so we need // a async call to retrieve the table states from disk. // Note that |HCR_begin| is fine to be sync because // it doesn't appear in a sync call chain. this.fillTableStatesBase64(() => { try { this.openChannel(); // Notify the RequestBackoff if opening the channel succeeded. At this // point, finishRequest must be called. this.completer.noteRequest(this.gethashUrl); } catch (err) { this.completer.removeFromOngoingRequests(this); this.notifyFailure(err); throw err; } }); } makeChannelURL() { return this.gethashUrl + "&$req=" + this.buildRequest(); } buildRequest() { // Convert the "name to state" mapping to two equal-length arrays. let tableNameArray = []; let stateArray = []; this.tableNames.forEach((state, name) => { // We skip the table which is not associated with a state. if (state) { tableNameArray.push(name); stateArray.push(state); } }); // Build the "distinct" prefix array. // The array is sorted to make sure the entries are arbitrary mixed in a // deterministic way let prefixSet = new Set(); this.requests.forEach(r => prefixSet.add(btoa(r.partialHash))); let prefixArray = Array.from(prefixSet).sort(); log( "Build v4 gethash request with " + JSON.stringify(tableNameArray) + ", " + JSON.stringify(stateArray) + ", " + JSON.stringify(prefixArray) ); return lazy.gUrlUtil.makeFindFullHashRequestV4( tableNameArray, stateArray, prefixArray ); } setupChannel(channel) { let httpChannel = channel.QueryInterface(Ci.nsIHttpChannel); httpChannel.setRequestHeader("X-HTTP-Method-Override", "POST", false); } handleResponse() { if (this.response == "") { return; } let callback = { // onCompleteHashFound will be called for each fullhash found in // FullHashResponse. onCompleteHashFound: ( aCompleteHash, aTableNames, aPerHashCacheDuration ) => { log( "V4 fullhash response complete hash found callback: " + aTableNames + ", CacheDuration(" + aPerHashCacheDuration + ")" ); // Filter table names which we didn't requested. let filteredTables = aTableNames.split(",").filter(name => { return this.tableNames.get(name); }); if (0 === filteredTables.length) { log("ERROR: Got complete hash which is from unknown table."); return; } if (filteredTables.length > 1) { log("WARNING: Got complete hash which has ambigious threat type."); } this.handleItem({ completeHash: aCompleteHash, tableName: filteredTables[0], cacheDuration: aPerHashCacheDuration, }); }, // onResponseParsed will be called no matter if there is match in // FullHashResponse, the callback is mainly used to pass negative cache // duration and minimum wait duration. onResponseParsed: (aMinWaitDuration, aNegCacheDuration) => { log( "V4 fullhash response parsed callback: " + "MinWaitDuration(" + aMinWaitDuration + "), " + "NegativeCacheDuration(" + aNegCacheDuration + ")" ); let minWaitDuration = aMinWaitDuration; if (aMinWaitDuration > MIN_WAIT_DURATION_MAX_VALUE) { log( "WARNING: Minimum wait duration too large, clamping it down " + "to a reasonable value." ); minWaitDuration = MIN_WAIT_DURATION_MAX_VALUE; } else if (aMinWaitDuration < 0) { log("WARNING: Minimum wait duration is negative, reset it to 0"); minWaitDuration = 0; } this.completer.setNextGethashTime( this.gethashUrl, Date.now() + minWaitDuration ); // A fullhash request may contain more than one prefix, so the negative // cache duration should be set for all the prefixes in the request. this.requests.forEach(request => { request.response.negCacheDuration = aNegCacheDuration; }); }, }; lazy.gUrlUtil.parseFindFullHashResponseV4(this.response, callback); } notifySuccess() { let completion = req => { let matches = Cc["@mozilla.org/array;1"].createInstance( Ci.nsIMutableArray ); req.response.matches.forEach(m => { matches.appendElement( new FullHashMatch(m.tableName, m.completeHash, m.cacheDuration) ); }); req.callback.completionV4( req.partialHash, req.tableName, req.response.negCacheDuration, matches ); req.callback.completionFinished(Cr.NS_OK); }; this.requests.forEach(req => { completion(req); }); } } class HashCompleterRequestV5 extends HashCompleterRequestBase { constructor(aCompleter, aGethashUrl, aIsTesting = false) { super(aCompleter, aGethashUrl); this.provider = aIsTesting ? "test" : "google5"; } beginBuildChannel() { try { this.openChannel(); // Notify the RequestBackoff if opening the channel succeeded. At this // point, finishRequest must be called. this.completer.noteRequest(this.gethashUrl); } catch (err) { this.completer.removeFromOngoingRequests(this); this.notifyFailure(err); throw err; } } makeChannelURL() { let prefixSet = new Set(); // In V5, the prefix is sent using query parameters. We need to encode the // prefixes to avoid issues with special characters. this.requests.forEach(r => prefixSet.add(encodeURIComponent(btoa(r.partialHash))) ); let prefixArray = Array.from(prefixSet).sort(); log("Build v5 gethash request URL with " + JSON.stringify(prefixArray)); return ( this.gethashUrl + "&" + lazy.gUrlUtil.makeFindFullHashRequestV5(prefixArray) ); } // In V5, we don't need to do extra setup for the channel. The request uses // the default GET method. setupChannel(_channel) {} handleResponse() { if (this.response == "") { return; } let callback = { // onCompleteHashFound will be called for each fullhash found in // FullHashResponse. onCompleteHashFound: ( aCompleteHash, aTableNames, aPerHashCacheDuration ) => { log( "V5 hashes::search response complete hash found callback: " + aTableNames + ", CacheDuration(" + aPerHashCacheDuration + ")" ); this.handleItem({ completeHash: aCompleteHash, cacheDuration: aPerHashCacheDuration, }); }, // onResponseParsed will be called no matter if there is match in // FullHashResponse, the callback is mainly used to pass negative cache // duration and minimum wait duration. onResponseParsed: (aMinWaitDuration, aNegCacheDuration) => { log( "V5 hashes::search response parsed callback: " + "MinWaitDuration(" + aMinWaitDuration + "), " + "NegativeCacheDuration(" + aNegCacheDuration + ")" ); let minWaitDuration = aMinWaitDuration; if (aMinWaitDuration > MIN_WAIT_DURATION_MAX_VALUE) { log( "WARNING: Minimum wait duration too large, clamping it down " + "to a reasonable value." ); minWaitDuration = MIN_WAIT_DURATION_MAX_VALUE; } else if (aMinWaitDuration < 0) { log("WARNING: Minimum wait duration is negative, reset it to 0"); minWaitDuration = 0; } this.completer.setNextGethashTime( this.gethashUrl, Date.now() + minWaitDuration ); // A fullhash request may contain more than one prefix, so the negative // cache duration should be set for all the prefixes in the request. this.requests.forEach(request => { request.response.negCacheDuration = aNegCacheDuration; }); }, }; lazy.gUrlUtil.parseFindFullHashResponseV5(this.response, callback); } notifySuccess() { let completion = req => { let matches = Cc["@mozilla.org/array;1"].createInstance( Ci.nsIMutableArray ); req.response.matches.forEach(m => { matches.appendElement( new FullHashMatch(m.tableName, m.completeHash, m.cacheDuration) ); }); // We still use the V4 completion method for V5 because V5 uses the same // caching mechanism as V4. req.callback.completionV4( req.partialHash, req.tableName, req.response.negCacheDuration, matches ); req.callback.completionFinished(Cr.NS_OK); }; this.requests.forEach(req => { completion(req); }); } } function errorWithStack() { let err = new Error(); err.value = Cr.NS_ERROR_FAILURE; return err; }