diff --git a/js/navbar/tabColor.js b/js/navbar/tabColor.js index 636a4550f..ac320357a 100644 --- a/js/navbar/tabColor.js +++ b/js/navbar/tabColor.js @@ -2,6 +2,7 @@ var webviews = require('webviews.js') var settings = require('util/settings/settings.js') const colorExtractorImage = document.createElement('img') +colorExtractorImage.crossOrigin = 'anonymous' const colorExtractorCanvas = document.createElement('canvas') const colorExtractorContext = colorExtractorCanvas.getContext('2d') diff --git a/js/newTabPage.js b/js/newTabPage.js index 15d0d96ab..ab7b0f886 100644 --- a/js/newTabPage.js +++ b/js/newTabPage.js @@ -7,26 +7,30 @@ const newTabPage = { picker: document.getElementById('ntp-image-picker'), deleteBackground: document.getElementById('ntp-image-remove'), imagePath: path.join(window.globalArgs['user-data-path'], 'newTabBackground'), + blobInstance: null, reloadBackground: function () { - newTabPage.background.src = newTabPage.imagePath + '?t=' + Date.now() - function onLoad () { - newTabPage.background.hidden = false - newTabPage.hasBackground = true - document.body.classList.add('ntp-has-background') - newTabPage.background.removeEventListener('load', onLoad) - - newTabPage.deleteBackground.hidden = false - } - function onError () { - newTabPage.background.hidden = true - newTabPage.hasBackground = false - document.body.classList.remove('ntp-has-background') - newTabPage.background.removeEventListener('error', onError) - - newTabPage.deleteBackground.hidden = true - } - newTabPage.background.addEventListener('load', onLoad) - newTabPage.background.addEventListener('error', onError) + fs.readFile(newTabPage.imagePath, function (err, data) { + if (newTabPage.blobInstance) { + URL.revokeObjectURL(newTabPage.blobInstance) + newTabPage.blobInstance = null + } + if (err) { + newTabPage.background.hidden = true + newTabPage.hasBackground = false + document.body.classList.remove('ntp-has-background') + newTabPage.deleteBackground.hidden = true + } else { + const blob = new Blob([data], { type: 'application/octet-binary' }) + const url = URL.createObjectURL(blob) + newTabPage.blobInstance = url + newTabPage.background.src = url + + newTabPage.background.hidden = false + newTabPage.hasBackground = true + document.body.classList.add('ntp-has-background') + newTabPage.deleteBackground.hidden = false + } + }) }, initialize: function () { newTabPage.reloadBackground() @@ -57,6 +61,4 @@ const newTabPage = { } } -window.ntp = newTabPage - module.exports = newTabPage diff --git a/js/pdfViewer.js b/js/pdfViewer.js index 4d52e1b94..6d150771d 100644 --- a/js/pdfViewer.js +++ b/js/pdfViewer.js @@ -5,7 +5,7 @@ const urlParser = require('util/urlParser.js') const PDFViewer = { url: { - base: urlParser.getFileURL(__dirname + '/pages/pdfViewer/index.html'), + base: 'min://app/pages/pdfViewer/index.html', queryString: '?url=%l' }, isPDFViewer: function (tabId) { diff --git a/js/places/tagIndex.js b/js/places/tagIndex.js index e133eb40d..61666d49a 100644 --- a/js/places/tagIndex.js +++ b/js/places/tagIndex.js @@ -8,8 +8,8 @@ var tagIndex = { getPageTokens: function (page) { var urlChunk = '' try { - const url = new URL(page.url) - if (page.url.startsWith('file://') && url.searchParams.get('url')) { + let url = new URL(page.url) + if ((page.url.startsWith('file://') || page.url.startsWith('min://')) && url.searchParams.get('url')) { url = new URL(url.searchParams.get('url')) } urlChunk = url.hostname.split('.').slice(0, -1).join(' ') + ' ' + url.pathname.split('/').filter(p => p.length > 1).slice(0, 2).join(' ') diff --git a/js/preload/default.js b/js/preload/default.js index 8f88f0e0e..c7a302782 100644 --- a/js/preload/default.js +++ b/js/preload/default.js @@ -46,7 +46,7 @@ ipc.on('enterPictureInPicture', function (event, data) { }) window.addEventListener('message', function (e) { - if (!e.origin.startsWith('file://')) { + if (!e.origin.startsWith('min://')) { return } diff --git a/js/readerView.js b/js/readerView.js index b8b3f68d9..e06938b10 100644 --- a/js/readerView.js +++ b/js/readerView.js @@ -5,7 +5,7 @@ var urlParser = require('util/urlParser.js') var readerDecision = require('readerDecision.js') var readerView = { - readerURL: urlParser.getFileURL(__dirname + '/reader/index.html'), + readerURL: 'min://app/reader/index.html', getReaderURL: function (url) { return readerView.readerURL + '?url=' + url }, diff --git a/js/searchbar/updateNotifications.js b/js/searchbar/updateNotifications.js index d10b201af..e6b258fcf 100644 --- a/js/searchbar/updateNotifications.js +++ b/js/searchbar/updateNotifications.js @@ -1,4 +1,4 @@ -const UPDATE_URL = 'https://minbrowser.github.io/min/updates/latestVersion.json' +const UPDATE_URL = 'https://minbrowser.org/min/updates/latestVersion.json' var settings = require('util/settings/settings.js') diff --git a/js/sessionRestore.js b/js/sessionRestore.js index da6ea18b4..d9fe8ea53 100644 --- a/js/sessionRestore.js +++ b/js/sessionRestore.js @@ -179,7 +179,7 @@ const sessionRestore = { // create a new tab with an explanation of what happened var newTask = tasks.add() var newSessionErrorTab = tasks.get(newTask).tabs.add({ - url: 'file://' + __dirname + '/pages/sessionRestoreError/index.html?backupLoc=' + encodeURIComponent(backupSavePath) + url: 'min://app/pages/sessionRestoreError/index.html?backupLoc=' + encodeURIComponent(backupSavePath) }) browserUI.switchToTask(newTask) diff --git a/js/util/settings/settingsPreload.js b/js/util/settings/settingsPreload.js index 3c7683e1a..5b4e85dee 100644 --- a/js/util/settings/settingsPreload.js +++ b/js/util/settings/settingsPreload.js @@ -1,5 +1,5 @@ window.addEventListener('message', function (e) { - if (!e.origin.startsWith('file://')) { + if (!e.origin.startsWith('min://')) { return } @@ -13,7 +13,7 @@ window.addEventListener('message', function (e) { }) ipc.on('receiveSettingsData', function (e, data) { - if (window.location.toString().startsWith('file://')) { // probably redundant, but might as well check - window.postMessage({ message: 'receiveSettingsData', settings: data }, 'file://') + if (window.location.toString().startsWith('min://')) { // probably redundant, but might as well check + window.postMessage({ message: 'receiveSettingsData', settings: data }, window.location.toString()) } }) diff --git a/js/util/urlParser.js b/js/util/urlParser.js index ce7400d96..36535f3b2 100644 --- a/js/util/urlParser.js +++ b/js/util/urlParser.js @@ -61,18 +61,11 @@ var urlParser = { return 'view-source:' + urlParser.parse(realURL) } - // if the URL is an internal URL, convert it to the correct file:// url - if (url.startsWith('min:')) { - try { - var urlObj = new URL(url) - var pathname = urlObj.pathname.replace('//', '') - if (/^[a-zA-Z]+$/.test(pathname)) { - // only paths with letters are allowed - return urlParser.getFileURL( - path.join(__dirname, 'pages', pathname, 'index.html') + urlObj.search - ) - } - } catch (e) {} + if (url.startsWith('min:') && !url.startsWith('min://app/')) { + // convert shortened min:// urls to full ones + const urlChunks = url.split('?')[0].replace(/min:(\/\/)?/g, '').split('/') + const query = url.split('?')[1] + return 'min://app/pages/' + urlChunks[0] + (urlChunks[1] ? urlChunks.slice(1).join('/') : '/index.html') + (query ? '?' + query : '') } // if the url starts with a (supported) protocol @@ -111,7 +104,7 @@ var urlParser = { } }, isInternalURL: function (url) { - return url.startsWith(urlParser.getFileURL(__dirname)) + return url.startsWith('min://') }, getSourceURL: function (url) { // converts internal URLs (like the PDF viewer or the reader view) to the URL of the page they are displaying diff --git a/js/webviews.js b/js/webviews.js index 4d826c5d2..5124689e0 100644 --- a/js/webviews.js +++ b/js/webviews.js @@ -29,7 +29,7 @@ function captureCurrentTab (options) { // called whenever a new page starts loading, or an in-page navigation occurs function onPageURLChange (tab, url) { - if (url.indexOf('https://') === 0 || url.indexOf('about:') === 0 || url.indexOf('chrome:') === 0 || url.indexOf('file://') === 0) { + if (url.indexOf('https://') === 0 || url.indexOf('about:') === 0 || url.indexOf('chrome:') === 0 || url.indexOf('file://') === 0 || url.indexOf('min://') === 0) { tabs.update(tab, { secure: true, url: url @@ -104,7 +104,7 @@ const webviews = { placeholderRequests: [], asyncCallbacks: {}, internalPages: { - error: urlParser.getFileURL(__dirname + '/pages/error/index.html') + error: 'min://app/pages/error/index.html' }, events: [], IPCEvents: [], @@ -464,7 +464,7 @@ webviews.bindIPC('setSetting', function (tabId, args) { settings.listen(function () { tasks.forEach(function (task) { task.tabs.forEach(function (tab) { - if (tab.url.startsWith('file://')) { + if (tab.url.startsWith('min://')) { try { webviews.callAsync(tab.id, 'send', ['receiveSettingsData', settings.list]) } catch (e) { diff --git a/main/download.js b/main/download.js index 2601e01df..cf9c0be28 100644 --- a/main/download.js +++ b/main/download.js @@ -62,7 +62,6 @@ function downloadHandler (event, item, webContents) { function listenForDownloadHeaders (ses) { ses.webRequest.onHeadersReceived(function (details, callback) { if (details.resourceType === 'mainFrame' && details.responseHeaders) { - let sourceWindow if (details.webContents) { const sourceView = Object.values(viewMap).find(view => view.webContents.id === details.webContents.id) @@ -98,6 +97,27 @@ function listenForDownloadHeaders (ses) { isFileView }) } + + /* + SECURITY POLICY EXCEPTION: + reader and PDF internal pages get universal access to web resources + Note: we can't limit to the URL in the query string, because there could be redirects + */ + if (details.webContents && (details.webContents.getURL().startsWith('min://app/pages/pdfViewer') || details.webContents.getURL().startsWith('min://app/reader/') || details.webContents.getURL() === 'min://app/index.html')) { + const filteredHeaders = Object.fromEntries( + Object.entries(details.responseHeaders).filter(([key, val]) => key.toLowerCase() !== 'access-control-allow-origin' && key.toLowerCase() !== 'access-control-allow-credentials') + ) + + callback({ + responseHeaders: { + ...filteredHeaders, + 'Access-Control-Allow-Origin': 'min://app', + 'Access-Control-Allow-Credentials': 'true' + } + }) + return + } + callback({ cancel: false }) }) } diff --git a/main/main.js b/main/main.js index 5d7d1f80e..de89aca60 100644 --- a/main/main.js +++ b/main/main.js @@ -13,7 +13,8 @@ const { crashReporter, dialog, nativeTheme, - shell + shell, + net } = electron crashReporter.start({ @@ -61,7 +62,7 @@ app.commandLine.appendSwitch('disable-backgrounding-occluded-windows', 'true') var userDataPath = app.getPath('userData') -const browserPage = 'file://' + __dirname + '/index.html' +const browserPage = 'min://app/index.html' var mainMenu = null var secondaryMenu = null @@ -327,6 +328,8 @@ app.on('ready', function () { return } + registerBundleProtocol(session.defaultSession) + const newWin = createWindow() newWin.webContents.on('did-finish-load', function () { diff --git a/main/menu.js b/main/menu.js index 7132c39fb..9257f0d6f 100644 --- a/main/menu.js +++ b/main/menu.js @@ -92,7 +92,7 @@ function buildAppMenu (options = {}) { accelerator: 'CmdOrCtrl+,', click: function (item, window) { sendIPCToWindow(window, 'addTab', { - url: 'file://' + __dirname + '/pages/settings/index.html' + url: 'min://app/pages/settings/index.html' }) } } diff --git a/main/minInternalProtocol.js b/main/minInternalProtocol.js new file mode 100644 index 000000000..13aa3fd99 --- /dev/null +++ b/main/minInternalProtocol.js @@ -0,0 +1,50 @@ +const { pathToFileURL } = require('url') + +protocol.registerSchemesAsPrivileged([ + { + scheme: 'min', + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + } + } +]) + +function registerBundleProtocol (ses) { + ses.protocol.handle('min', (req) => { + let { host, pathname } = new URL(req.url) + + if (pathname.charAt(0) === '/') { + pathname = pathname.substring(1) + } + + if (host !== 'app') { + return new Response('bad', { + status: 400, + headers: { 'content-type': 'text/html' } + }) + } + + // NB, this checks for paths that escape the bundle, e.g. + // app://bundle/../../secret_file.txt + const pathToServe = path.resolve(__dirname, pathname) + const relativePath = path.relative(__dirname, pathToServe) + const isSafe = relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath) + + if (!isSafe) { + return new Response('bad', { + status: 400, + headers: { 'content-type': 'text/html' } + }) + } + + return net.fetch(pathToFileURL(pathToServe).toString()) + }) +} + +app.on('session-created', (ses) => { + if (ses !== session.defaultSession) { + registerBundleProtocol(ses) + } +}) diff --git a/main/prompt.js b/main/prompt.js index 7431a0a3c..b9b18b224 100644 --- a/main/prompt.js +++ b/main/prompt.js @@ -31,7 +31,7 @@ function createPrompt (options, callback) { }) // Load the HTML dialog box - promptWindow.loadURL('file://' + __dirname + '/pages/prompt/index.html') + promptWindow.loadURL('min://app/pages/prompt/index.html') promptWindow.once('ready-to-show', () => { promptWindow.show() }) } diff --git a/package.json b/package.json index 905a9c633..47111f8a1 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "author": "PalmerAL", "version": "1.30.0", "description": "A fast, minimal browser that protects your privacy", - "electronVersion": "29.0.0-alpha.4", + "electronVersion": "29.0.0-alpha.7", "main": "main.build.js", "standard": { "globals": [ @@ -29,6 +29,7 @@ ] }, "dependencies": { + "@electron/fuses": "^1.7.0", "dexie": "^3.0.3", "dragula": "github:minbrowser/dragula", "electron-squirrel-startup": "^1.0.0", @@ -47,7 +48,7 @@ "browserify": "^16.5.1", "concurrently": "^5.2.0", "decomment": "^0.9.0", - "electron": "29.0.0-alpha.4", + "electron": "29.0.0-alpha.7", "electron-builder": "^22.14.13", "electron-installer-windows": "^3.0.0", "electron-packager": "^15.1.0", @@ -60,6 +61,7 @@ }, "license": "Apache-2.0", "scripts": { + "postinstall": "node ./scripts/setupDevEnv.js", "test": "standard --verbose js/**/*.js main/*.js", "watch": "node ./scripts/watch.js", "startElectron": "electron . --development-mode", diff --git a/scripts/buildMain.js b/scripts/buildMain.js index 5584faa3b..3e1ced2e6 100644 --- a/scripts/buildMain.js +++ b/scripts/buildMain.js @@ -10,6 +10,7 @@ const modules = [ 'main/touchbar.js', 'main/registryConfig.js', 'main/main.js', + 'main/minInternalProtocol.js', 'js/util/settings/settingsMain.js', 'main/filtering.js', 'main/viewManager.js', diff --git a/scripts/createPackage.js b/scripts/createPackage.js index efd3d4f82..a3ac1db2f 100644 --- a/scripts/createPackage.js +++ b/scripts/createPackage.js @@ -1,16 +1,10 @@ -const packager = require('electron-packager') -const rebuild = require('electron-rebuild').default - -const packageFile = require('./../package.json') -const version = packageFile.version -const electronVersion = packageFile.electronVersion - -const basedir = require('path').join(__dirname, '../') - const builder = require('electron-builder') const Platform = builder.Platform const Arch = builder.Arch +const { flipFuses, FuseVersion, FuseV1Options } = require('@electron/fuses') +const path = require('path') + function toPath (platform, arch) { if (platform == 'win32') { switch (arch) { @@ -41,6 +35,30 @@ function toPath (platform, arch) { } module.exports = function (platform, extraOptions) { + //https://github.com/electron-userland/electron-builder/issues/6365#issuecomment-1186038034 + const afterPack = async context => { + const ext = { + darwin: '.app', + win32: '.exe', + linux: [''] + }[context.electronPlatformName] + + const IS_LINUX = context.electronPlatformName === 'linux' + const executableName = IS_LINUX + ? context.packager.appInfo.productFilename.toLowerCase().replace('-dev', '') + : context.packager.appInfo.productFilename + + const electronBinaryPath = path.join( + context.appOutDir, + `${executableName}${ext}` + ) + + await flipFuses(electronBinaryPath, { + version: FuseVersion.V1, + [FuseV1Options.GrantFileProtocolExtraPrivileges]: false + }) + } + const options = { files: [ '**/*', @@ -63,7 +81,7 @@ module.exports = function (platform, extraOptions) { '!**/node_modules/@types/', '!**/node_modules/pdfjs-dist/legacy', '!**/node_modules/pdfjs-dist/lib', - '!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}', + '!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}' ], linux: { target: [ @@ -114,10 +132,11 @@ module.exports = function (platform, extraOptions) { schemes: ['file'] } ], - asar: false + asar: false, + afterPack: afterPack } - let target = function () { + const target = (function () { if (platform == 'win32') { return Platform.WINDOWS.createTarget(['dir'], extraOptions.arch) } else if (platform == 'linux') { @@ -125,7 +144,7 @@ module.exports = function (platform, extraOptions) { } else if (platform == 'mac') { return Platform.MAC.createTarget(['dir'], extraOptions.arch) } - }() + }()) return builder.build({ targets: target, diff --git a/scripts/setupDevEnv.js b/scripts/setupDevEnv.js new file mode 100644 index 000000000..a7308e6d8 --- /dev/null +++ b/scripts/setupDevEnv.js @@ -0,0 +1,14 @@ +const { flipFuses, FuseVersion, FuseV1Options } = require('@electron/fuses') +const { execSync } = require('child_process') + +// Note: these fuses should match those defined in createPackage.js +flipFuses(require('electron'), { + version: FuseVersion.V1, + [FuseV1Options.GrantFileProtocolExtraPrivileges]: false +}) + .then(() => { + // macOS ARM always requires a valid code signature + if (process.platform === 'darwin' && process.arch === 'arm64') { + execSync('codesign -s - -a arm64 -f --deep ' + require('electron')) + } + })