diff --git a/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/ScriptHandlers/Paged/ContentBlockerScriptHandler.swift b/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/ScriptHandlers/Paged/ContentBlockerScriptHandler.swift index 84af8fe77698..0782a9670968 100644 --- a/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/ScriptHandlers/Paged/ContentBlockerScriptHandler.swift +++ b/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/ScriptHandlers/Paged/ContentBlockerScriptHandler.swift @@ -19,7 +19,7 @@ extension ContentBlockerHelper: TabContentScript { } let securityToken: String - let data: ContentblockerDTOData + let data: [ContentblockerDTOData] } static let scriptName = "TrackingProtectionStats" @@ -62,71 +62,73 @@ extension ContentBlockerHelper: TabContentScript { do { let data = try JSONSerialization.data(withJSONObject: message.body) - let dto = try JSONDecoder().decode(ContentBlockerDTO.self, from: data) - - Task { @MainActor in - let isPrivateBrowsing = self.tab?.isPrivate == true - let domain = Domain.getOrCreate(forUrl: currentTabURL, persistent: !isPrivateBrowsing) - if domain.areAllShieldsOff { - // if domain is "all_off", can just skip - return - } - - if dto.data.resourceType == .script && domain.isShieldExpected(.NoScript, considerAllShieldsOption: true) { - self.stats = self.stats.adding(scriptCount: 1) - BraveGlobalShieldStats.shared.scripts += 1 - return - } - - // Because javascript urls allow some characters that `URL` does not, - // we use `NSURL(idnString: String)` to parse them - guard let requestURL = NSURL(idnString: dto.data.resourceURL) as URL? else { return } - guard let sourceURL = NSURL(idnString: dto.data.sourceURL) as URL? else { return } - guard let domainURLString = domain.url else { return } - let genericTypes = ContentBlockerManager.shared.validGenericTypes(for: domain) - - let blockedType = await TPStatsBlocklistChecker.shared.blockedTypes( - requestURL: requestURL, - sourceURL: sourceURL, - enabledRuleTypes: genericTypes, - resourceType: dto.data.resourceType, - isAggressiveMode: domain.blockAdsAndTrackingLevel.isAggressive - ) - - guard let blockedType = blockedType else { return } - - assertIsMainThread("Result should happen on the main thread") - - // Ensure we check that the stats we're tracking is still for the same page - // Some web pages (like youtube) like to rewrite their main frame urls - // so we check the source etld+1 agains the tab url etld+1 - // For subframes which may use different etld+1 than the main frame (example `reddit.com` and `redditmedia.com`) - // We simply check the known subframeURLs on this page. - guard self.tab?.url?.baseDomain == sourceURL.baseDomain || - self.tab?.currentPageData?.allSubframeURLs.contains(sourceURL) == true else { - return - } - - if blockedType == .ad, Preferences.PrivacyReports.captureShieldsData.value, - let domainURL = URL(string: domainURLString), - let blockedResourceHost = requestURL.baseDomain, - tab?.isPrivate != true { - PrivacyReportsManager.pendingBlockedRequests.append((blockedResourceHost, domainURL, Date())) - } - - // First check to make sure we're not counting the same repetitive requests multiple times - guard !self.blockedRequests.contains(requestURL) else { return } - self.blockedRequests.insert(requestURL) - - // Increase global stats (here due to BlocklistName being in Client and BraveGlobalShieldStats being - // in BraveShared) - let stats = BraveGlobalShieldStats.shared - switch blockedType { - case .ad: - stats.adblock += 1 - self.stats = self.stats.adding(adCount: 1) - case .image: - stats.images += 1 + let dtos = try JSONDecoder().decode(ContentBlockerDTO.self, from: data).data + + dtos.forEach { dto in + Task { @MainActor in + let isPrivateBrowsing = self.tab?.isPrivate == true + let domain = Domain.getOrCreate(forUrl: currentTabURL, persistent: !isPrivateBrowsing) + if domain.areAllShieldsOff { + // if domain is "all_off", can just skip + return + } + + if dto.resourceType == .script && domain.isShieldExpected(.NoScript, considerAllShieldsOption: true) { + self.stats = self.stats.adding(scriptCount: 1) + BraveGlobalShieldStats.shared.scripts += 1 + return + } + + // Because javascript urls allow some characters that `URL` does not, + // we use `NSURL(idnString: String)` to parse them + guard let requestURL = NSURL(idnString: dto.resourceURL) as URL? else { return } + guard let sourceURL = NSURL(idnString: dto.sourceURL) as URL? else { return } + guard let domainURLString = domain.url else { return } + let genericTypes = ContentBlockerManager.shared.validGenericTypes(for: domain) + + let blockedType = await TPStatsBlocklistChecker.shared.blockedTypes( + requestURL: requestURL, + sourceURL: sourceURL, + enabledRuleTypes: genericTypes, + resourceType: dto.resourceType, + isAggressiveMode: domain.blockAdsAndTrackingLevel.isAggressive + ) + + guard let blockedType = blockedType else { return } + + assertIsMainThread("Result should happen on the main thread") + + // Ensure we check that the stats we're tracking is still for the same page + // Some web pages (like youtube) like to rewrite their main frame urls + // so we check the source etld+1 agains the tab url etld+1 + // For subframes which may use different etld+1 than the main frame (example `reddit.com` and `redditmedia.com`) + // We simply check the known subframeURLs on this page. + guard self.tab?.url?.baseDomain == sourceURL.baseDomain || + self.tab?.currentPageData?.allSubframeURLs.contains(sourceURL) == true else { + return + } + + if blockedType == .ad, Preferences.PrivacyReports.captureShieldsData.value, + let domainURL = URL(string: domainURLString), + let blockedResourceHost = requestURL.baseDomain, + tab?.isPrivate != true { + PrivacyReportsManager.pendingBlockedRequests.append((blockedResourceHost, domainURL, Date())) + } + + // First check to make sure we're not counting the same repetitive requests multiple times + guard !self.blockedRequests.contains(requestURL) else { return } + self.blockedRequests.insert(requestURL) + + // Increase global stats (here due to BlocklistName being in Client and BraveGlobalShieldStats being + // in BraveShared) + let stats = BraveGlobalShieldStats.shared + switch blockedType { + case .ad: + stats.adblock += 1 + self.stats = self.stats.adding(adCount: 1) + case .image: + stats.images += 1 + } } } } catch { diff --git a/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/Scripts/Paged/TrackingProtectionStats.js b/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/Scripts/Paged/TrackingProtectionStats.js index f563ef6d9b87..3a494ff261bf 100644 --- a/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/Scripts/Paged/TrackingProtectionStats.js +++ b/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/Scripts/Paged/TrackingProtectionStats.js @@ -6,100 +6,153 @@ window.__firefox__.execute(function($) { const messageHandler = '$'; - + let sendInfo = []; + let sendInfoTimeout = null; + let sendMessage = $(function(urlString, resourceType) { - if (urlString) { - try { - let resourceURL = new URL(urlString, window.location.href) - $.postNativeMessage(messageHandler, { - "securityToken": SECURITY_TOKEN, - "data": { - resourceURL: resourceURL.href, - sourceURL: window.location.href, - resourceType: resourceType - } - }); - } catch (error) { - console.error(error) + // String is empty, null, undefined, ... + if (!urlString) { + return; + } + + let resourceURL = null; + try { + resourceURL = new URL(urlString, document.location.href); + + // First party urls or invalid URLs are not blocked + if (document.location.host === resourceURL.host) { + return; } + } catch (error) { + console.error(error); + return; + } + + sendInfo.push({ + resourceURL: resourceURL.href, + sourceURL: document.location.href, + resourceType: resourceType + }); + + if (sendInfoTimeout) { + return; } + + // Send the URLs in batches every 200ms to avoid perf issues + // from calling js-to-native too frequently. + sendInfoTimeout = setTimeout($(() => { + sendInfoTimeout = null; + if (sendInfo.length == 0) { + return; + } + + $.postNativeMessage(messageHandler, { + "securityToken": SECURITY_TOKEN, + "data": sendInfo + }); + + sendInfo = []; + }), 200); }); let onLoadNativeCallback = $(function() { // Send back the sources of every script and image in the DOM back to the host application. - [].slice.apply(document.scripts).forEach(function(el) { sendMessage(el.src, "script"); }); - [].slice.apply(document.images).forEach(function(el) { - // If the image's natural width is zero, then it has not loaded so we - // can assume that it may have been blocked. - if (el.naturalWidth === 0) { - sendMessage(el.src, "image"); - } - }); + [].slice.apply(document.scripts).forEach((el) => { sendMessage(el.src, "script"); }); + [].slice.apply(document.images).forEach((el) => { sendMessage(el.src, "image"); }); + [].slice.apply(document.getElementsByTagName('subdocument')).forEach((el) => { sendMessage(el.src, "subdocument"); }) }); - let originalOpen = null; - let originalSend = null; + let originalXHROpen = null; + let originalXHRSend = null; + let originalFetch = null; let originalImageSrc = null; let mutationObserver = null; let injectStatsTracking = $(function(enabled) { // This enable/disable section is a change from the original Focus iOS version. if (enabled) { - if (originalOpen) { + if (originalXHROpen) { return; } window.addEventListener("load", onLoadNativeCallback, false); } else { window.removeEventListener("load", onLoadNativeCallback, false); - if (originalOpen) { // if one is set, then all the enable code has run - XMLHttpRequest.prototype.open = originalOpen; - XMLHttpRequest.prototype.send = originalSend; - Image.prototype.src = originalImageSrc; + if (originalXHROpen) { // if one is set, then all the enable code has run + XMLHttpRequest.prototype.open = originalXHROpen; + XMLHttpRequest.prototype.send = originalXHRSend; + window.fetch = originalFetch; + // Image.prototype.src = originalImageSrc; // doesn't work to reset mutationObserver.disconnect(); - originalOpen = originalSend = originalImageSrc = mutationObserver = null; + originalXHROpen = null; + originalXHRSend = null; + originalFetch = null; + originalImageSrc = null; + mutationObserver = null; } return; } // ------------------------------------------------- - // Send ajax requests URLs to the host application + // Send XHR requests URLs to the host application // ------------------------------------------------- const localURLProp = Symbol('url') - var xhrProto = XMLHttpRequest.prototype; - if (!originalOpen) { - originalOpen = xhrProto.open; - originalSend = xhrProto.send; + const localErrorHandlerProp = Symbol('tpErrorHandler') + + if (!originalXHROpen) { + originalXHROpen = XMLHttpRequest.prototype.open; + originalXHRSend = XMLHttpRequest.prototype.send; } - xhrProto.open = $(function(method, url) { + XMLHttpRequest.prototype.open = $(function(method, url, isAsync) { // Blocked async XMLHttpRequest are handled via RequestBlocking.js // We only handle sync requests - if (arguments[2] === undefined || arguments[2]) { - return originalOpen.apply(this, arguments); + if (isAsync === undefined || isAsync) { + return originalXHROpen.apply(this, arguments); } this[localURLProp] = url; - return originalOpen.apply(this, arguments); + return originalXHROpen.apply(this, arguments); }, /*overrideToString=*/false); - xhrProto.send = $(function(body) { - if (this[localURLProp] === undefined) { - return originalSend.apply(this, arguments); + XMLHttpRequest.prototype.send = $(function(body) { + let url = this[localURLProp]; + if (!url) { + return originalXHRSend.apply(this, arguments); } // Only attach the `error` event listener once for this // `XMLHttpRequest` instance. - if (!this._tpErrorHandler) { + if (!this[localErrorHandlerProp]) { // If this `XMLHttpRequest` instance fails to load, we // can assume it has been blocked. - this._tpErrorHandler = $(function() { - sendMessage(this[localURLProp], "xmlhttprequest"); + this[localErrorHandlerProp] = $(function() { + sendMessage(url, "xmlhttprequest"); }); - this.addEventListener("error", this._tpErrorHandler); + + this.addEventListener("error", this[localErrorHandlerProp]); + } + return originalXHRSend.apply(this, arguments); + }, /*overrideToString=*/false); + + + + // ------------------------------------------------- + // Send `fetch()` request URLs to the host application + // ------------------------------------------------- + if (!originalFetch) { + originalFetch = window.fetch; + } + + window.fetch = $(function(input, init) { + if (typeof input === 'string') { + sendMessage(input, 'xmlhttprequest'); + } else if (input instanceof Request) { + sendMessage(input.url, 'xmlhttprequest'); } - return originalSend.apply(this, arguments); + + return originalFetch.apply(window, arguments); }, /*overrideToString=*/false); // ------------------------------------------------- @@ -108,24 +161,25 @@ window.__firefox__.execute(function($) { if (!originalImageSrc) { originalImageSrc = Object.getOwnPropertyDescriptor(Image.prototype, "src"); } + delete Image.prototype.src; + Object.defineProperty(Image.prototype, "src", { get: $(function() { return originalImageSrc.get.call(this); }), + set: $(function(value) { // Only attach the `error` event listener once for this // Image instance. - if (!this._tpErrorHandler) { + if (!this[localErrorHandlerProp]) { // If this `Image` instance fails to load, we can assume // it has been blocked. - this._tpErrorHandler = $(function() { - // We don't need to send it if the src is set to "" - // (which will give us `window.location.href`) - if (this.src === window.location.href) { return } + this[localErrorHandlerProp] = $(function() { sendMessage(this.src, "image"); }); - this.addEventListener("error", this._tpErrorHandler); + + this.addEventListener("error", this[localErrorHandlerProp]); } originalImageSrc.set.call(this, value); @@ -141,10 +195,25 @@ window.__firefox__.execute(function($) { mutationObserver = new MutationObserver($(function(mutations) { mutations.forEach($(function(mutation) { mutation.addedNodes.forEach($(function(node) { - // Only consider `