1
0
mirror of synced 2026-10-23 07:08:55 +03:00

Keep external pages out of the desktop runtime's privileged windows and confine non-prompted downloads to the downloads directory. #10614 (#10616)

This commit is contained in:
Dave Page
2026-10-16 09:38:54 +01:00
parent 7f21dc4610
commit b8afe34fbb
3 changed files with 228 additions and 43 deletions

View File

@@ -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);
});

View File

@@ -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();

View File

@@ -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';
}