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 @@
+