Browse Source

feat: analyze CSP and skip page mode if unavailable

+ re-allow page mode scripts on install/update because we're using an iframe fallback now
tophf 4 years ago
parent
commit
5c9c96291e
2 changed files with 61 additions and 24 deletions
  1. 59 19
      src/background/utils/preinject.js
  2. 2 5
      src/injected/content/index.js

+ 59 - 19
src/background/utils/preinject.js

@@ -1,6 +1,7 @@
 import { getScriptName, getUniqId } from '#/common';
 import {
-  INJECT_AUTO, INJECT_CONTENT, INJECT_MAPPING, INJECTABLE_TAB_URL_RE, METABLOCK_RE,
+  INJECT_AUTO, INJECT_CONTENT, INJECT_MAPPING, INJECT_PAGE,
+  INJECTABLE_TAB_URL_RE, METABLOCK_RE,
 } from '#/common/consts';
 import initCache from '#/common/cache';
 import { forEachEntry, objectPick, objectSet } from '#/common/object';
@@ -27,8 +28,10 @@ const cache = initCache({
     rcs?.unregister();
   },
 });
+const INJECT_INTO = 'injectInto';
+// KEY_XXX for hooked options
 const KEY_EXPOSE = 'expose';
-const KEY_INJECT_INTO = 'defaultInjectInto';
+const KEY_DEF_INJECT_INTO = 'defaultInjectInto';
 const KEY_IS_APPLIED = 'isApplied';
 const KEY_XHR_INJECT = 'xhrInject';
 const expose = {};
@@ -37,7 +40,7 @@ let injectInto;
 let xhrInject;
 hookOptions(onOptionChanged);
 postInitialize.push(() => {
-  for (const key of [KEY_EXPOSE, KEY_INJECT_INTO, KEY_IS_APPLIED, KEY_XHR_INJECT]) {
+  for (const key of [KEY_EXPOSE, KEY_DEF_INJECT_INTO, KEY_IS_APPLIED, KEY_XHR_INJECT]) {
     onOptionChanged({ [key]: getOption(key) });
   }
 });
@@ -96,7 +99,7 @@ browser.storage.onChanged.addListener(async changes => {
   }
 });
 
