From 486d02e89bf833ae2e816bb337aaff604546e7c3 Mon Sep 17 00:00:00 2001 From: stavroskasidis Date: Thu, 28 Apr 2022 11:11:42 +0300 Subject: [PATCH] Obfuscated caching fixes --- README.md | 21 +- .../BlazorHostedSamplePwa.Client.csproj | 2 +- .../BlazorHostedSamplePwa.Server.csproj | 2 +- .../ObfuscateDlls.cs | 6 +- ...lazorWasmAntivirusProtection.lib.module.js | 213 ++++++++++-------- 5 files changed, 138 insertions(+), 106 deletions(-) diff --git a/README.md b/README.md index fbb9547..bb2b658 100644 --- a/README.md +++ b/README.md @@ -19,14 +19,15 @@ This package attempts to guard against false positives from antiviruses that fla ## What does this package do ? This package injects some custom MSBuild tasks that do the following during publishing: 1. Obfuscates all client assemblies so that firewalls and antiviruses don't see them as executables. Obfuscation methods supported: - * Using a key to XOR all client assemblies (**default**). + * Using a key to XOR all client assemblies (**default**) . * **OR** - * Changing the MZ header of all client assemblies to BZ, a custom header (less aggressive - more info [here](https://en.wikipedia.org/wiki/DOS_MZ_executable)) -2. Renames the extension of all client assemblies from **.dll** to **.bin** -3. Adds a lib.module.js that contains a `beforeStart` blazor initialization method (more info [here](https://docs.microsoft.com/en-us/aspnet/core/blazor/javascript-interoperability/?view=aspnetcore-6.0#javascript-initializers)), that uses a custom `loadBootResource` function to restore the obfuscation of the assemblies after downloaded, but before loaded by dotnet.wasm + * Changing the MZ header of all client assemblies to BZ, a custom header (less aggressive - more info [here](https://en.wikipedia.org/wiki/DOS_MZ_executable)) . +2. Renames the extension of all client assemblies from **.dll** to **.bin** . +3. Swaps Blazor's default caching mechanism with a custom one that saves the obfuscated assemblies on the cache instead of the unobfuscated ones. This is because some antiviruses are flaging the cached Blazor files that are being saved on the disk by the browser. +4. Adds a lib.module.js that contains a `beforeStart` Blazor initialization method (more info [here](https://docs.microsoft.com/en-us/aspnet/core/blazor/javascript-interoperability/?view=aspnetcore-6.0#javascript-initializers)), that uses a custom `loadBootResource` function to restore the obfuscation of the assemblies after downloaded, but before loaded by dotnet.wasm. ## How to use -1. Add the nuget package in your **Client** (wasm) **AND** your **Server** (if using blazor wasm hosted) projects +1. Add the nuget package in your **Client** (wasm) **AND** your **Server** (if using Blazor wasm hosted) projects ``` dotnet add package BlazorWasmAntivirusProtection ``` @@ -45,13 +46,13 @@ dotnet publish Server\BlazorHostedSampleApp.Server.csproj -c Release ## Configuration The following options allow you to customize the tasks executed by this package. ### **Custom dll rename extension** -If you want to use a different extension for renaming dlls, for example ".blz", add the following property in the **published** project's .csproj file (**Server** project if using blazor hosted). +If you want to use a different extension for renaming dlls, for example ".blz", add the following property in the **published** project's .csproj file (**Server** project if using Blazor hosted). ```xml blz ``` ### **Disable dll rename** -You can disable dll renaming by adding the following property in the **published** project's .csproj file (**Server** project if using blazor hosted). +You can disable dll renaming by adding the following property in the **published** project's .csproj file (**Server** project if using Blazor hosted). ```xml true ``` @@ -73,6 +74,12 @@ You can change the key that is used for the XOR obfuscation adding the following mykey ``` +### **Disable caching** +You can disable boot resources caching by using the following property in your Client project's .csproj file, just as you would in any Blazor project. More info [here](https://docs.microsoft.com/en-us/aspnet/core/blazor/host-and-deploy/webassembly?view=aspnetcore-6.0#disable-integrity-checking-for-non-pwa-apps). +```xml +false +``` + ## Samples / Demo You can find a sample app using this package [here](https://blazor-antivirus-block.azurewebsites.net/). diff --git a/sampleapps/BlazorHostedSamplePwa/Client/BlazorHostedSamplePwa.Client.csproj b/sampleapps/BlazorHostedSamplePwa/Client/BlazorHostedSamplePwa.Client.csproj index 0bcae3d..5eaa5c4 100644 --- a/sampleapps/BlazorHostedSamplePwa/Client/BlazorHostedSamplePwa.Client.csproj +++ b/sampleapps/BlazorHostedSamplePwa/Client/BlazorHostedSamplePwa.Client.csproj @@ -14,7 +14,7 @@ - + diff --git a/sampleapps/BlazorHostedSamplePwa/Server/BlazorHostedSamplePwa.Server.csproj b/sampleapps/BlazorHostedSamplePwa/Server/BlazorHostedSamplePwa.Server.csproj index 4c2842f..0983984 100644 --- a/sampleapps/BlazorHostedSamplePwa/Server/BlazorHostedSamplePwa.Server.csproj +++ b/sampleapps/BlazorHostedSamplePwa/Server/BlazorHostedSamplePwa.Server.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/BlazorWasmAntivirusProtection.Tasks/ObfuscateDlls.cs b/src/BlazorWasmAntivirusProtection.Tasks/ObfuscateDlls.cs index e0bae49..e83926a 100644 --- a/src/BlazorWasmAntivirusProtection.Tasks/ObfuscateDlls.cs +++ b/src/BlazorWasmAntivirusProtection.Tasks/ObfuscateDlls.cs @@ -17,11 +17,11 @@ public class ObfuscateDlls : Task [Required] public string SettingsPath { get; set; } - public string OriginalBlazorCacheBootResources { get; set; } + public bool OriginalBlazorCacheBootResources { get; set; } public string ObfuscationMode { get; set; } = Tasks.ObfuscationMode.Xor.ToString(); - public string XorKey { get; set; } = "antiviruses suck!"; + public string XorKey { get; set; } = "blazor is not a virus!!"; [Output] public ITaskItem[] Extension { get; set; } @@ -74,7 +74,7 @@ public override bool Execute() { obfuscationMode = obfuscationMode, xorKey = XorKey, - cacheBootResources = OriginalBlazorCacheBootResources + cacheBootResourcesObfuscated = OriginalBlazorCacheBootResources }); File.WriteAllText(SettingsPath, settings); diff --git a/src/BlazorWasmAntivirusProtection/wwwroot/BlazorWasmAntivirusProtection.lib.module.js b/src/BlazorWasmAntivirusProtection/wwwroot/BlazorWasmAntivirusProtection.lib.module.js index 0ea0c52..9bde9fe 100644 --- a/src/BlazorWasmAntivirusProtection/wwwroot/BlazorWasmAntivirusProtection.lib.module.js +++ b/src/BlazorWasmAntivirusProtection/wwwroot/BlazorWasmAntivirusProtection.lib.module.js @@ -1,20 +1,13 @@ - var usedCacheKeys = {}; var cacheIfUsed; -export async function afterStarted(blazor) { - purgeUnusedCacheEntriesAsync(); -} - - export async function beforeStart(wasmOptions, extensions) { if (!extensions || !extensions.avpsettings) { return; } try { - const integrity = extensions.avpsettings['avp-settings.json']; - const settingsResponse = await fetch('avp-settings.json', { integrity: integrity, cache: 'no-cache' }); + const settingsResponse = await fetch('avp-settings.json', { cache: 'no-cache' }); var settings = await settingsResponse.json(); cacheIfUsed = await getCacheToUseIfEnabled(settings); //This is to support custom Blazor.start with a custom loadBootResource @@ -25,27 +18,27 @@ export async function beforeStart(wasmOptions, extensions) { if (existingLoadBootResouce) { existingLoaderResponse = existingLoadBootResouce(type, name, defaultUri, integrity); } - //if (type != "assembly") { - // if (existingLoaderResponse) { - // return existingLoaderResponse; - // } - // else { - // return defaultUri; - // } - //} + if (type == "dotnetjs") { + if (existingLoaderResponse) { + return existingLoaderResponse; + } + else { + return defaultUri; + } + } var fetchPromise = null; if (existingLoaderResponse) { if (typeof existingLoaderResponse == "string") { - fetchPromise = fetchOrGetFromCache(existingLoaderResponse, integrity); + fetchPromise = fetchOrGetFromCache(existingLoaderResponse, integrity, settings); } else { fetchPromise = existingLoaderResponse; } } else { - fetchPromise = fetchOrGetFromCache(defaultUri, integrity); + fetchPromise = fetchOrGetFromCache(defaultUri, integrity, settings); } var resp = fetchPromise.then(response => { @@ -77,100 +70,132 @@ export async function beforeStart(wasmOptions, extensions) { } catch (error) { console.log(error); } +} - async function fetchOrGetFromCache(url, contentHash) { - const response = cacheIfUsed - ? loadResourceWithCaching(cacheIfUsed, url, contentHash) - : loadResourceWithoutCaching(url,contentHash); +export async function afterStarted(blazor) { + purgeUnusedCacheEntriesAsync(); +} - return response; - } +// DISCLAIMER: +// ====================== +// Most of the code below is copied from https://github.com/dotnet/aspnetcore/blob/main/src/Components/Web.JS/src/Platform/WebAssemblyResourceLoader.ts +// Blazor's default caching is disabled and a custom caching of obfuscated dlls is being used. +// This is because by default blazor caches the unobfuscated dlls and some antiviruses flag the cached files that are being stored on the disk by the browser. +// ====================== - // DISCLAIMER: - // ====================== - // Most of the code below is copied from https://github.com/dotnet/aspnetcore/blob/main/src/Components/Web.JS/src/Platform/WebAssemblyResourceLoader.ts - // Blazor's default caching is disabled and a custom caching of obfuscated dlls is being used. - // This is because by default blazor caches the unobfuscated dlls and some antiviruses flag the cached files that are being stored on the disk by the browser. - // ====================== +const networkFetchCacheMode = 'no-cache'; - const networkFetchCacheMode = 'no-cache'; +function toAbsoluteUri(relativeUri) { + var testAnchor = document.createElement('a'); + testAnchor.href = relativeUri; + return testAnchor.href; +} - async function loadResourceWithCaching(cache, url, contentHash) { - // Since we are going to cache the response, we require there to be a content hash for integrity - // checking. We don't want to cache bad responses. There should always be a hash, because the build - // process generates this data. - if (!contentHash || contentHash.length === 0) { - throw new Error('Content hash is required'); - } - const cacheKey = toAbsoluteUri(`${url}.${contentHash}`); - usedCacheKeys[cacheKey] = true; +async function fetchOrGetFromCache(url, contentHash, settings) { + const response = cacheIfUsed + ? loadResourceWithCaching(cacheIfUsed, url, contentHash, settings) + : loadResourceWithoutCaching(url, contentHash, settings); - let cachedResponse; - try { - cachedResponse = await cache.match(cacheKey); - } catch { - // Be tolerant to errors reading from the cache. This is a guard for https://bugs.chromium.org/p/chromium/issues/detail?id=968444 where - // chromium browsers may sometimes throw when working with the cache. - } + return response; +} - if (cachedResponse) { - // It's in the cache. - - //const responseBytes = parseInt(cachedResponse.headers.get('content-length') || '0'); - //cacheLoads[name] = { responseBytes }; - return cachedResponse; - } else { - // It's not in the cache. Fetch from network. - const networkResponse = await loadResourceWithoutCaching(url, contentHash); - cache.put(cacheKey, networkResponse); - return networkResponse; - } +async function loadResourceWithCaching(cache, url, contentHash, settings) { + // Since we are going to cache the response, we require there to be a content hash for integrity + // checking. We don't want to cache bad responses. There should always be a hash, because the build + // process generates this data. + if (!contentHash || contentHash.length === 0) { + throw new Error('Content hash is required'); } - async function loadResourceWithoutCaching(url, contentHash) { - return fetch(url, { - cache: networkFetchCacheMode, - integrity: settings.cacheBootResources ? contentHash : undefined, - }); + const cacheKey = toAbsoluteUri(`${url}.${contentHash}`); + usedCacheKeys[cacheKey] = true; + + let cachedResponse; + try { + cachedResponse = await cache.match(cacheKey); + } catch { + // Be tolerant to errors reading from the cache. This is a guard for https://bugs.chromium.org/p/chromium/issues/detail?id=968444 where + // chromium browsers may sometimes throw when working with the cache. } - async function getCacheToUseIfEnabled(bootConfig) { - // caches will be undefined if we're running on an insecure origin (secure means https or localhost) - if (!bootConfig.cacheBootResources || typeof caches === 'undefined') { - return null; - } + if (cachedResponse) { + // It's in the cache. + + //const responseBytes = parseInt(cachedResponse.headers.get('content-length') || '0'); + //cacheLoads[name] = { responseBytes }; + return cachedResponse; + } else { + // It's not in the cache. Fetch from network. + const networkResponse = await loadResourceWithoutCaching(url, contentHash, settings); + addToCacheAsync(cache, cacheKey, networkResponse); + return networkResponse; + } +} - // cache integrity is compromised if the first request has been served over http (except localhost) - // in this case, we want to disable caching and integrity validation - if (window.isSecureContext === false) { - return null; - } +async function addToCacheAsync(cache, cacheKey, response) { + // We have to clone in order to put this in the cache *and* not prevent other code from + // reading the original response stream. + const responseData = await response.clone().arrayBuffer(); - // Define a separate cache for each base href, so we're isolated from any other - // Blazor application running on the same origin. We need this so that we're free - // to purge from the cache anything we're not using and don't let it keep growing, - // since we don't want to be worst offenders for space usage. - const relativeBaseHref = document.baseURI.substring(document.location.origin.length); - const cacheName = `blazor-resources-${relativeBaseHref}`; - - try { - // There's a Chromium bug we need to be aware of here: the CacheStorage APIs say that when - // caches.open(name) returns a promise that succeeds, the value is meant to be a Cache instance. - // However, if the browser was launched with a --user-data-dir param that's "too long" in some sense, - // then even through the promise resolves as success, the value given is `undefined`. - // See https://stackoverflow.com/a/46626574 and https://bugs.chromium.org/p/chromium/issues/detail?id=1054541 - // If we see this happening, return "null" to mean "proceed without caching". - return (await caches.open(cacheName)) || null; - } catch { - // There's no known scenario where we should get an exception here, but considering the - // Chromium bug above, let's tolerate it and treat as "proceed without caching". - return null; - } + // Add to cache as a custom response object so we can track extra data such as responseBytes + // We can't rely on the server sending content-length (ASP.NET Core doesn't by default) + const responseToCache = new Response(responseData, { + headers: { + 'content-type': response.headers.get('content-type') || '', + 'content-length': (response.headers.get('content-length') || '').toString(), + }, + }); + + try { + await cache.put(cacheKey, responseToCache); + } catch { + // Be tolerant to errors writing to the cache. This is a guard for https://bugs.chromium.org/p/chromium/issues/detail?id=968444 where + // chromium browsers may sometimes throw when performing cache operations. + } +} + +async function loadResourceWithoutCaching(url, contentHash, settings) { + return fetch(url, { + cache: networkFetchCacheMode, + integrity: settings.cacheBootResources ? contentHash : undefined, + }); +} + +async function getCacheToUseIfEnabled(settings) { + // caches will be undefined if we're running on an insecure origin (secure means https or localhost) + if (!settings.cacheBootResourcesObfuscated || typeof caches === 'undefined') { + return null; } + // cache integrity is compromised if the first request has been served over http (except localhost) + // in this case, we want to disable caching and integrity validation + if (window.isSecureContext === false) { + return null; + } + + // Define a separate cache for each base href, so we're isolated from any other + // Blazor application running on the same origin. We need this so that we're free + // to purge from the cache anything we're not using and don't let it keep growing, + // since we don't want to be worst offenders for space usage. + const relativeBaseHref = document.baseURI.substring(document.location.origin.length); + const cacheName = `blazor-resources-${relativeBaseHref}`; + + try { + // There's a Chromium bug we need to be aware of here: the CacheStorage APIs say that when + // caches.open(name) returns a promise that succeeds, the value is meant to be a Cache instance. + // However, if the browser was launched with a --user-data-dir param that's "too long" in some sense, + // then even through the promise resolves as success, the value given is `undefined`. + // See https://stackoverflow.com/a/46626574 and https://bugs.chromium.org/p/chromium/issues/detail?id=1054541 + // If we see this happening, return "null" to mean "proceed without caching". + return (await caches.open(cacheName)) || null; + } catch { + // There's no known scenario where we should get an exception here, but considering the + // Chromium bug above, let's tolerate it and treat as "proceed without caching". + return null; + } } async function purgeUnusedCacheEntriesAsync() {