From b8afe34fbb57c186436d15ce4b84c199c974253d Mon Sep 17 00:00:00 2001 From: Dave Page Date: Fri, 16 Oct 2026 09:38:54 +0100 Subject: [PATCH] Keep external pages out of the desktop runtime's privileged windows and confine non-prompted downloads to the downloads directory. #10614 (#10616) --- runtime/src/js/downloader.js | 57 +++++++++++---- runtime/src/js/pgadmin.js | 121 ++++++++++++++++++++++++-------- runtime/src/js/window_policy.js | 93 ++++++++++++++++++++++++ 3 files changed, 228 insertions(+), 43 deletions(-) create mode 100644 runtime/src/js/window_policy.js diff --git a/runtime/src/js/downloader.js b/runtime/src/js/downloader.js index 214ce543c..5aeeadbc8 100644 --- a/runtime/src/js/downloader.js +++ b/runtime/src/js/downloader.js @@ -64,29 +64,56 @@ function updateProgress(callerWindow) { setProgress.call(callerWindow, progress); } -async function fileDownloadPath(callerWindow, options, prompt=true) { - let filePath = path.join(app.getPath('downloads'), options.defaultPath); - // prompt is true when the user has set the preference to prompt for download location - if(prompt) { - const result = await dialog.showSaveDialog(callerWindow, { - title: 'Save File', - ...options, - }); +// Work out where to save a download without asking the user. Only the file +// name part of the suggested name is used, so the file always lands directly +// in the downloads directory, whatever the requesting page asked for. +export function defaultDownloadPath(downloadsDir, defaultPath) { + const fileName = path.basename(String(defaultPath ?? '')); + if (fileName === '' || fileName === '.' || fileName === '..') { + return null; + } - if (result.canceled) { - return; - } - filePath = result.filePath; + const resolvedDir = path.resolve(downloadsDir); + const filePath = path.resolve(resolvedDir, fileName); + if (path.dirname(filePath) !== resolvedDir) { + return null; } return filePath; } +async function fileDownloadPath(callerWindow, options, prompt=true) { + // prompt is true when the user has set the preference to prompt for download location + if(!prompt) { + const filePath = defaultDownloadPath(app.getPath('downloads'), options?.defaultPath); + if (!filePath) { + writeServerLog(`Refused to save a download as ${options?.defaultPath}`); + } + return filePath ?? undefined; + } + + // The page only gets to suggest a file name; the dialog always starts in + // the downloads directory. + const result = await dialog.showSaveDialog(callerWindow, { + title: 'Save File', + ...options, + defaultPath: defaultDownloadPath(app.getPath('downloads'), options?.defaultPath) ?? undefined, + }); + + if (result.canceled) { + return; + } + return result.filePath; +} + export function setupDownloader() { // Listen for the renderer's request to show the open dialog ipcMain.handle('get-download-stream-path', async (event, options, prompt=true) => { try { const callerWindow = BrowserWindow.fromWebContents(event.sender); const filePath = await fileDownloadPath(callerWindow, options, prompt); + if (!filePath) { + return; + } downloadQueue[filePath] = new DownloadItem(filePath, () => { updateProgress(callerWindow); }, () => { @@ -128,6 +155,9 @@ export function setupDownloader() { ipcMain.handle('download-base64-url-data', async (event, base64url, options, prompt=true, openFile=false) => { const callerWindow = BrowserWindow.fromWebContents(event.sender); const filePath = await fileDownloadPath(callerWindow, options, prompt); + if (!filePath) { + return; + } const buffer = Buffer.from(base64url.split(',')[1], 'base64'); fs.writeFileSync(filePath, buffer); openFile && shell.openPath(filePath); @@ -136,6 +166,9 @@ export function setupDownloader() { ipcMain.handle('download-text-data', async (event, text, options, prompt=true, openFile=false) => { const callerWindow = BrowserWindow.fromWebContents(event.sender); const filePath = await fileDownloadPath(callerWindow, options, prompt); + if (!filePath) { + return; + } fs.writeFileSync(filePath, text); openFile && shell.openPath(filePath); }); diff --git a/runtime/src/js/pgadmin.js b/runtime/src/js/pgadmin.js index 73d7a6961..4bd0e3a73 100644 --- a/runtime/src/js/pgadmin.js +++ b/runtime/src/js/pgadmin.js @@ -18,6 +18,7 @@ import { setupMenu } from './menu.js'; import contextMenu from 'electron-context-menu'; import { setupDownloader } from './downloader.js'; import { setupAutoUpdater, updateConfigAndMenus } from './autoUpdaterHandler.js'; +import { classifyWindowOpen, isAppUrl, isExternalUrl } from './window_policy.js'; const configStore = new Store({ defaults: { @@ -43,8 +44,6 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); let baseUrl = `http://127.0.0.1:${serverPort}`; -let docsURLSubStrings = ['www.enterprisedb.com', 'www.postgresql.org', 'www.pgadmin.org', 'help/help']; - process.env['ELECTRON_ENABLE_SECURITY_WARNINGS'] = false; // Paths to the rest of the app @@ -361,6 +360,94 @@ function startDesktopMode() { performPing(); } +// Restrict what a window showing pgAdmin (and so carrying pgadmin_preload.js) +// may open or navigate to. Pages that are not served by the local pgAdmin +// server must never be loaded with the preload attached, as it gives them +// access to privileged APIs such as writing and opening files. +function setupWindowGuards(win) { + const contents = win.webContents; + + contents.setWindowOpenHandler(({url, frameName})=>{ + const openDocsInBrowser = configStore.get('openDocsInBrowser', true); + const target = classifyWindowOpen({url, frameName}, baseUrl, openDocsInBrowser); + + if (target === 'external') { + shell.openExternal(url); + return { action: 'deny' }; + } else if (target === 'deny') { + misc.writeServerLog(`Refused to open a window for ${url}`); + return { action: 'deny' }; + } + + // Both remaining cases open in-app, but only pgAdmin's own pages get + // the preload. Electron does not pass the preload on to child windows + // unless it is given here. + return { + action: 'allow', + overrideBrowserWindowOptions: { + 'position': 'center', + 'minWidth': 640, + 'minHeight': 480, + icon: '../../assets/pgAdmin4.png', + ...pgAdminMainScreen.getBounds(), + webPreferences: target === 'app' ? { + preload: path.join(__dirname, 'pgadmin_preload.js'), + } : { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }, + }; + }); + + // Child windows showing pgAdmin pages need the same restrictions, or they + // could open or navigate to other sites with the preload attached. + contents.on('did-create-window', (childWindow, {url, frameName})=>{ + const openDocsInBrowser = configStore.get('openDocsInBrowser', true); + const target = classifyWindowOpen({url, frameName}, baseUrl, openDocsInBrowser); + if (target === 'app') { + setupWindowGuards(childWindow); + } else if (target === 'isolated') { + setupIsolatedWindowGuards(childWindow); + } + }); + + // Keep the window, and any frames within it (the preload is also loaded + // into subframes of the main window), on pgAdmin's own pages. Links to + // other sites are sent to the default browser instead. + const guardNavigation = (event)=>{ + const url = event.url; + if (url === 'about:blank' || url === 'about:srcdoc' || isAppUrl(url, baseUrl)) { + return; + } + event.preventDefault(); + if (isExternalUrl(url)) { + shell.openExternal(url); + } else { + misc.writeServerLog(`Refused to navigate to ${url}`); + } + }; + contents.on('will-frame-navigate', guardNavigation); + contents.on('will-redirect', guardNavigation); +} + +// Windows showing third party pages in-app (the Google sign-in flow, or the +// online documentation) must not open further in-app windows; links that try +// to are sent to the default browser instead. Navigation within the window is +// left alone, as the Google sign-in flow moves between Google's own hosts +// before finally redirecting back to the local pgAdmin server. +function setupIsolatedWindowGuards(win) { + win.webContents.setWindowOpenHandler(({url})=>{ + if (isExternalUrl(url)) { + shell.openExternal(url); + } else { + misc.writeServerLog(`Refused to open a window for ${url}`); + } + return { action: 'deny' }; + }); +} + // This function is used to hide the splash screen and create/launch // new window to render pgAdmin4 page. function launchPgAdminWindow() { @@ -409,35 +496,7 @@ function launchPgAdminWindow() { forceQuitAndInstallUpdate, }); - pgAdminMainScreen.webContents.setWindowOpenHandler(({url})=>{ - let openDocsInBrowser = configStore.get('openDocsInBrowser', true); - let isDocURL = false; - docsURLSubStrings.forEach(function (key) { - if (url.indexOf(key) >= 0) { - isDocURL = true; - } - }); - - if (openDocsInBrowser && isDocURL) { - // Do not open the window - shell.openExternal(url); - return { action: 'deny' }; - } else { - return { - action: 'allow', - overrideBrowserWindowOptions: { - 'position': 'center', - 'minWidth': 640, - 'minHeight': 480, - icon: '../../assets/pgAdmin4.png', - ...pgAdminMainScreen.getBounds(), - webPreferences: { - preload: path.join(__dirname, 'pgadmin_preload.js'), - }, - }, - }; - } - }); + setupWindowGuards(pgAdminMainScreen); pgAdminMainScreen.on('closed', ()=>{ cleanupAutoUpdateFlag(); diff --git a/runtime/src/js/window_policy.js b/runtime/src/js/window_policy.js new file mode 100644 index 000000000..d7992c56a --- /dev/null +++ b/runtime/src/js/window_policy.js @@ -0,0 +1,93 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Decides where URLs opened or navigated to from pgAdmin's windows may be +// loaded. Only pages served by the local pgAdmin server may be loaded in a +// window that has pgadmin_preload.js attached, because that preload exposes +// privileged APIs (file dialogs, downloads that write and open files, etc.) +// to the page. Everything else is either handed to the default browser, or, +// where it genuinely has to stay in the app, loaded without the preload. + +// Hosts whose documentation may be shown in an in-app window when the user +// has turned off 'Open Documentation in Default Browser'. +const DOCS_HOSTS = ['www.enterprisedb.com', 'www.postgresql.org', 'www.pgadmin.org']; + +// Path under which the pgAdmin server serves its own documentation. +const APP_DOCS_PATH = '/help/help/'; + +// Window names used by flows that must complete in the app's own session, +// even though they start on a third party site. The Google OAuth flow +// redirects back to the local server, which needs pgAdmin's session cookie +// to accept the callback. +const IN_APP_EXTERNAL_FRAME_NAMES = ['google_authentication']; + +// Schemes that may be handed to the operating system's default handler. +const EXTERNAL_SCHEMES = ['http:', 'https:', 'mailto:']; + +function parseUrl(url) { + try { + return new URL(url); + } catch { + return null; + } +} + +// Returns true if the URL is served by the local pgAdmin server. +export function isAppUrl(url, appBaseUrl) { + const parsed = parseUrl(url); + const base = parseUrl(appBaseUrl); + return Boolean(parsed && base && parsed.origin !== 'null' && + parsed.origin === base.origin); +} + +// Returns true if the URL may be handed to the default browser or mail client. +export function isExternalUrl(url) { + const parsed = parseUrl(url); + return Boolean(parsed && EXTERNAL_SCHEMES.includes(parsed.protocol)); +} + +// Classifies a window.open() request from a pgAdmin window. Returns one of: +// 'app' - open in-app, with the pgAdmin preload attached +// 'isolated' - open in-app, without the pgAdmin preload +// 'external' - open in the default browser +// 'deny' - do not open at all +export function classifyWindowOpen({url, frameName}, appBaseUrl, openDocsInBrowser) { + // window.open('') gives an about:blank window that the opening pgAdmin page + // then fills in and submits to the server (see ToolView.jsx). + if (url === 'about:blank') { + return 'app'; + } + + const parsed = parseUrl(url); + if (!parsed) { + return 'deny'; + } + + if (isAppUrl(url, appBaseUrl)) { + if (openDocsInBrowser && parsed.pathname.startsWith(APP_DOCS_PATH)) { + return 'external'; + } + return 'app'; + } + + if (!isExternalUrl(url)) { + return 'deny'; + } + + if (parsed.protocol === 'https:' && IN_APP_EXTERNAL_FRAME_NAMES.includes(frameName)) { + return 'isolated'; + } + + if (!openDocsInBrowser && parsed.protocol !== 'mailto:' && + DOCS_HOSTS.includes(parsed.hostname)) { + return 'isolated'; + } + + return 'external'; +}