-function normalizeInjectInto(value) {
+function normalizeRealm(value) {
   return INJECT_MAPPING::hasOwnProperty(value)
     ? value
     : injectInto || INJECT_AUTO;
@@ -105,8 +108,8 @@ function normalizeInjectInto(value) {
 function onOptionChanged(changes) {
   changes::forEachEntry(([key, value]) => {
     switch (key) {
-    case KEY_INJECT_INTO:
-      injectInto = normalizeInjectInto(value);
+    case KEY_DEF_INJECT_INTO:
+      injectInto = normalizeRealm(value);
       cache.destroy();
       break;
     case KEY_XHR_INJECT:
@@ -179,21 +182,32 @@ function onSendHeaders({ url, tabId, frameId }) {
 /** @param {chrome.webRequest.WebResponseHeadersDetails} info */
 function onHeadersReceived(info) {
   const key = getKey(info.url, !info.frameId);
-  const injection = xhrInject && cache.get(key)?.inject;
+  const data = xhrInject && cache.get(key);
   cache.hit(key, TIME_AFTER_RECEIVE);
-  if (injection) {
-    const blobUrl = URL.createObjectURL(new Blob([JSON.stringify(injection)]));
-    const { responseHeaders } = info;
-    responseHeaders.push({
-      name: 'Set-Cookie',
-      value: `"${process.env.INIT_FUNC_NAME}"=${blobUrl.split('/').pop()}; SameSite=Lax`,
-    });
-    setTimeout(URL.revokeObjectURL, TIME_KEEP_DATA, blobUrl);
-    return { responseHeaders };
+  return data?.inject && prepareXhrBlob(info, data);
+}
+
+/**
+ * @param {chrome.webRequest.WebResponseHeadersDetails} info
+ * @param {VMGetInjectedDataContainer} data
+ */
+function prepareXhrBlob({ url, responseHeaders }, data) {
+  if (url.startsWith('https:') && detectStrictCsp(responseHeaders)) {
+    forceContentInjection(data);
   }
+  const blobUrl = URL.createObjectURL(new Blob([
+    JSON.stringify(data.inject),
+  ]));
+  responseHeaders.push({
+    name: 'Set-Cookie',
+    value: `"${process.env.INIT_FUNC_NAME}"=${blobUrl.split('/').pop()}; SameSite=Lax`,
+  });
+  setTimeout(URL.revokeObjectURL, TIME_KEEP_DATA, blobUrl);
+  return { responseHeaders };
 }
 
 function prepare(key, url, tabId, frameId, forceContent) {
+  /** @namespace VMGetInjectedDataContainer */
   const res = {
     /** @namespace VMGetInjectedData */
     inject: {
@@ -218,8 +232,8 @@ async function prepareScripts(res, cacheKey, url, tabId, frameId, forceContent)
   const { inject } = res;
   /** @namespace VMGetInjectedData */
   Object.assign(inject, {
-    injectInto,
     scripts,
+    [INJECT_INTO]: injectInto,
     cache: data.cache,
     feedId: {
       cacheKey, // InjectionFeedback cache key for cleanup when getDataFF outruns GetInjected
@@ -231,6 +245,7 @@ async function prepareScripts(res, cacheKey, url, tabId, frameId, forceContent)
       ua,
     },
   });
+  /** @namespace VMGetInjectedDataContainer */
   Object.assign(res, {
     feedback,
     valOpIds: [...data[ENV_VALUE_IDS], ...envDelayed[ENV_VALUE_IDS]],
@@ -252,7 +267,7 @@ function prepareScript(script) {
   const dataKey = getUniqId('VMin');
   const displayName = getScriptName(script);
   const name = encodeURIComponent(displayName.replace(/[#&',/:;?@=+]/g, replaceWithFullWidthForm));
-  const realm = normalizeInjectInto(custom.injectInto || meta.injectInto);
+  const realm = normalizeRealm(custom[INJECT_INTO] || meta[INJECT_INTO]);
   const isContent = realm === INJECT_CONTENT || forceContent && realm === INJECT_AUTO;
   const pathMap = custom.pathMap || {};
   const reqs = meta.require?.map(key => require[pathMap[key] || key]).filter(Boolean);
@@ -282,7 +297,7 @@ function prepareScript(script) {
     displayName,
     // code will be `true` if the desired realm is PAGE which is not injectable
     code: isContent ? '' : forceContent || injectedCode,
-    injectInto: realm,
+    [INJECT_INTO]: realm,
     metaStr: code.match(METABLOCK_RE)[1] || '',
     values: value[id] || null,
   });
@@ -317,3 +332,28 @@ function registerScriptDataFF(inject, url, allFrames) {
     runAt: 'document_start',
   });
 }
+
+/** @param {chrome.webRequest.HttpHeader[]} responseHeaders */
+function detectStrictCsp(responseHeaders) {
+  return responseHeaders.some(({ name, value }) => (
+    /^content-security-policy$/i.test(name)
+    && /^.(?!.*'unsafe-inline')/.test( // true if not empty and without 'unsafe-inline'
+      value.match(/(?:^|;)\s*script-src-elem\s[^;]+/)
+      || value.match(/(?:^|;)\s*script-src\s[^;]+/)
+      || value.match(/(?:^|;)\s*default-src\s[^;]+/)
+      || '',
+    )
+  ));
+}
+
+/** @param {VMGetInjectedDataContainer} data */
+function forceContentInjection(data) {
+  /** @type VMGetInjectedData */
+  const inject = data.inject;
+  inject.forceContent = true;
+  inject.scripts.forEach(scr => {
+    const realm = normalizeRealm(scr.custom[INJECT_INTO] || scr.meta[INJECT_INTO]);
+    scr.code = realm === INJECT_PAGE || ''; // `true` will put it into failedIds
+    data.feedback.push([scr.dataKey, true]);
+  });
+}

+ 2 - 5
src/injected/content/index.js

@@ -18,18 +18,15 @@ let bfCacheWired;
 async function init() {
   const contentId = getUniqIdSafe();
   const webId = getUniqIdSafe();
-  const pageInfo = {
+  const xhrData = getXhrInjection();
+  const pageInfo = !xhrData?.forceContent && {
     /* In FF93 sender.url is wrong: https://bugzil.la/1734984,
      * in Chrome sender.url is ok, but location.href is wrong for text selection URLs #:~:text= */
     url: IS_FIREFOX && global.location.href,
     // XML document's appearance breaks when script elements are added
     forceContent: document instanceof XMLDocument
-    /* Don't autorun `page` mode userscripts when the extension was installed/updated in Firefox
-     * since sites can spoof JS environment and easily impersonate a userscript in `page` mode. */
-      || IS_FIREFOX && !isDocumentLoading()
       || !injectPageSandbox(contentId, webId),
   };
-  const xhrData = getXhrInjection();
   const dataPromise = !xhrData && sendCmd('GetInjected', pageInfo, { retry: true });
   // detecting if browser.contentScripts is usable, it was added in FF59 as well as composedPath
   const data = xhrData || (