Skip to content

Commit

Permalink
Add security verification for untrusted archives #587 (#588)
Browse files Browse the repository at this point in the history
  • Loading branch information
Jaifroid authored Apr 19, 2024
1 parent 0c833ba commit 7c40edd
Show file tree
Hide file tree
Showing 5 changed files with 143 additions and 50 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

## In-progress release 3.2.2

* FEATURE: Add security dialogue on opening a ZIM for the first time in ServiceWorker mode
* UPDATE: Rename JQuery mode to Restricted mode
* FIX: Cached last page sometimes overwrites new ZIM landing page when switching from Restricted mode
* FIX: Display of open/close marker with h5 and h6 headings in Wikimedia ZIMs
* FIX: Inability to print HTML books in Gutenberg ZIMs
* FIX: Bug in JQuery mode which made all images load as manual display areas in some non-Wikimedia ZIMs
Expand Down
5 changes: 5 additions & 0 deletions www/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1252,6 +1252,11 @@ <h4 class="panel-group-heading">Expert settings</h4>
<span class="checkmark"></span>
<b>Disable drag-and-drop</b> (in case it is causing anomalies)
</label>
<label class="checkbox" data-i18n-tip="configure-expert-enable-source-verification-tip" title="Warning: Some ZIM archives from untrusted sources could run malicious code in your browser. This can be prevented by using Restricted mode, which cannot run active content from the ZIM. Highly dynamic ZIMs will probably fail in Restricted mode, but ZIMs with largely static content should work. If you trust the source of all of your ZIMs, then disabling this option will use ServiceWorker mode by default, if available.">
<input type="checkbox" name="disableFileVerification" id="enableSourceVerificationCheck" >
<span data-i18n="configure-expert-enable-source-verification-check-box" class="checkmark"></span>
<b>Enable source verification of new files</b> (<i>recommended</i>: you will only be prompted the first time you open a ZIM)
</label>
<div id="expressPortInputDiv" style="display: none;" title="If you allowed network access on startup, you can access this app from any local browser by going to localhost:port in the browser address bar (e.g. http://localhost:3000).">
<b>Customize the localhost port when accessing this app from a browser</b>:<br />
<label>
Expand Down
180 changes: 132 additions & 48 deletions www/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,7 @@ function printIntercept () {
return uiUtil.systemAlert('Sorry, we could not find a document to print! Please load one first.', 'Warning');
}
if (params.contentInjectionMode === 'serviceworker') {
// Re-establishe lastPageVisit because it is not always set, for example with dynamic loads, in SW mode
// Re-establish lastPageVisit because it is not always set, for example with dynamic loads, in SW mode
params.lastPageVisit = articleDocument.location.href.replace(/^.+\/([^/]+\.[zZ][iI][mM]\w?\w?)\/([CA]\/.*$)/, function (m0, zimName, zimURL) {
return decodeURI(zimURL) + '@kiwixKey@' + decodeURI(zimName);
});
Expand Down Expand Up @@ -1689,15 +1689,27 @@ document.querySelectorAll('input[name="contentInjectionMode"][type="radio"]').fo
}
// Do the necessary to enable or disable the Service Worker
setContentInjectionMode(this.value);
// If we're in a PWA UWP app, warn the user that this does not disable the PWA
if (this.value === 'jquery' && /^http/i.test(window.location.protocol) && /UWP\|PWA/.test(params.appType) &&
params.allowInternetAccess === 'true') {
uiUtil.systemAlert(
'<p>Please note that switching content injection mode does not revert to local code.</p>' +
'<p>If you wish to exit the PWA, you will need to turn off "Allow Internet access?" above.</p>'
);

/** DEV: PLEASE NOTE THAT "jQuery mode" HAS NOW CHANGED to "Restricted mode", but we still use "jquery" in code */

// Actions that must be completed after switch to Restricted mode
if (this.value === 'jquery') {
// Hide the source verification option
document.getElementById('enableSourceVerificationCheck').style.display = 'none';
// If we're in a PWA UWP app, warn the user that this does not disable the PWA
if (/^http/i.test(window.location.protocol) && /UWP\|PWA/.test(params.appType) &&
params.allowInternetAccess === 'true') {
uiUtil.systemAlert(
'<p>Please note that switching content injection mode does not revert to local code.</p>' +
'<p>If you wish to exit the PWA, you will need to turn off "Allow Internet access?" above.</p>'
);
}
}
if (this.value === 'serviceworker') {
document.getElementById('enableSourceVerificationCheck').style.display = '';
if (appstate.selectedArchive.isReady() && !(settingsStore.getItem('trustedZimFiles').includes(appstate.selectedArchive.file.name)) && params.sourceVerification) {
verifyLoadedArchive(appstate.selectedArchive);
}
if (params.manipulateImages || params.allowHTMLExtraction) {
if (!appstate.wikimediaZimLoaded) {
var message = 'Please note that we are disabling "Image manipulation" and/or "Download or open current article" features, as these options ' +
Expand Down Expand Up @@ -1855,6 +1867,12 @@ document.getElementById('disableDragAndDropCheck').addEventListener('change', fu
}
});
});
// Source verification is only makes sense in SW mode as doing the same in jQuery mode is redundant.
document.getElementById('enableSourceVerificationCheck').style.display = params.contentInjectionMode === ('serviceworker' || 'serviceworkerlocal') ? 'block' : 'none';
document.getElementById('enableSourceVerificationCheck').addEventListener('change', function () {
params.sourceVerification = this.checked;
settingsStore.setItem('sourceVerification', this.checked, Infinity);
});
document.getElementById('hideActiveContentWarningCheck').addEventListener('change', function () {
params.hideActiveContentWarning = this.checked;
settingsStore.setItem('hideActiveContentWarning', params.hideActiveContentWarning, Infinity);
Expand Down Expand Up @@ -2418,12 +2436,12 @@ function switchCSSTheme () {
}, 100);
// If the interval has not succeeded after 3 seconds, give up
if (zimitIframe && document.getElementById('configuration').style.display === 'none') {
setTimeout(function () {
articleContainer.style.display = '';
zimitIframe.style.display = '';
setTimeout(function (zimitf, articleC) {
articleC.style.display = '';
zimitf.style.display = '';
clearInterval(interval);
window.dispatchEvent(new Event('resize')); // Force repaint
}, 3000);
}, 3000, zimitIframe, articleContainer);
}
} else if (document.getElementById('configuration').style.display === 'none') {
// We're dealing with a light style, so we just display it
Expand Down Expand Up @@ -4067,6 +4085,38 @@ function setLocalArchiveFromFileList (files, fromArchiveList) {
});
}

