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:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
93
runtime/src/js/window_policy.js
Normal file
93
runtime/src/js/window_policy.js
Normal 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';
|
||||
}
|
||||
Reference in New Issue
Block a user