add Patch CSP option

This commit is contained in:
tophf 2020-11-15 19:34:37 +03:00
parent 497f31e3cd
commit 0816933932
6 changed files with 150 additions and 99 deletions

View File

@ -976,6 +976,12 @@
"optionsAdvancedNewStyleAsUsercss": {
"message": "Write new style as usercss"
},
"optionsAdvancedPatchCsp": {
"message": "Patch <code>CSP</code> to allow style assets"
},
"optionsAdvancedPatchCspNote": {
"message": "Enable this if some of your styles fail to show an image/background/font on sites with a strict <code>Content-Security-Policy</code>.\n\nEnabling this will loosen CSP a bit by merging it with <code>img-src data: *; font-src data: *; style-src 'unsafe-inline'</code> which means you should accept the potential risk and/or regularly check the CSS code of your styles. Read about CSS-based attacks for more information.\n\nNote, this is not guaranteed to take effect if another installed extension modifies the network response first."
},
"optionsAdvancedStyleViaXhr": {
"message": "Instant inject mode"
},

View File

@ -0,0 +1,129 @@
/* global API CHROME prefs */
'use strict';
// eslint-disable-next-line no-unused-expressions
CHROME && (async () => {
const idCsp = 'patchCsp';
const idOff = 'disableAll';
const idXhr = 'styleViaXhr';
const blobUrlPrefix = 'blob:' + chrome.runtime.getURL('/');
const stylesToPass = {};
const enabled = {};
await prefs.initializing;
prefs.subscribe([idXhr, idOff, idCsp], toggle, {now: true});
function toggle() {
const csp = prefs.get(idCsp) && !prefs.get(idOff);
const xhr = prefs.get(idXhr) && !prefs.get(idOff) && Boolean(chrome.declarativeContent);
if (xhr === enabled.xhr && csp === enabled.csp) {
return;
}
// Need to unregister first so that the optional EXTRA_HEADERS is properly registered
chrome.webRequest.onBeforeRequest.removeListener(prepareStyles);
chrome.webRequest.onHeadersReceived.removeListener(modifyHeaders);
if (xhr || csp) {
const reqFilter = {
urls: ['<all_urls>'],
types: ['main_frame', 'sub_frame'],
};
chrome.webRequest.onBeforeRequest.addListener(prepareStyles, reqFilter);
chrome.webRequest.onHeadersReceived.addListener(modifyHeaders, reqFilter, [
'blocking',
'responseHeaders',
xhr && chrome.webRequest.OnHeadersReceivedOptions.EXTRA_HEADERS,
].filter(Boolean));
}
if (enabled.xhr !== xhr) {
enabled.xhr = xhr;
toggleEarlyInjection();
}
enabled.csp = csp;
}
/** Runs content scripts earlier than document_start */
function toggleEarlyInjection() {
const api = chrome.declarativeContent;
if (!api) return;
api.onPageChanged.removeRules([idXhr], async () => {
if (enabled.xhr) {
api.onPageChanged.addRules([{
id: idXhr,
conditions: [
new api.PageStateMatcher({
pageUrl: {urlContains: '://'},
}),
],
actions: [
new api.RequestContentScript({
js: chrome.runtime.getManifest().content_scripts[0].js,
allFrames: true,
}),
],
}]);
}
});
}
/** @param {chrome.webRequest.WebRequestBodyDetails} req */
function prepareStyles(req) {
API.getSectionsByUrl(req.url).then(sections => {
if (Object.keys(sections).length) {
stylesToPass[req.requestId] = !enabled.xhr ? true :
URL.createObjectURL(new Blob([JSON.stringify(sections)])).slice(blobUrlPrefix.length);
setTimeout(cleanUp, 600e3, req.requestId);
}
});
}
/** @param {chrome.webRequest.WebResponseHeadersDetails} req */
function modifyHeaders(req) {
const {responseHeaders} = req;
const csp = responseHeaders.find(h => h.name.toLowerCase() === 'content-security-policy');
const id = stylesToPass[req.requestId];
if (!id) {
return;
}
let res;
if (enabled.xhr) {
res = true;
responseHeaders.push({
name: 'Set-Cookie',
value: `${chrome.runtime.id}=${prefs.get(idOff) ? 1 : 0}${id}`,
});
// Allow cookies in CSP sandbox (known case: raw github urls)
if (csp) {
csp.value = csp.value.replace(/(?:^|;)\s*sandbox(\s+[^;]*|)(?=;|$)/, (s, allow) =>
allow.split(/\s+/).includes('allow-same-origin') ? s : `${s} allow-same-origin`);
}
}
if (enabled.csp && csp) {
res = true;
const src = {};
for (let p of csp.value.split(';')) {
p = p.trim().split(/\s+/);
src[p[0]] = p.slice(1);
}
addToCsp(src, 'img-src', 'data:', '*');
addToCsp(src, 'font-src', 'data:', '*');
addToCsp(src, 'style-src', "'unsafe-inline'");
csp.value = Object.entries(src).map(([k, v]) => `${k} ${v.join(' ')}`).join('; ');
}
if (res) {
return {responseHeaders};
}
}
function addToCsp(src, name, ...values) {
const list = src[name] || (src[name] = []);
const def = src['default-src'] || [];
list.push(...values.filter(v => !list.includes(v) && !def.includes(v)));
if (!list.length) delete src[name];
}
function cleanUp(key) {
const blobId = stylesToPass[key];
delete stylesToPass[key];
if (blobId) URL.revokeObjectURL(blobUrlPrefix + blobId);
}
})();

