From 3922f234f4ed22c7a97da768becf574d71a94afc Mon Sep 17 00:00:00 2001 From: hackademix Date: Fri, 28 Aug 2020 21:00:01 +0200 Subject: [PATCH] Refactored and improved syncFetchPolicy fallback for file: and ftp: special cases. --- build.sh | 6 +- html5_events/html5_events.pl | 12 ++-- src/content/DocumentCSP.js | 18 ----- src/content/staticNS.js | 126 ++------------------------------- src/content/syncFetchPolicy.js | 116 ++++++++++++++++++++++++++++++ src/lib/SyncMessage.js | 3 +- src/manifest.json | 11 ++- 7 files changed, 142 insertions(+), 150 deletions(-) create mode 100644 src/content/syncFetchPolicy.js diff --git a/build.sh b/build.sh index 16f9846..2fa02d8 100644 --- a/build.sh +++ b/build.sh @@ -69,7 +69,11 @@ if ! [ $(date -r "$LIB/tld.js" +'%Y%m%d') -ge $(date +'%Y%m%d') -a "$1" != "tld git add src/lib/tld.js TLD && git commit -m'Updated TLDs.' fi -./html5_events/html5_events.pl +if ./html5_events/html5_events.pl; then + # update full event list as an array in src/content/syncFetchPolicy.js + EVENTS=$(egrep '^on[a-z]+$' html5_events/html5_events_archive.txt | sed "s/^on//;s/.*/'&'/;H;1h;"'$!d;x;s/\n/, /g'); + perl -pi -e 's/(\blet eventTypes\s*=\s*)\[.*?\]/$1['"$EVENTS"']/' src/content/syncFetchPolicy.js +fi rm -rf "$BUILD" "$XPI" cp -pR "$SRC" "$BUILD" diff --git a/html5_events/html5_events.pl b/html5_events/html5_events.pl index 0406ff1..df83040 100644 --- a/html5_events/html5_events.pl +++ b/html5_events/html5_events.pl @@ -103,12 +103,12 @@ sub patch if ($must_replace) { rename $dst, $src; print "Patched.\n"; + return 0; } - else - { - unlink $dst; - print "Nothing to do.\n"; - } + + unlink $dst; + print "Nothing to do.\n"; + return 1; } -patch($SOURCE_FILE); +exit(patch($SOURCE_FILE)); diff --git a/src/content/DocumentCSP.js b/src/content/DocumentCSP.js index 34406ff..df354c2 100644 --- a/src/content/DocumentCSP.js +++ b/src/content/DocumentCSP.js @@ -6,13 +6,6 @@ class DocumentCSP { this.root = document.documentElement; } - removeEventAttributes() { - console.debug("Removing event attributes"); // DEV_ONLY - let {root} = this; - this.rootAttrs = [...root.attributes].filter(a => a.name.toLowerCase().startsWith("on")); - for (let a of this.rootAttrs) root.removeAttributeNode(a); - } - apply(capabilities, embedding = CSP.isEmbedType(this.document.contentType)) { let {document} = this; if (!capabilities.has("script")) { @@ -40,8 +33,6 @@ class DocumentCSP { meta.setAttribute("http-equiv", header.name); meta.setAttribute("content", header.value); let root = document.documentElement; - let rootAttrs = [...root.attributes].filter(a => a.name.toLowerCase().startsWith("on")); - for (let a of rootAttrs) root.removeAttributeNode(a); let {head} = document; let parent = head || @@ -60,13 +51,4 @@ class DocumentCSP { } return true; } - - restoreEventAttributes() { - if (!this.rootAttrs) return; - console.debug("Restoring event attributes"); // DEV_ONLY - let {root, rootAttrs} = this; - for (let a of rootAttrs) { - root.setAttributeNodeNS(a); - } - } } diff --git a/src/content/staticNS.js b/src/content/staticNS.js index fef0daf..1acde13 100644 --- a/src/content/staticNS.js +++ b/src/content/staticNS.js @@ -2,7 +2,6 @@ 'use strict'; let listenersMap = new Map(); let backlog = new Set(); - let documentCSP = new DocumentCSP(document); let ns = { debug: true, // DEV_ONLY @@ -42,8 +41,9 @@ //, document.domain, document.baseURI, window.isSecureContext // DEV_ONLY ); - let requireDocumentCSP = /^(?:ftp|file):/.test(url); - if (!requireDocumentCSP) { + if (this.syncFetchPolicy) { // ftp: or file: - no CSP headers yet + this.syncFetchPolicy(); + } else { // CSP headers have been already provided by webRequest, we are not in a hurry... if (/^(javascript|about):/.test(url)) { url = document.readyState === "loading" @@ -68,118 +68,6 @@ asyncFetch(); return; } - - // Here we've got no CSP header yet (file: or ftp: URL), we need one - // injected in the DOM as soon as possible. - debug("No CSP yet for non-HTTP document load: fetching policy synchronously..."); - documentCSP.removeEventAttributes(); - - let earlyScripts = []; - let dequeueEarlyScripts = (last = false) => { - if (!(ns.canScript && earlyScripts)) return; - if (earlyScripts.length === 0) { - earlyScripts = null; - return; - } - for (let s; s = earlyScripts.shift(); ) { - debug("Restoring", s); - s.firstChild._replaced = true; - s._original.replaceWith(s); - } - } - - let syncFetch = callback => { - browser.runtime.sendSyncMessage( - {id: "fetchPolicy", url, contextUrl: url}, - callback); - }; - - if (UA.isMozilla && document.readyState !== "complete") { - // Mozilla has already parsed the element, we must take extra steps... - - debug("Early parsing: preemptively suppressing events and script execution."); - - { - let eventTypes = []; - for (let p in document.documentElement) if (p.startsWith("on")) eventTypes.push(p.substring(2)); - let eventSuppressor = e => { - try { - debug("Event suppressor called for ", e.type, e.target, earlyScripts, e.target._earlyScript); // DEV_ONLY - if (!earlyScripts || document.readyState === "complete") { - debug("Stopping event suppression"); - for (let et of eventTypes) document.removeEventListener(et, eventSuppressor, true); - return; - } - - if (!ns.canScript || e.target._earlyScript) { - e.stopImmediatePropagation(); - debug(`Suppressing ${e.type} on `, e.target); // DEV_ONLY - } - } catch (e) { - error(e); - } - } - debug("Starting event suppression"); - for (let et of eventTypes) document.addEventListener(et, eventSuppressor, true); - - ns.on("capabilities", () => { - if (!ns.canScript) { - try { - for (node of document.querySelectorAll("*")) { - let evAttrs = [...node.attributes].filter(a => a.name.toLowerCase().startsWith("on")); - for (let a of evAttrs) { - debug("Reparsing event attribute after CSP", a, node); - node.removeAttributeNode(a); - node.setAttributeNodeNS(a); - } - } - } catch (e) { - error(e); - } - } - - }); - } - - addEventListener("beforescriptexecute", e => { - debug(e.type, e.target); - if (earlyScripts) { - let s = e.target; - if (s._replaced) { - debug("Replaced script found"); - dequeueEarlyScripts(true); - return; - } - let replacement = document.createRange().createContextualFragment(s.outerHTML); - replacement._original = s; - s._earlyScript = true; - earlyScripts.push(replacement); - e.preventDefault(); - dequeueEarlyScripts(true); - debug("Blocked early script"); - } - }, true); - } - - let setup = policy => { - debug("Fetched %o, readyState %s", policy, document.readyState); // DEV_ONLY - this.setup(policy); - documentCSP.restoreEventAttributes(); - } - - for (let attempts = 3; attempts-- > 0;) { - try { - syncFetch(setup); - break; - } catch (e) { - if (!Messages.isMissingEndpoint(e) || document.readyState === "complete") { - error(e); - break; - } - error("Background page not ready yet, retrying to fetch policy...") - } - } - dequeueEarlyScripts(); }, setup(policy) { @@ -196,7 +84,7 @@ } else { let perms = policy.permissions; this.capabilities = new Set(perms.capabilities); - documentCSP.apply(this.capabilities, this.embeddingDocument); + new DocumentCSP(document).apply(this.capabilities, this.embeddingDocument); } this.canScript = this.allows("script"); this.fire("capabilities"); @@ -209,9 +97,5 @@ }, }; - if (this.ns) { - this.ns.merge(ns); - } else { - this.ns = ns; - } + this.ns = this.ns ? Object.assign(this.ns, ns) : ns; } diff --git a/src/content/syncFetchPolicy.js b/src/content/syncFetchPolicy.js new file mode 100644 index 0000000..6aab543 --- /dev/null +++ b/src/content/syncFetchPolicy.js @@ -0,0 +1,116 @@ +"use strict"; + +(this.ns || (this.ns = {})).syncFetchPolicy = function() { + + let url = document.URL; + + // Here we've got no CSP header yet (file: or ftp: URL), we need one + // injected in the DOM as soon as possible. + debug("No CSP yet for non-HTTP document load: fetching policy synchronously..."); + + let earlyScripts = []; + let dequeueEarlyScripts = (last = false) => { + if (!(ns.canScript && earlyScripts)) return; + if (earlyScripts.length === 0) { + earlyScripts = null; + return; + } + for (let s; earlyScripts && (s = earlyScripts.shift()); ) { + debug("Restoring", s); + s.firstChild._replaced = true; + s._original.replaceWith(s); + } + } + + let syncFetch = callback => { + browser.runtime.sendSyncMessage( + {id: "fetchPolicy", url, contextUrl: url}, + callback); + }; + + if (UA.isMozilla && document.readyState !== "complete") { + // Mozilla has already parsed the element, we must take extra steps... + + debug("Early parsing: preemptively suppressing events and script execution."); + + { + // List updated by build.sh from https://hg.mozilla.org/mozilla-central/raw-file/tip/xpcom/ds/StaticAtoms.py + // whenever html5_events/html5_events.pl retrieves something new. + let eventTypes = ['abort', 'mozaccesskeynotfound', 'activate', 'afterprint', 'afterscriptexecute', 'animationcancel', 'animationend', 'animationiteration', 'animationstart', 'audioprocess', 'auxclick', 'beforecopy', 'beforecut', 'beforeinput', 'beforepaste', 'beforeprint', 'beforescriptexecute', 'beforeunload', 'blocked', 'blur', 'bounce', 'boundschange', 'broadcast', 'bufferedamountlow', 'cached', 'cancel', 'change', 'chargingchange', 'chargingtimechange', 'checking', 'click', 'close', 'command', 'commandupdate', 'complete', 'compositionend', 'compositionstart', 'compositionupdate', 'connect', 'connectionavailable', 'contextmenu', 'copy', 'cut', 'dblclick', 'dischargingtimechange', 'downloading', 'data', 'drag', 'dragdrop', 'dragend', 'dragenter', 'dragexit', 'dragleave', 'dragover', 'dragstart', 'drain', 'drop', 'error', 'finish', 'focus', 'focusin', 'focusout', 'fullscreenchange', 'fullscreenerror', 'get', 'hashchange', 'input', 'inputsourceschange', 'install', 'invalid', 'keydown', 'keypress', 'keyup', 'languagechange', 'levelchange', 'load', 'loading', 'loadingdone', 'loadingerror', 'popstate', 'merchantvalidation', 'message', 'messageerror', 'midimessage', 'mousedown', 'mouseenter', 'mouseleave', 'mouselongtap', 'mousemove', 'mouseout', 'mouseover', 'mouseup', 'mozfullscreenchange', 'mozfullscreenerror', 'mozkeydownonplugin', 'mozkeyuponplugin', 'mozpointerlockchange', 'mozpointerlockerror', 'mute', 'notificationclick', 'notificationclose', 'noupdate', 'obsolete', 'online', 'offline', 'open', 'orientationchange', 'overflow', 'pagehide', 'pageshow', 'paste', 'payerdetailchange', 'paymentmethodchange', 'pointerlockchange', 'pointerlockerror', 'popuphidden', 'popuphiding', 'popuppositioned', 'popupshowing', 'popupshown', 'processorerror', 'push', 'pushsubscriptionchange', 'readystatechange', 'rejectionhandled', 'remove', 'requestprogress', 'resourcetimingbufferfull', 'responseprogress', 'reset', 'resize', 'scroll', 'select', 'selectionchange', 'selectend', 'selectstart', 'set', 'shippingaddresschange', 'shippingoptionchange', 'show', 'squeeze', 'squeezeend', 'squeezestart', 'statechange', 'storage', 'submit', 'success', 'typechange', 'terminate', 'text', 'toggle', 'tonechange', 'touchstart', 'touchend', 'touchmove', 'touchcancel', 'transitioncancel', 'transitionend', 'transitionrun', 'transitionstart', 'underflow', 'unhandledrejection', 'unload', 'unmute', 'updatefound', 'updateready', 'upgradeneeded', 'versionchange', 'visibilitychange', 'voiceschanged', 'vrdisplayactivate', 'vrdisplayconnect', 'vrdisplaydeactivate', 'vrdisplaydisconnect', 'vrdisplaypresentchange', 'webkitanimationend', 'webkitanimationiteration', 'webkitanimationstart', 'webkittransitionend', 'wheel', 'zoom', 'begin', 'end', 'repeat', 'pointerdown', 'pointermove', 'pointerup', 'pointercancel', 'pointerover', 'pointerout', 'pointerenter', 'pointerleave', 'gotpointercapture', 'lostpointercapture', 'devicemotion', 'deviceorientation', 'absolutedeviceorientation', 'deviceproximity', 'mozorientationchange', 'userproximity', 'devicelight', 'devicechange', 'mozvisualresize', 'mozvisualscroll', 'mozshowdropdown', 'scrollend', 'loadend', 'loadstart', 'progress', 'suspend', 'emptied', 'stalled', 'play', 'pause', 'loadedmetadata', 'loadeddata', 'waiting', 'playing', 'canplay', 'canplaythrough', 'seeking', 'seeked', 'timeout', 'timeupdate', 'ended', 'formdata', 'ratechange', 'durationchange', 'volumechange', 'addtrack', 'controllerchange', 'cuechange', 'enter', 'exit', 'encrypted', 'waitingforkey', 'keystatuseschange', 'removetrack', 'dataavailable', 'warning', 'start', 'stop', 'photo', 'gamepadbuttondown', 'gamepadbuttonup', 'gamepadaxismove', 'gamepadconnected', 'gamepaddisconnected', 'fetch', 'audiostart', 'audioend', 'soundstart', 'soundend', 'speechstart', 'speechend', 'result', 'nomatch', 'resume', 'mark', 'boundary', 'activated', 'deactivated', 'metadatachange', 'playbackstatechange', 'positionstatechange', 'supportedkeyschange', 'sourceopen', 'sourceended', 'sourceclosed', 'updatestart', 'update', 'updateend', 'addsourcebuffer', 'removesourcebuffer', 'appinstalled', 'activestatechanged', 'adapteradded', 'adapterremoved', 'alerting', 'antennaavailablechange', 'attributechanged', 'attributereadreq', 'attributewritereq', 'beforeevicted', 'busy', 'callschanged', 'cardstatechange', 'cfstatechange', 'characteristicchanged', 'clirmodechange', 'connected', 'connecting', 'connectionstatechanged', 'currentchannelchanged', 'currentsourcechanged', 'datachange', 'dataerror', 'deleted', 'deliveryerror', 'deliverysuccess', 'devicefound', 'devicepaired', 'deviceunpaired', 'dialing', 'disabled', 'disconnect', 'disconnected', 'disconnecting', 'displaypasskeyreq', 'draggesture', 'eitbroadcasted', 'emergencycbmodechange', 'enabled', 'enterpincodereq', 'evicted', 'failed', 'frequencychange', 'groupchange', 'headphoneschange', 'held', 'hfpstatuschanged', 'hidstatuschanged', 'holding', 'iccchange', 'iccdetected', 'iccinfochange', 'iccundetected', 'incoming', 'mapfolderlistingreq', 'mapgetmessagereq', 'mapmessageslistingreq', 'mapmessageupdatereq', 'mapsendmessagereq', 'mapsetmessagestatusreq', 'mousewheel', 'mozbrowserafterkeydown', 'mozbrowserafterkeyup', 'mozbrowserbeforekeydown', 'mozbrowserbeforekeyup', 'mozinterruptbegin', 'mozinterruptend', 'moznetworkdownload', 'moznetworkupload', 'moztimechange', 'newrdsgroup', 'obexpasswordreq', 'otastatuschange', 'overflowchanged', 'paint', 'pairingaborted', 'pairingconfirmationreq', 'pairingconsentreq', 'pendingchange', 'pichange', 'pschange', 'ptychange', 'pullphonebookreq', 'pullvcardentryreq', 'pullvcardlistingreq', 'radiostatechange', 'rdsdisabled', 'rdsenabled', 'readerror', 'readsuccess', 'ready', 'received', 'reloadpage', 'remoteheld', 'remoteresumed', 'requestmediaplaystatus', 'resuming', 'retrieving', 'rtchange', 'scanningstatechanged', 'scostatuschanged', 'sending', 'sent', 'speakerforcedchange', 'statuschanged', 'stkcommand', 'stksessionend', 'storageareachanged', 'ussdreceived', 'voicechange', 'websocket']; + let eventSuppressor = e => { + try { + debug("Event suppressor called for ", e.type, e.target, earlyScripts, e.target._earlyScript); // DEV_ONLY + if (!earlyScripts || document.readyState === "complete") { + debug("Stopping event suppression"); + for (let et of eventTypes) document.removeEventListener(et, eventSuppressor, true); + return; + } + + if (!ns.canScript || e.target._earlyScript) { + e.stopPropagation(); + debug(`Suppressing ${e.type} on `, e.target); // DEV_ONLY + } + } catch (e) { + error(e); + } + } + debug("Starting event suppression"); + for (let et of eventTypes) document.addEventListener(et, eventSuppressor, true); + + ns.on("capabilities", () => { + if (!ns.canScript) { + try { + for (let node of document.querySelectorAll("*")) { + let evAttrs = [...node.attributes].filter(a => a.name.toLowerCase().startsWith("on")); + for (let a of evAttrs) { + debug("Reparsing event attribute after CSP", a, node); + node.removeAttributeNode(a); + node.setAttributeNodeNS(a); + } + } + } catch (e) { + error(e); + } + } + }); + } + + addEventListener("beforescriptexecute", e => { + debug(e.type, e.target); + if (earlyScripts) { + let s = e.target; + if (s._replaced) { + debug("Replaced script found"); + dequeueEarlyScripts(true); + return; + } + let replacement = document.createRange().createContextualFragment(s.outerHTML); + replacement._original = s; + s._earlyScript = true; + earlyScripts.push(replacement); + e.preventDefault(); + dequeueEarlyScripts(true); + debug("Blocked early script"); + } + }, true); + } + + let setup = policy => { + debug("Fetched %o, readyState %s", policy, document.readyState); // DEV_ONLY + ns.setup(policy); + } + + for (let attempts = 3; attempts-- > 0;) { + try { + syncFetch(setup); + break; + } catch (e) { + if (!Messages.isMissingEndpoint(e) || document.readyState === "complete") { + error(e); + break; + } + error("Background page not ready yet, retrying to fetch policy...") + } + } + dequeueEarlyScripts(); +} \ No newline at end of file diff --git a/src/lib/SyncMessage.js b/src/lib/SyncMessage.js index 68e027f..f7fcd3a 100644 --- a/src/lib/SyncMessage.js +++ b/src/lib/SyncMessage.js @@ -244,8 +244,7 @@ console.debug("sendSyncMessage suspending on ", records) suspend(); }); - domSuspender.observe(document.documentElement, {childList: true}); - + domSuspender.observe(document, {childList: true, subtree: true}); let finalize = () => { console.debug("sendSyncMessage finalizing"); domSuspender.disconnect(); diff --git a/src/manifest.json b/src/manifest.json index 2b29120..ff268a9 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -72,7 +72,15 @@ "css": [ "/content/content.css" ] - }, + }, + { + "run_at": "document_start", + "matches": ["file://*/*", "ftp://*/*"], + "js": [ + "lib/SyncMessage.js", + "content/syncFetchPolicy.js" + ] + }, { "run_at": "document_start", "matches": [""], @@ -83,7 +91,6 @@ "lib/browser-polyfill.js", "lib/log.js", "lib/uuid.js", - "lib/SyncMessage.js", "lib/sha256.js", "lib/Messages.js", "lib/CSP.js",