diff options
-rw-r--r-- | background/policy_injector.js | 101 | ||||
-rw-r--r-- | common/misc.js | 54 | ||||
-rw-r--r-- | content/main.js | 1 |
3 files changed, 133 insertions, 23 deletions
diff --git a/background/policy_injector.js b/background/policy_injector.js index b3d85e8..9725e99 100644 --- a/background/policy_injector.js +++ b/background/policy_injector.js @@ -20,24 +20,28 @@ * IMPORT url_extract_target * IMPORT sign_policy * IMPORT query_best - * IMPORT csp_rule + * IMPORT sanitize_csp_header * IMPORTS_END */ var storage; -const csp_header_names = { - "content-security-policy" : true, - "x-webkit-csp" : true, - "x-content-security-policy" : true -}; +const csp_header_names = new Set([ + "content-security-policy", + "x-webkit-csp", + "x-content-security-policy" +]); -const header_name = "content-security-policy"; +/* TODO: variable no longer in use; remove if not needed */ +const unwanted_csp_directives = new Set([ + "report-to", + "report-uri", + "script-src", + "script-src-elem", + "prefetch-src" +]); -function is_csp_header(header) -{ - return !!csp_header_names[header.name.toLowerCase()]; -} +const report_only = "content-security-policy-report-only"; function url_inject(details) { @@ -82,20 +86,73 @@ function headers_inject(details) if (!targets.current) return {cancel: true}; - const rule = csp_rule(targets.policy.nonce); - var headers = details.responseHeaders; + let orig_csp_headers = is_chrome ? null : []; + let headers = []; + let csp_headers = is_chrome ? headers : []; + + const rule = `'nonce-${targets.policy.nonce}'`; + const block = !targets.policy.allow; + + for (const header of details.responseHeaders) { + if (!csp_header_names.has(header)) { + /* Remove headers that only snitch on us */ + if (header.name.toLowerCase() === report_only && block) + continue; + headers.push(header); + + /* If these are the original CSP headers, use them instead */ + /* Test based on url_extract_target() in misc.js */ + if (is_mozilla && header.name === "x-orig-csp") { + let index = header.value.indexOf('%5B'); + if (index === -1) + continue; + + let sig = header.value.substring(0, index); + let data = header.value.substring(index); + if (sig !== sign_policy(data, 0)) + continue; + + /* Confirmed- it's the originals, smuggled in! */ + try { + data = JSON.parse(decodeURIComponent(data)); + } catch (e) { + /* This should not be reached - + it's our self-produced valid JSON. */ + console.log("Unexpected internal error - invalid JSON smuggled!", e); + } + + orig_csp_headers = csp_headers = null; + for (const header of data) + headers.push(sanitize_csp_header(header, rule, block)); + } + } else if (is_chrome || !orig_csp_headers) { + csp_headers.push(sanitize_csp_header(header, rule, block)); + if (is_mozilla) + orig_csp_headers.push(header); + } + } - /* - * Chrome doesn't have the buggy behavior of caching headers - * we injected. Firefox does and we have to remove it there. - */ - if (!targets.policy.allow || is_mozilla) - headers = headers.filter(h => !is_csp_header(h)); + if (orig_csp_headers) { + /** Smuggle in the original CSP headers for future use. + * These are signed with a time of 0, as it's not clear there + * is a limit on how long Firefox might retain these headers in + * the cache. + */ + orig_csp_headers = encodeURIComponent(JSON.stringify(orig_csp_headers)); + headers.push({ + name: "x-orig-csp", + value: sign_policy(orig_csp_headers, 0) + orig_csp_headers + }); + + headers = headers.concat(csp_headers); + } - if (!targets.policy.allow) { + /* To ensure there is a CSP header if required */ + if (block) { headers.push({ - name : header_name, - value : rule + name: "content-security-policy", + value: `script-src ${rule}; script-src-elem ${rule}; ` + + "script-src-attr 'none'; prefetch-src 'none';" }); } diff --git a/common/misc.js b/common/misc.js index 7158d32..3c7dc46 100644 --- a/common/misc.js +++ b/common/misc.js @@ -155,6 +155,59 @@ function sign_policy(policy, now, hours_offset) { return gen_unique(time + policy); } +/* Parse a CSP header */ +function parse_csp(csp) { + let directive, directive_array; + let directives = {}; + for (directive of csp.split(';')) { + directive = directive.trim(); + if (directive === '') + continue; + + directive_array = directive.split(/\s+/); + directive = directive_array.shift(); + /* The "true" case should never occur; nevertheless... */ + directives[directive] = directive in directives ? + directives[directive].concat(directive_array) : + directive_array; + } + return directives; +} + +/* Make CSP headers do our bidding, not interfere */ +function sanitize_csp_header(header, rule, block) +{ + const csp = parse_csp(header.value); + + if (block) { + /* No snitching */ + delete csp['report-to']; + delete csp['report-uri']; + + delete csp['script-src']; + delete csp['script-src-elem']; + + csp['script-src-attr'] = ["'none'"]; + csp['prefetch-src'] = ["'none'"]; + } + + if ('script-src' in csp) + csp['script-src'].push(rule); + else + csp['script-src'] = [rule]; + + if ('script-src-elem' in csp) + csp['script-src-elem'].push(rule); + else + csp['script-src-elem'] = [rule]; + + const new_policy = Object.entries(csp).map( + i => `${i[0]} ${i[1].join(' ')};` + ); + + return {name: header.name, value: new_policy.join('')}; +} + /* Regexes and objest to use as/in schemas for parse_json_with_schema(). */ const nonempty_string_matcher = /.+/; @@ -178,6 +231,7 @@ const matchers = { * EXPORT nice_name * EXPORT open_in_settings * EXPORT is_privileged_url + * EXPORT sanitize_csp_header * EXPORT matchers * EXPORTS_END */ diff --git a/content/main.js b/content/main.js index 8f8375e..9ed557c 100644 --- a/content/main.js +++ b/content/main.js @@ -9,7 +9,6 @@ /* * IMPORTS_START * IMPORT handle_page_actions - * IMPORT url_item * IMPORT url_extract_target * IMPORT gen_unique * IMPORT gen_nonce |