/**
* Verifies the given archive and switches contentInjectionMode accourdingly
* Code to undertake the verification adapted from kiwix/kiwix-js #1192 kindly authored by @Greeshmanth1909
*
* @param {Object} archive The archive that needs verification
*
*/
function verifyLoadedArchive (archive) {
return uiUtil.systemAlert('<p>Is this ZIM archive from a trusted source?</p><p style="border: 1px solid;padding:5px;">' +
'Name:&nbsp;<b>' + archive.file.name + '</b><br />' +
'Creator:&nbsp;<b>' + archive.creator + '</b><br />' +
'Publisher:&nbsp;<b>' + archive.publisher + '</b><br />' +
'Scraper:&nbsp;<b>' + archive.scraper + '</b><br />' +
'</p><p><b><i>Warning: above data can easily be spoofed!</i></b></p>' +
'</p><p>If you do not trust the source, you can still read the ZIM file in Restricted mode. Closing this window also opens the file in Restricted mode.</p>' +
'<p><i>If you mark the file as trusted, this alert will not show again.</i> (Security checks can be disabled in Expert Settings.)</p>',
'Security alert!', true, 'Open in Restricted mode', 'Trust source').then(response => {
if (response) {
params.contentInjectionMode = 'serviceworker';
var trustedZimFiles = settingsStore.getItem('trustedZimFiles');
var updatedTrustedZimFiles = trustedZimFiles + archive.file.name + '|';
settingsStore.setItem('trustedZimFiles', updatedTrustedZimFiles, Infinity);
// Change radio buttons accordingly
document.getElementById('serviceworkerModeRadio').checked = true;
} else {
// Switch to Restricted mode
params.contentInjectionMode = 'jquery';
document.getElementById('jQueryModeRadio').checked = true;
}
});
}

