Index: lib/filterClasses.js |
diff --git a/lib/filterClasses.js b/lib/filterClasses.js |
index 1498ad8db2da7975ef3dce4916ef843d29d51420..217260b8cece1d2c7508caa6bfa7bb145624abd7 100644 |
--- a/lib/filterClasses.js |
+++ b/lib/filterClasses.js |
@@ -97,7 +97,22 @@ Filter.regexpRegExp = /^(@@)?\/.*\/(?:\$~?[\w-]+(?:=[^,\s]+)?(?:,~?[\w-]+(?:=[^, |
* Regular expression that options on a RegExp filter should match |
* @type {RegExp} |
*/ |
-Filter.optionsRegExp = /\$(~?[\w-]+(?:=[^,\s]+)?(?:,~?[\w-]+(?:=[^,\s]+)?)*)$/; |
+Filter.optionsRegExp = /\$(~?[\w-]+(?:=[^,]+)?(?:,~?[\w-]+(?:=[^,]+)?)*)$/; |
+ |
+/** |
+ * Forbidden Content Security Policy directives |
+ * @type {Set<string>} |
+ */ |
+Filter.cspDirectiveBlacklist = new Set([ |
+ "base-uri", "referrer", "report-to", "report-uri", "upgrade-insecure-requests" |
+]); |
Sebastian Noack
2018/03/21 16:23:13
I don't get why we create this Set here, just to t
kzar
2018/03/21 16:58:11
I created the Set since I figured being about to d
|
+/** |
+ * Regular expression that matches an invalid Content Security Policy |
+ * @type {RegExp} |
+ */ |
+Filter.invalidCSPRegExp = new RegExp( |
+ "(;|^) ?(" + Array.from(Filter.cspDirectiveBlacklist).join("|") + ")\\b" |
+); |
/** |
* Creates a filter of correct type from its text representation - does the |
@@ -172,22 +187,60 @@ Filter.normalize = function(text) |
if (!text) |
return text; |
- // Remove line breaks and such |
- text = text.replace(/[^\S ]/g, ""); |
+ // Remove line breaks, tabs etc |
+ text = text.replace(/[^\S ]+/g, ""); |
- if (/^\s*!/.test(text)) |
- { |
- // Don't remove spaces inside comments |
+ // Don't remove spaces inside comments |
+ if (/^ *!/.test(text)) |
return text.trim(); |
- } |
- else if (Filter.elemhideRegExp.test(text)) |
+ |
+ // Special treatment for element hiding filters, right side is allowed to |
+ // contain spaces |
+ if (Filter.elemhideRegExp.test(text)) |
{ |
- // Special treatment for element hiding filters, right side is allowed to |
- // contain spaces |
let [, domain, separator, selector] = /^(.*?)(#@?#?)(.*)$/.exec(text); |
- return domain.replace(/\s/g, "") + separator + selector.trim(); |
+ return domain.replace(/ +/g, "") + separator + selector.trim(); |
+ } |
+ |
+ // For most regexp filters we strip all spaces, but $csp filter options |
+ // are allowed to contain single (non trailing) spaces. |
+ let strippedText = text.replace(/ +/g, ""); |
+ if (!strippedText.includes("$") || !/\bcsp=/i.test(strippedText)) |
+ return strippedText; |
+ |
+ let optionsMatch = Filter.optionsRegExp.exec(strippedText); |
+ if (!optionsMatch) |
+ return strippedText; |
+ |
+ // For $csp filters we must first separate out the options part of the |
+ // text, being careful to preserve its spaces. |
+ let beforeOptions = strippedText.substring(0, optionsMatch.index); |
+ let strippedDollarIndex = -1; |
+ let dollarIndex = -1; |
+ do |
+ { |
+ strippedDollarIndex = beforeOptions.indexOf("$", strippedDollarIndex + 1); |
+ dollarIndex = text.indexOf("$", dollarIndex + 1); |
+ } |
+ while (strippedDollarIndex != -1); |
+ let optionsText = text.substr(dollarIndex + 1); |
+ |
+ // Then we can normalize spaces in the options part safely |
+ let options = optionsText.split(","); |
+ for (let i = 0; i < options.length; i++) |
+ { |
+ let option = options[i]; |
+ let cspMatch = /^ *c *s *p *=/i.exec(option); |
+ if (cspMatch) |
+ { |
+ options[i] = cspMatch[0].replace(/ +/g, "") + |
+ option.substr(cspMatch[0].length).trim().replace(/ +/g, " "); |
+ } |
+ else |
+ options[i] = option.replace(/ +/g, ""); |
} |
- return text.replace(/\s/g, ""); |
+ |
+ return beforeOptions + "$" + options.join(); |
}; |
/** |
@@ -727,11 +780,12 @@ RegExpFilter.fromText = function(text) |
let sitekeys = null; |
let thirdParty = null; |
let collapse = null; |
+ let csp = null; |
let options; |
let match = (text.indexOf("$") >= 0 ? Filter.optionsRegExp.exec(text) : null); |
if (match) |
{ |
- options = match[1].toUpperCase().split(","); |
+ options = match[1].split(","); |
text = match.input.substr(0, match.index); |
for (let option of options) |
{ |
@@ -742,12 +796,20 @@ RegExpFilter.fromText = function(text) |
value = option.substr(separatorIndex + 1); |
option = option.substr(0, separatorIndex); |
} |
- option = option.replace(/-/, "_"); |
+ option = option.replace(/-/, "_").toUpperCase(); |
if (option in RegExpFilter.typeMap) |
{ |
if (contentType == null) |
contentType = 0; |
contentType |= RegExpFilter.typeMap[option]; |
+ |
+ if (option == "CSP" && typeof value != "undefined") |
+ { |
+ if (csp) |
+ csp.push(value); |
Sebastian Noack
2018/03/21 16:23:13
Does that mean that if multiple $csp options are g
kzar
2018/03/21 16:58:11
Right.
|
+ else |
+ csp = [value]; |
+ } |
} |
else if (option[0] == "~" && option.substr(1) in RegExpFilter.typeMap) |
{ |
@@ -760,7 +822,7 @@ RegExpFilter.fromText = function(text) |
else if (option == "~MATCH_CASE") |
matchCase = false; |
else if (option == "DOMAIN" && typeof value != "undefined") |
- domains = value; |
+ domains = value.toUpperCase(); |
else if (option == "THIRD_PARTY") |
thirdParty = true; |
else if (option == "~THIRD_PARTY") |
@@ -770,7 +832,7 @@ RegExpFilter.fromText = function(text) |
else if (option == "~COLLAPSE") |
collapse = false; |
else if (option == "SITEKEY" && typeof value != "undefined") |
- sitekeys = value; |
+ sitekeys = value.toUpperCase(); |
else |
return new InvalidFilter(origText, "filter_unknown_option"); |
} |
@@ -780,8 +842,16 @@ RegExpFilter.fromText = function(text) |
{ |
if (blocking) |
{ |
+ if (csp) |
+ { |
+ csp = csp.join("; ").toLowerCase(); |
Sebastian Noack
2018/03/21 16:23:13
If we convert the CSP to lower case, shouldn't thi
kzar
2018/03/21 16:58:11
Hmm good point, initially I converted the value to
kzar
2018/03/21 16:58:11
Yes and no.
While I see your logic of course we c
|
+ |
+ if (Filter.invalidCSPRegExp.test(csp)) |
+ return new InvalidFilter(origText, "filter_invalid_csp"); |
+ } |
+ |
return new BlockingFilter(origText, text, contentType, matchCase, domains, |
- thirdParty, sitekeys, collapse); |
+ thirdParty, sitekeys, collapse, csp); |
} |
return new WhitelistFilter(origText, text, contentType, matchCase, domains, |
thirdParty, sitekeys); |
@@ -805,6 +875,7 @@ RegExpFilter.typeMap = { |
DOCUMENT: 64, |
WEBSOCKET: 128, |
WEBRTC: 256, |
+ CSP: 512, |
XBL: 1, |
PING: 1024, |
XMLHTTPREQUEST: 2048, |
@@ -821,9 +892,10 @@ RegExpFilter.typeMap = { |
GENERICHIDE: 0x80000000 |
}; |
-// DOCUMENT, ELEMHIDE, POPUP, GENERICHIDE and GENERICBLOCK options shouldn't |
-// be there by default |
-RegExpFilter.prototype.contentType &= ~(RegExpFilter.typeMap.DOCUMENT | |
+// CSP, DOCUMENT, ELEMHIDE, POPUP, GENERICHIDE and GENERICBLOCK options |
+// shouldn't be there by default |
+RegExpFilter.prototype.contentType &= ~(RegExpFilter.typeMap.CSP | |
+ RegExpFilter.typeMap.DOCUMENT | |
RegExpFilter.typeMap.ELEMHIDE | |
RegExpFilter.typeMap.POPUP | |
RegExpFilter.typeMap.GENERICHIDE | |
@@ -840,16 +912,19 @@ RegExpFilter.prototype.contentType &= ~(RegExpFilter.typeMap.DOCUMENT | |
* @param {string} sitekeys see RegExpFilter() |
* @param {boolean} collapse |
* defines whether the filter should collapse blocked content, can be null |
+ * @param {string} [csp] |
+ * Content Security Policy to inject when the filter matches |
* @constructor |
* @augments RegExpFilter |
*/ |
function BlockingFilter(text, regexpSource, contentType, matchCase, domains, |
- thirdParty, sitekeys, collapse) |
+ thirdParty, sitekeys, collapse, csp) |
{ |
RegExpFilter.call(this, text, regexpSource, contentType, matchCase, domains, |
thirdParty, sitekeys); |
this.collapse = collapse; |
+ this.csp = csp; |
} |
exports.BlockingFilter = BlockingFilter; |
@@ -861,7 +936,13 @@ BlockingFilter.prototype = extend(RegExpFilter, { |
* Can be null (use the global preference). |
* @type {boolean} |
*/ |
- collapse: null |
+ collapse: null, |
+ |
+ /** |
+ * Content Security Policy to inject for matching requests. |
+ * @type {string} |
+ */ |
+ csp: null |
}); |
/** |