View File

@ -1,98 +0,0 @@
/* global API CHROME prefs */
'use strict';
// eslint-disable-next-line no-unused-expressions
CHROME && (async () => {
const prefId = 'styleViaXhr';
const blobUrlPrefix = 'blob:' + chrome.runtime.getURL('/');
const stylesToPass = {};
let enabled;
await prefs.initializing;
prefs.subscribe([prefId, 'disableAll'], toggle, {now: true});
function toggle() {
let value = prefs.get(prefId) && !prefs.get('disableAll');
if (!chrome.declarativeContent) { // not yet granted in options page
value = false;
}
if (value === enabled) {
return;
}
enabled = value;
if (value) {
const reqFilter = {
urls: ['<all_urls>'],
types: ['main_frame', 'sub_frame'],
};
chrome.webRequest.onBeforeRequest.addListener(prepareStyles, reqFilter);
chrome.webRequest.onHeadersReceived.addListener(passStyles, reqFilter, [
'blocking',
'responseHeaders',
chrome.webRequest.OnHeadersReceivedOptions.EXTRA_HEADERS,
].filter(Boolean));
} else {
chrome.webRequest.onBeforeRequest.removeListener(prepareStyles);
chrome.webRequest.onHeadersReceived.removeListener(passStyles);
}
if (!chrome.declarativeContent) {
return;
}
chrome.declarativeContent.onPageChanged.removeRules([prefId], async () => {
if (!value) return;
chrome.declarativeContent.onPageChanged.addRules([{
id: prefId,
conditions: [
new chrome.declarativeContent.PageStateMatcher({
pageUrl: {urlContains: ':'},
}),
],
actions: [
new chrome.declarativeContent.RequestContentScript({
allFrames: true,
// This runs earlier than document_start
js: chrome.runtime.getManifest().content_scripts[0].js,
}),
],
}]);
});
}
/** @param {chrome.webRequest.WebRequestBodyDetails} req */
function prepareStyles(req) {
API.getSectionsByUrl(req.url).then(sections => {
const str = JSON.stringify(sections);
if (str !== '{}') {
stylesToPass[req.requestId] = URL.createObjectURL(new Blob([str])).slice(blobUrlPrefix.length);
setTimeout(cleanUp, 600e3, req.requestId);
}
});
}
/** @param {chrome.webRequest.WebResponseHeadersDetails} req */
function passStyles(req) {
const blobId = stylesToPass[req.requestId];
if (blobId) {
const {responseHeaders} = req;
responseHeaders.push({
name: 'Set-Cookie',
value: `${chrome.runtime.id}=${prefs.get('disableAll') ? 1 : 0}${blobId}`,
});
// allow cookies for sandbox CSP (known case: raw github urls)
for (const h of responseHeaders) {
if (h.name.toLowerCase() === 'content-security-policy' && h.value.includes('sandbox')) {
h.value = h.value.replace(/(?:^|;)\s*sandbox(\s+[^;]*|)(?=;|$)/, (s, allow) =>
allow.split(/\s+/).includes('allow-same-origin') ? s : `${s} allow-same-origin`);
break;
}
}
return {responseHeaders};
}
}
function cleanUp(key) {
const blobId = stylesToPass[key];
delete stylesToPass[key];
if (blobId) URL.revokeObjectURL(blobUrlPrefix + blobId);
}
})();

View File

@ -15,6 +15,7 @@ window.INJECTED !== 1 && (() => {
'exposeIframes': false, // Add 'stylus-iframe' attribute to HTML element in all iframes
'newStyleAsUsercss': false, // create new style in usercss format
'styleViaXhr': false, // early style injection to avoid FOUC
'patchCsp': false, // add data: and popular image hosting sites to strict CSP
// checkbox in style config dialog
'config.autosave': true,

View File

@ -55,7 +55,7 @@
"background/usercss-helper.js",
"background/usercss-install-helper.js",
"background/style-via-api.js",
"background/style-via-xhr.js",
"background/style-via-webrequest.js",
"background/search-db.js",
"background/update.js",
"background/openusercss-api.js"

View File

@ -247,6 +247,19 @@
<span></span>
</span>
</label>
<label>
<span>
<span i18n-html="optionsAdvancedPatchCsp"></span>
<a i18n-title="optionsAdvancedPatchCspNote"
data-cmd="note" href="#" class="svg-inline-wrapper">
<svg class="svg-icon info"><use xlink:href="#svg-icon-help"/></svg>
</a>
</span>
<span class="onoffswitch">
<input type="checkbox" id="patchCsp" class="slider">
<span></span>
</span>
</label>
<label>
<span i18n-text="optionsAdvancedExposeIframes">
<a i18n-title="optionsAdvancedExposeIframesNote"