/**
* Functions to be run immediately after the archive is loaded
*
Expand Down Expand Up @@ -4185,27 +4235,57 @@ function archiveReadyCallback (archive) {
}
// This ensures the correct icon is set for the newly loaded archive
cssUIThemeGetOrSet(params.cssUITheme);
if (params.rescan) {
document.getElementById('btnConfigure').click();
setTimeout(function () {
var displayArchive = function () {
if (params.rescan) {
document.getElementById('btnConfigure').click();
params.rescan = false;
}, 100);
} else {
if (typeof Windows === 'undefined' && typeof window.showOpenFilePicker !== 'function' && !params.useOPFS && !window.dialog) {
document.getElementById('instructions').style.display = 'none';
setTimeout(function () {
document.getElementById('btnConfigure').click();
params.rescan = false;
}, 100);
} else {
document.getElementById('openLocalFiles').style.display = 'none';
document.getElementById('rescanStorage').style.display = 'block';
if (typeof Windows === 'undefined' && typeof window.showOpenFilePicker !== 'function' && !params.useOPFS && !window.dialog) {
document.getElementById('instructions').style.display = 'none';
} else {
document.getElementById('openLocalFiles').style.display = 'none';
document.getElementById('rescanStorage').style.display = 'block';
}
document.getElementById('usage').style.display = 'none';
if (params.rememberLastPage && ~params.lastPageVisit.indexOf(params.storedFile.replace(/\.zim(\w\w)?$/, ''))) {
var lastPage = params.lastPageVisit.replace(/@kiwixKey@.+/, '');
goToArticle(lastPage);
} else {
document.getElementById('btnHome').click();
}
}
document.getElementById('usage').style.display = 'none';
if (params.rememberLastPage && ~params.lastPageVisit.indexOf(params.storedFile.replace(/\.zim(\w\w)?$/, ''))) {
var lastPage = params.lastPageVisit.replace(/@kiwixKey@.+/, '');
goToArticle(lastPage);
} else {
document.getElementById('btnHome').click();
}
// Set contentInjectionMode to serviceWorker when opening a new archive in case the user switched to Restricted mode/jQuery Mode when opening the previous archive
if (params.contentInjectionMode === 'jquery') {
params.contentInjectionMode = settingsStore.getItem('contentInjectionMode');
// Change the radio buttons accordingly
switch (settingsStore.getItem('contentInjectionMode')) {
case 'serviceworker':
document.getElementById('serviceworkerModeRadio').checked = true;
// In case we atuo-switched off assetsCache due to switch to Restricted mode, we need to reset
params.assetsCache = settingsStore.getItem('asetsCache') !== 'false';
break;
case 'serviceworkerlocal':
document.getElementById('serviceworkerLocalModeRadio').checked = true;
break;
}
}
if (settingsStore.getItem('trustedZimFiles') === null) {
settingsStore.setItem('trustedZimFiles', '', Infinity);
}
if (params.sourceVerification && (params.contentInjectionMode === 'serviceworker' || params.contentInjectionMode === 'serviceworkerlocal')) {
// Check if source of the zim file can be trusted.
if (!(settingsStore.getItem('trustedZimFiles').includes(archive.file.name))) {
verifyLoadedArchive(archive).then(function () {
displayArchive();
});
return;
}
}
displayArchive();
}

function loadPackagedArchive () {
Expand Down Expand Up @@ -5119,6 +5199,21 @@ var articleLoadedSW = function (dirEntry, container) {
uiUtil.showSlidingUIElements();
var doc = articleWindow ? articleWindow.document : null;
articleDocument = doc;
var mimeType = dirEntry.getMimetype();
// If we've successfully loaded an HTML document...
if (doc && /\bx?html/i.test(mimeType)) {
if (params.rememberLastPage) {
params.lastPageVisit = dirEntry.namespace + '/' + dirEntry.url + '@kiwixKey@' + appstate.selectedArchive.file.name;
} else {
params.lastPageVisit = '';
}
// Turn off failsafe for SW mode
settingsStore.setItem('lastPageLoad', 'OK', Infinity);
settingsStore.setItem('lastPageVisit', params.lastPageVisit, Infinity);
// Set or clear the ZIM store of last page
var lastPage = params.rememberLastPage ? dirEntry.namespace + '/' + dirEntry.url : '';
settingsStore.setItem(appstate.selectedArchive.file.name, lastPage, Infinity);
}
var docBody = doc ? doc.body : null;
if (docBody) {
// Trap clicks in the iframe to enable us to work around the sandbox when opening external links and PDFs
Expand All @@ -5133,7 +5228,6 @@ var articleLoadedSW = function (dirEntry, container) {
listenForSearchKeys();
}
// Note that switchCSSTheme() requires access to params.lastPageVisit
params.lastPageVisit = dirEntry.namespace + '/' + dirEntry.url + '@kiwixKey@' + appstate.selectedArchive.file.name;
if (!appstate.isReplayWorkerAvailable) switchCSSTheme(); // Gets called in articleLoader for replay_iframe
if (appstate.selectedArchive.zimType === 'open') {
// Set relative font size + Stackexchange-family multiplier
Expand Down Expand Up @@ -5183,12 +5277,6 @@ var articleLoadedSW = function (dirEntry, container) {
resizeIFrame();
}, 200);
}
// Turn off failsafe for SW mode
settingsStore.setItem('lastPageLoad', 'OK', Infinity);
if (!appstate.isReplayWorkerAvailable) {
// Because this is loading within docBody, it should only get set for HTML documents
if (params.rememberLastPage) settingsStore.setItem('lastPageVisit', params.lastPageVisit, Infinity);
}
uiUtil.clearSpinner();
// If we reloaded the page to print the desktop style, we need to return to the printIntercept dialogue
if (params.printIntercept) printIntercept();
Expand All @@ -5205,7 +5293,10 @@ var articleLoadedSW = function (dirEntry, container) {
if (dirEntry) uiUtil.makeReturnLink(dirEntry.getTitleOrUrl());
params.isLandingPage = false;
} else {
loaded = false;
// If we havent' loaded a text-type document, we probably haven't finished loading
if (!/^text\//i.test(mimeType)) {
loaded = false;
}
}

// Show spinner when the article unloads
Expand Down Expand Up @@ -6253,7 +6344,8 @@ function displayArticleContentInContainer (dirEntry, htmlArticle) {
if (downloadAlert) downloadAlert.style.display = 'none';

// Code below will run after we have written the new article to the articleContainer
var articleLoaded = params.contentInjectionMode === 'serviceworker' ? function () {} : function () {
var articleLoaded = function () {
if (params.contentInjectionMode === 'serviceworker') return;
// Set a global error handler for articleWindow
articleWindow.onerror = function (msg, url, line, col, error) {
console.error('Error caught in ZIM contents [' + url + ':' + line + ']:\n' + msg, error);
Expand Down Expand Up @@ -6379,17 +6471,9 @@ function displayArticleContentInContainer (dirEntry, htmlArticle) {
// Make sure the article area is displayed
setTab();
checkToolbar();
var showArticle = function () {
articleDocument.bgcolor = '';
docBody.style.display = 'block';
};
if ('MSBlobBuilder' in window) {
// For legacy MS browsers, including UWP, delay causes blank screen on slow systems
showArticle();
} else {
// For Chromium browsers a small delay greatly improves composition
setTimeout(showArticle, 80);
}
// Show the article
articleDocument.bgcolor = '';
docBody.style.display = 'block';
// Jump to any anchor parameter
if (anchorParameter) {
var target = articleWindow.document.getElementById(anchorParameter);
Expand Down
2 changes: 2 additions & 0 deletions www/js/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ params['rightClickType'] = getSetting('rightClickType'); // 'single|double|false
params['navButtonsPos'] = getSetting('navButtonsPos') || 'bottom'; // 'top|bottom' A setting that determines where the back-forward nav buttons appear
params['useOPFS'] = getSetting('useOPFS') === true; // A setting that determines whether to use OPFS (experimental)
params['useLegacyZimitSupport'] = getSetting('useLegacyZimitSupport') === true; // A setting that determines whether to force the use of legacy Zimit support
params['sourceVerification'] = params.contentInjectionMode === 'serviceworker' ? (getSetting('sourceVerification') === null ? true : getSetting('sourceVerification')) : false; // Sets a boolean indicating weather a user trusts the source of zim files

// Do not touch these values unless you know what they do! Some are global variables, some are set programmatically
params['cacheAPI'] = 'kiwixjs-assetsCache'; // Set the global Cache API database or cache name here, and synchronize with Service Worker
Expand Down Expand Up @@ -251,6 +252,7 @@ document.getElementById('rememberLastPageCheck').checked = params.rememberLastPa
document.getElementById('displayFileSelectorsCheck').checked = params.showFileSelectors;
document.getElementById('hideActiveContentWarningCheck').checked = params.hideActiveContentWarning;
document.getElementById('useLibzimReaderCheck').checked = params.useLibzim;
document.getElementById('enableSourceVerificationCheck').checked = getSetting('sourceVerification') === null ? true : getSetting('sourceVerification');
document.getElementById('useLegacyZimitSupportCheck').checked = params.useLegacyZimitSupport;
document.getElementById('alphaCharTxt').value = params.alphaChar;
document.getElementById('omegaCharTxt').value = params.omegaChar;
Expand Down
Loading

0 comments on commit 7c40edd

Please sign in to comment.