Refactored XSS filter into an asynchronous worker to better handle DOS attempts.

This commit is contained in:
hackademix 2020-02-29 19:01:45 +01:00
parent e48c2053df
commit 9a664f7b3b
7 changed files with 158 additions and 63 deletions

View File

@ -15,7 +15,7 @@ class Timing {
}
async pause() {
if (this.interrupted) throw new TimingException("Interrupted");
if (this.interrupted) throw new TimingException("Timing: interrupted");
let now = Date.now();
this.calls++;
let sinceLastCall = now - this.lastCall;
@ -28,7 +28,7 @@ class Timing {
if (now - this.lastPause > this.workSlot || this.calls > this.maxCalls) {
this.tooLong = this.elapsed >= this.longTime;
if (this.tooLong && this.fatalTimeout) {
throw new TimingException(`Exceeded ${this.longTime}ms timeout`);
throw new TimingException(`Timing: exceeded ${this.longTime}ms timeout`);
}
this.calls = 0;
await Timing.sleep(this.pauseTime);

View File

@ -1,6 +1,8 @@
{
let PREFIX = `[${browser.runtime.getManifest().name}]`;
let PREFIX = typeof browser === "object"
? `[${browser.runtime.getManifest().name}]` : '';
let debugCount = 0;
function log(msg, ...rest) {

View File

@ -1,5 +1,5 @@
if (UA.isMozilla) {
let y = async (url, originUrl = '') => await XSS.maybe({originUrl, url, method: "GET"});
let y = async (url, originUrl = '') => await XSS.maybe(XSS.parseRequest({originUrl, url, method: "GET"}));
let n = async (...args) => !await y(...args);
Promise.all([
() => y("https://noscript.net/<script"),

View File

@ -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
}

View File

@ -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});
}
}
}

View File

@ -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;

View File

@ -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)
});
}
};
})();