From 9a664f7b3b6a5315983317b0e47128b809bc5531 Mon Sep 17 00:00:00 2001 From: hackademix Date: Sat, 29 Feb 2020 19:01:45 +0100 Subject: Refactored XSS filter into an asynchronous worker to better handle DOS attempts. --- src/xss/Exceptions.js | 4 +- src/xss/InjectionCheckWorker.js | 81 +++++++++++++++++++++++++++ src/xss/InjectionChecker.js | 6 +- src/xss/XSS.js | 120 ++++++++++++++++++++++------------------ 4 files changed, 152 insertions(+), 59 deletions(-) create mode 100644 src/xss/InjectionCheckWorker.js (limited to 'src/xss') diff --git a/src/xss/Exceptions.js b/src/xss/Exceptions.js index 24fc480..e8db6e1 100644 --- a/src/xss/Exceptions.js +++ b/src/xss/Exceptions.js @@ -52,14 +52,14 @@ XSS.Exceptions = (() => { // destination or @source matching legacy regexp if (this.legacyExceptions && (this.legacyExceptions.test(unescapedDest) && - !this.isBadException(destObj.hostname) || + !this.isBadException(xssReq.destDomain) || this.legacyExceptions.test("@" + unescape(srcUrl)) )) { logEx("Legacy exception", this.legacyExceptions); return true; } - if (!srcObj && isGet) { + if (!srcOrigin && isGet) { if (/^https?:\/\/msdn\.microsoft\.com\/query\/[^<]+$/.test(unescapedDest)) { return true; // MSDN from Microsoft VS } diff --git a/src/xss/InjectionCheckWorker.js b/src/xss/InjectionCheckWorker.js new file mode 100644 index 0000000..47f007d --- /dev/null +++ b/src/xss/InjectionCheckWorker.js @@ -0,0 +1,81 @@ +let include = src => { + if (Array.isArray(src)) importScripts(...src); + else importScripts(src); +} + +let XSS = {}; +include("/lib/log.js"); + +for (let logType of ["log", "debug", "error"]) { + this[logType] = (...log) => { + postMessage({log, logType}); + } +} + +include("InjectionChecker.js"); +Entities = { + convertAll(s) { return s }, +}; + +{ + let timingsMap = new Map(); + + let Handlers = { + async check({xssReq, skip}) { + let {destUrl, unparsedRequest: request, debugging} = xssReq; + let { + skipParams, + skipRx + } = skip; + let ic = new (await XSS.InjectionChecker)(); + + if (debugging) { + ic.logEnabled = true; + debug("[XSS] InjectionCheckWorker started in %s ms (%s).", + Date.now() - xssReq.timestamp, destUrl); + } else { + debug = () => {}; + } + + let {timing} = ic; + timingsMap.set(request.requestId, timing); + timing.fatalTimeout = true; + + let postInjection = xssReq.isPost && + request.requestBody && request.requestBody.formData && + await ic.checkPost(request.requestBody.formData, skipParams); + + let protectName = ic.nameAssignment; + let urlInjection = await ic.checkUrl(destUrl, skipRx); + protectName = protectName || ic.nameAssignment; + if (timing.tooLong) { + log("[XSS] Long check (%s ms) - %s", timing.elapsed, JSON.stringify(xssReq)); + } else if (debugging) { + debug("[XSS] InjectionCheckWorker done in %s ms (%s).", + Date.now() - xssReq.timestamp, destUrl); + } + + postMessage(!(protectName || postInjection || urlInjection) ? null + : { protectName, postInjection, urlInjection } + ); + }, + + requestDone({requestId}) { + let timing = timingsMap.get(requestId); + if (timing) { + timing.interrupted = true; + timingsMap.delete(requestId); + } + } + } + + onmessage = async e => { + let msg = e.data; + if (msg.handler in Handlers) try { + await Handlers[msg.handler](msg); + } catch (e) { + postMessage({error: e}); + } + } + +} diff --git a/src/xss/InjectionChecker.js b/src/xss/InjectionChecker.js index 157147e..a309891 100644 --- a/src/xss/InjectionChecker.js +++ b/src/xss/InjectionChecker.js @@ -1,6 +1,6 @@ -debug("Initializing InjectionChecker"); XSS.InjectionChecker = (async () => { await include([ + "/common/SyntaxChecker.js", "/lib/Base64.js", "/lib/Timing.js", "/xss/FlashIdiocy.js", @@ -1031,7 +1031,7 @@ XSS.InjectionChecker = (async () => { return true; if (s.indexOf("&") !== -1) { - let unent = Entities.convertAll(s); + let unent = await Entities.convertAll(s); if (unent !== s && await this._checkRecursive(unent, depth)) return true; } @@ -1050,7 +1050,7 @@ XSS.InjectionChecker = (async () => { return true; if (/[\u0000-\u001f]|&#/.test(unescaped)) { - let unent = Entities.convertAll(unescaped.replace(/[\u0000-\u001f]+/g, '')); + let unent = await Entities.convertAll(unescaped.replace(/[\u0000-\u001f]+/g, '')); if (unescaped != unent && await this._checkRecursive(unent, depth)) { this.log("Trash-stripped nested URL match!"); return true; diff --git a/src/xss/XSS.js b/src/xss/XSS.js index aba48ca..0a21603 100644 --- a/src/xss/XSS.js +++ b/src/xss/XSS.js @@ -4,8 +4,8 @@ var XSS = (() => { const ABORT = {cancel: true}, ALLOW = {}; + let workersMap = new Map(); let promptsMap = new Map(); - let timingsMap = new Map(); async function getUserResponse(xssReq) { let {originKey} = xssReq; @@ -23,10 +23,11 @@ var XSS = (() => { } function doneListener(request) { - let timing = timingsMap.get(request.id); - if (timing) { - timing.interrupted = true; - timingsMap.delete(request.id); + let {requestId} = request; + let worker = workersMap.get(requestId); + if (worker) { + worker.terminate(); + workersMap.delete(requestId); } } @@ -58,7 +59,7 @@ var XSS = (() => { data = []; } catch (e) { error(e, "XSS filter processing %o", xssReq); - if (e instanceof TimingException) { + if (/^Timing:/.test(e.message) && !/\btimeout\b/i.test(e.message)) { // we don't want prompts if the request expired / errored first return ABORT; } @@ -125,6 +126,21 @@ var XSS = (() => { } }; + function parseUrl(url) { + let u = new URL(url); + // make it cloneable + return { + href: u.href, + protocol: u.protocol, + hostname: u.hostname, + port: u.port, + origin: u.origin, + pathname: u.pathname, + search: u.search, + hash: u.hash, + }; + } + return { async start() { if (!UA.isMozilla) return; // async webRequest is supported on Mozilla only @@ -166,7 +182,6 @@ var XSS = (() => { } }, - parseRequest(request) { let { url: destUrl, @@ -175,7 +190,7 @@ var XSS = (() => { } = request; let destObj; try { - destObj = new URL(destUrl); + destObj = parseUrl(destUrl); } catch (e) { error(e, "Cannot create URL object for %s", destUrl); return null; @@ -183,7 +198,7 @@ var XSS = (() => { let srcObj = null; if (srcUrl) { try { - srcObj = new URL(srcUrl); + srcObj = parseUrl(srcUrl); } catch (e) {} } else { srcUrl = ""; @@ -198,28 +213,21 @@ var XSS = (() => { let isGet = method === "GET"; return { - xssUnparsed: request, + unparsedRequest: request, srcUrl, destUrl, srcObj, destObj, srcOrigin, destOrigin, - get srcDomain() { - delete this.srcDomain; - return this.srcDomain = srcObj && srcObj.hostname && tld.getDomain(srcObj.hostname) || ""; - }, - get destDomain() { - delete this.destDomain; - return this.destDomain = tld.getDomain(destObj.hostname); - }, - get originKey() { - delete this.originKey; - return this.originKey = `${srcOrigin}>${destOrigin}`; - }, + srcDomain: srcObj && srcObj.hostname && tld.getDomain(srcObj.hostname) || "", + destDomain: tld.getDomain(destObj.hostname), + originKey: `${srcOrigin}>${destOrigin}`, unescapedDest, isGet, isPost: !isGet && method === "POST", + timestamp: Date.now(), + debugging: ns.local.debug, } }, @@ -237,42 +245,46 @@ var XSS = (() => { return this._userChoices[originKey]; }, - async maybe(request) { // return reason or null if everything seems fine - let xssReq = request.xssUnparsed ? request : this.parseRequest(request); - request = xssReq.xssUnparsed; - + async maybe(xssReq) { // return reason or null if everything seems fine if (await this.Exceptions.shouldIgnore(xssReq)) { return null; } - let { - skipParams, - skipRx - } = this.Exceptions.partial(xssReq); - - let {destUrl} = xssReq; - - await include("/xss/InjectionChecker.js"); - let ic = new (await this.InjectionChecker)(); - ic.logEnabled = ns.local.debug; - let {timing} = ic; - timingsMap.set(request.id, timing); - timing.fatalTimeout = true; - - let postInjection = xssReq.isPost && - request.requestBody && request.requestBody.formData && - await ic.checkPost(request.requestBody.formData, skipParams); - - if (timing.tooLong) { - log("[XSS] Long check (%s ms) - %s", timing.elapsed, JSON.stringify(xssReq)); - } - - let protectName = ic.nameAssignment; - let urlInjection = await ic.checkUrl(destUrl, skipRx); - protectName = protectName || ic.nameAssignment; - - return !(protectName || postInjection || urlInjection) ? null - : { protectName, postInjection, urlInjection }; + let skip = this.Exceptions.partial(xssReq); + let worker = new Worker(browser.runtime.getURL("/xss/InjectionCheckWorker.js")); + let {requestId} = xssReq.unparsedRequest; + workersMap.set(requestId, worker) + return await new Promise((resolve, reject) => { + let cleanup = () => { + workersMap.delete(requestId); + worker.terminate(); + }; + worker.onmessage = e => { + let {data} = e; + if (data) { + if (data.logType) { + window[data.logType](...data.log); + return; + } + if (data.error) { + reject(data.error); + cleanup(); + return; + } + } + resolve(e.data); + cleanup(); + } + worker.onerror = worker.onmessageerror = e => { + reject(e); + cleanup(); + } + worker.postMessage({handler: "check", xssReq, skip}); + setTimeout(() => { + reject(new Error("Timeout! DOS attack attempt?")); + cleanup(); + }, 20000) + }); } }; })(); -- cgit v1.2.3