diff --git a/_locales/en/messages.json b/_locales/en/messages.json index d99e03e5..840f4efe 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -976,6 +976,12 @@ "optionsAdvancedNewStyleAsUsercss": { "message": "Write new style as usercss" }, + "optionsAdvancedPatchCsp": { + "message": "Patch CSP 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 Content-Security-Policy.\n\nEnabling this will loosen CSP a bit by merging it with img-src data: *; font-src data: *; style-src 'unsafe-inline' 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" }, diff --git a/background/style-via-webrequest.js b/background/style-via-webrequest.js new file mode 100644 index 00000000..a70e9a2a --- /dev/null +++ b/background/style-via-webrequest.js @@ -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: [''], + 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); + } +})(); diff --git a/background/style-via-xhr.js b/background/style-via-xhr.js deleted file mode 100644 index cc8d63f7..00000000 --- a/background/style-via-xhr.js +++ /dev/null @@ -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: [''], - 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); - } -})(); diff --git a/js/prefs.js b/js/prefs.js index ea290534..4e3f935e 100644 --- a/js/prefs.js +++ b/js/prefs.js @@ -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, diff --git a/manifest.json b/manifest.json index faf9d0ca..da846a65 100644 --- a/manifest.json +++ b/manifest.json @@ -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" diff --git a/options.html b/options.html index 3a599808..a8582b27 100644 --- a/options.html +++ b/options.html @@ -247,6 +247,19 @@ +