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() { async pause() {
if (this.interrupted) throw new TimingException("Interrupted"); if (this.interrupted) throw new TimingException("Timing: interrupted");
let now = Date.now(); let now = Date.now();
this.calls++; this.calls++;
let sinceLastCall = now - this.lastCall; let sinceLastCall = now - this.lastCall;
@ -28,7 +28,7 @@ class Timing {
if (now - this.lastPause > this.workSlot || this.calls > this.maxCalls) { if (now - this.lastPause > this.workSlot || this.calls > this.maxCalls) {
this.tooLong = this.elapsed >= this.longTime; this.tooLong = this.elapsed >= this.longTime;
if (this.tooLong && this.fatalTimeout) { 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; this.calls = 0;
await Timing.sleep(this.pauseTime); 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; let debugCount = 0;
function log(msg, ...rest) { function log(msg, ...rest) {

View File

@ -1,5 +1,5 @@
if (UA.isMozilla) { 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); let n = async (...args) => !await y(...args);
Promise.all([ Promise.all([
() => y("https://noscript.net/<script"), () => y("https://noscript.net/<script"),

View File

@ -52,14 +52,14 @@ XSS.Exceptions = (() => {
// destination or @source matching legacy regexp // destination or @source matching legacy regexp
if (this.legacyExceptions && if (this.legacyExceptions &&
(this.legacyExceptions.test(unescapedDest) && (this.legacyExceptions.test(unescapedDest) &&
!this.isBadException(destObj.hostname) || !this.isBadException(xssReq.destDomain) ||
this.legacyExceptions.test("@" + unescape(srcUrl)) this.legacyExceptions.test("@" + unescape(srcUrl))
)) { )) {
logEx("Legacy exception", this.legacyExceptions); logEx("Legacy exception", this.legacyExceptions);
return true; return true;
} }
if (!srcObj && isGet) { if (!srcOrigin && isGet) {
if (/^https?:\/\/msdn\.microsoft\.com\/query\/[^<]+$/.test(unescapedDest)) { if (/^https?:\/\/msdn\.microsoft\.com\/query\/[^<]+$/.test(unescapedDest)) {
return true; // MSDN from Microsoft VS 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 () => { XSS.InjectionChecker = (async () => {
await include([ await include([
"/common/SyntaxChecker.js",
"/lib/Base64.js", "/lib/Base64.js",
"/lib/Timing.js", "/lib/Timing.js",
"/xss/FlashIdiocy.js", "/xss/FlashIdiocy.js",
@ -1031,7 +1031,7 @@ XSS.InjectionChecker = (async () => {
return true; return true;
if (s.indexOf("&") !== -1) { 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; if (unent !== s && await this._checkRecursive(unent, depth)) return true;
} }
@ -1050,7 +1050,7 @@ XSS.InjectionChecker = (async () => {
return true; return true;
if (/[\u0000-\u001f]|&#/.test(unescaped)) { 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)) { if (unescaped != unent && await this._checkRecursive(unent, depth)) {
this.log("Trash-stripped nested URL match!"); this.log("Trash-stripped nested URL match!");
return true; return true;

View File

@ -4,8 +4,8 @@ var XSS = (() => {
const ABORT = {cancel: true}, ALLOW = {}; const ABORT = {cancel: true}, ALLOW = {};
let workersMap = new Map();
let promptsMap = new Map(); let promptsMap = new Map();
let timingsMap = new Map();
async function getUserResponse(xssReq) { async function getUserResponse(xssReq) {
let {originKey} = xssReq; let {originKey} = xssReq;
@ -23,10 +23,11 @@ var XSS = (() => {
} }
function doneListener(request) { function doneListener(request) {
let timing = timingsMap.get(request.id); let {requestId} = request;
if (timing) { let worker = workersMap.get(requestId);
timing.interrupted = true; if (worker) {
timingsMap.delete(request.id); worker.terminate();
workersMap.delete(requestId);
} }
} }
@ -58,7 +59,7 @@ var XSS = (() => {
data = []; data = [];
} catch (e) { } catch (e) {
error(e, "XSS filter processing %o", xssReq); 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 // we don't want prompts if the request expired / errored first
return ABORT; 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 { return {
async start() { async start() {
if (!UA.isMozilla) return; // async webRequest is supported on Mozilla only if (!UA.isMozilla) return; // async webRequest is supported on Mozilla only
@ -166,7 +182,6 @@ var XSS = (() => {
} }
}, },
parseRequest(request) { parseRequest(request) {
let { let {
url: destUrl, url: destUrl,
@ -175,7 +190,7 @@ var XSS = (() => {
} = request; } = request;
let destObj; let destObj;
try { try {
destObj = new URL(destUrl); destObj = parseUrl(destUrl);
} catch (e) { } catch (e) {
error(e, "Cannot create URL object for %s", destUrl); error(e, "Cannot create URL object for %s", destUrl);
return null; return null;
@ -183,7 +198,7 @@ var XSS = (() => {
let srcObj = null; let srcObj = null;
if (srcUrl) { if (srcUrl) {
try { try {
srcObj = new URL(srcUrl); srcObj = parseUrl(srcUrl);
} catch (e) {} } catch (e) {}
} else { } else {
srcUrl = ""; srcUrl = "";
@ -198,28 +213,21 @@ var XSS = (() => {
let isGet = method === "GET"; let isGet = method === "GET";
return { return {
xssUnparsed: request, unparsedRequest: request,
srcUrl, srcUrl,
destUrl, destUrl,
srcObj, srcObj,
destObj, destObj,
srcOrigin, srcOrigin,
destOrigin, destOrigin,
get srcDomain() { srcDomain: srcObj && srcObj.hostname && tld.getDomain(srcObj.hostname) || "",
delete this.srcDomain; destDomain: tld.getDomain(destObj.hostname),
return this.srcDomain = srcObj && srcObj.hostname && tld.getDomain(srcObj.hostname) || ""; originKey: `${srcOrigin}>${destOrigin}`,
},
get destDomain() {
delete this.destDomain;
return this.destDomain = tld.getDomain(destObj.hostname);
},
get originKey() {
delete this.originKey;
return this.originKey = `${srcOrigin}>${destOrigin}`;
},
unescapedDest, unescapedDest,
isGet, isGet,
isPost: !isGet && method === "POST", isPost: !isGet && method === "POST",
timestamp: Date.now(),
debugging: ns.local.debug,
} }
}, },
@ -237,42 +245,46 @@ var XSS = (() => {
return this._userChoices[originKey]; return this._userChoices[originKey];
}, },
async maybe(request) { // return reason or null if everything seems fine async maybe(xssReq) { // return reason or null if everything seems fine
let xssReq = request.xssUnparsed ? request : this.parseRequest(request);
request = xssReq.xssUnparsed;
if (await this.Exceptions.shouldIgnore(xssReq)) { if (await this.Exceptions.shouldIgnore(xssReq)) {
return null; return null;
} }
let { let skip = this.Exceptions.partial(xssReq);
skipParams, let worker = new Worker(browser.runtime.getURL("/xss/InjectionCheckWorker.js"));
skipRx let {requestId} = xssReq.unparsedRequest;
} = this.Exceptions.partial(xssReq); workersMap.set(requestId, worker)
return await new Promise((resolve, reject) => {
let {destUrl} = xssReq; let cleanup = () => {
workersMap.delete(requestId);
await include("/xss/InjectionChecker.js"); worker.terminate();
let ic = new (await this.InjectionChecker)(); };
ic.logEnabled = ns.local.debug; worker.onmessage = e => {
let {timing} = ic; let {data} = e;
timingsMap.set(request.id, timing); if (data) {
timing.fatalTimeout = true; if (data.logType) {
window[data.logType](...data.log);
let postInjection = xssReq.isPost && return;
request.requestBody && request.requestBody.formData && }
await ic.checkPost(request.requestBody.formData, skipParams); if (data.error) {
reject(data.error);
if (timing.tooLong) { cleanup();
log("[XSS] Long check (%s ms) - %s", timing.elapsed, JSON.stringify(xssReq)); return;
} }
}
let protectName = ic.nameAssignment; resolve(e.data);
let urlInjection = await ic.checkUrl(destUrl, skipRx); cleanup();
protectName = protectName || ic.nameAssignment; }
worker.onerror = worker.onmessageerror = e => {
return !(protectName || postInjection || urlInjection) ? null reject(e);
: { protectName, postInjection, urlInjection }; cleanup();
}
worker.postMessage({handler: "check", xssReq, skip});
setTimeout(() => {
reject(new Error("Timeout! DOS attack attempt?"));
cleanup();
}, 20000)
});
} }
}; };
})(); })();