Files
pgadmin4/web/pgadmin/utils/__init__.py
T
Ashesh Vashi ad6c028cff Make the File Manager and storage-path tools act on the path that was checked. #10674
In server mode, check_access_permission() validated a path into the
user's storage directory, but several callers then acted on a path built
differently, so the path checked and the path used could name different
files:

- Opening a file in the Query Tool, saving a file, the Google client
  secret and, on Windows, Import/Export Data, Backup and Restore joined
  the path to the storage directory after stripping leading separators,
  so a path checked as inside storage could name any file the pgAdmin
  account can read, or on Windows write outside it.
- Listing a folder decoded the path again after the check, so a folder
  named like an encoded ".." listed any directory on the host.
- Download, delete, rename and the file-exists check appended the path
  to the storage directory without a separator, so where one user's
  storage directory name was a prefix of another's, that user's files
  could be read, deleted or moved; download also checked the path
  appended to itself.
- Creating a folder checked the parent rather than the folder created,
  and upload wrote to a path that was not checked.

check_access_permission() now returns the absolute path it checked, and
every caller acts on that path. Folder paths are decoded once, rename
checks the new name where it is created, and download returns a 404
without the server path for a missing file.

In server mode on POSIX, download, delete, rename and file loads now
open files relative to directories opened from the storage directory
without following symbolic links, as upload and save already did, so a
parent directory swapped for a symbolic link after the check is refused.
The walk follows the path as written: a ".." goes back up past a
directory the walk opened, rather than being collapsed in the text to a
different file from the one the check resolved. It keeps open only the
directories a later ".." can still go back up to, so a path holds at
most two more file descriptors than it has ".." segments, and working
out which to keep takes time linear in the length of the path.

Also:
- The storage directory itself can no longer be deleted.
- In desktop mode, a download refuses a FIFO or other non-regular file
  instead of blocking on it; symbolic links are still followed there.
- A symbolic link refusal is recognised as ELOOP, or EMLINK only where
  O_NOFOLLOW uses it.
- The File Manager sends the selected storage when checking whether a
  file exists, so the overwrite prompt sees files in a shared storage.
- get_closest_parent() tested whether the storage directory existed
  rather than the remembered folder; it now finds the closest existing
  folder inside storage.
- A refused or failed download now shows the server's message.
- check_file_for_bom_and_binary() reports a read error in its result.
2026-10-25 11:03:55 +05:30

1856 lines
68 KiB
Python

##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
import errno
import os
import stat
import sys
import json
import subprocess
import re
from collections import defaultdict
from functools import lru_cache
from operator import attrgetter
from pathlib import Path
from flask import Blueprint, current_app, url_for
from flask_babel import gettext
from flask_security import current_user, login_required
from flask_security.utils import get_post_login_redirect, \
get_post_logout_redirect
from threading import Lock
import config
from .paths import get_storage_directory
from .preferences import Preferences
from pgadmin.utils.constants import UTILITIES_ARRAY, USER_NOT_FOUND, \
MY_STORAGE, ACCESS_DENIED_MESSAGE, INTERNAL, \
CONNECTION_PARAM_ENV_VARS, CONNECTION_PARAM_FILE_PATHS, \
CONNECTION_PARAM_BOOLEANS, CONNECTION_PARAM_DIR_PATHS, \
CONNECTION_PARAM_CREDENTIAL_FILES
from pgadmin.utils.ajax import make_json_response
from pgadmin.model import db, User, ServerGroup, Server
from urllib.parse import unquote
ADD_SERVERS_MSG = "Added %d Server Group(s) and %d Server(s)."
class PgAdminModule(Blueprint):
"""
Base class for every PgAdmin Module.
This class defines a set of method and attributes that
every module should implement.
"""
def __init__(self, name, import_name, **kwargs):
kwargs.setdefault('url_prefix', '/' + name)
kwargs.setdefault('template_folder', 'templates')
kwargs.setdefault('static_folder', 'static')
self.submodules = []
self.parentmodules = []
super().__init__(name, import_name, **kwargs)
def register_preferences(self):
# To be implemented by child classes
pass
def register(self, app, options):
"""
Override the default register function to automagically register
sub-modules at once.
"""
# Sub-classes populate self.submodules from their own register(),
# but the blueprint objects themselves are module level singletons,
# so registering one against a second application instance (which
# happens whenever more than one app is created in a single
# process, as the regression suite does) would otherwise leave a
# duplicate entry behind for every sub-module. Anything that walks
# self.submodules then does its work once per duplicate; in Schema
# Diff's case that means generating the same DDL several times
# over.
self.submodules = list(dict.fromkeys(self.submodules))
super().register(app, options)
def create_module_preference():
# Create preference for each module by default
if hasattr(self, 'LABEL'):
self.preference = Preferences(self.name, self.LABEL)
else:
self.preference = Preferences(self.name, None)
self.register_preferences()
# Create and register the module preference object and preferences for
# it just before starting app
app.register_before_app_start(create_module_preference)
for module in self.submodules:
if self not in module.parentmodules:
module.parentmodules.append(self)
if app.blueprints.get(module.name) is None:
app.register_blueprint(module)
app.register_logout_hook(module)
def get_own_messages(self):
"""
Returns:
dict: the i18n messages used by this module, not including any
messages needed by the submodules.
"""
return dict()
def get_own_menuitems(self):
"""
Returns:
dict: the menuitems for this module, not including
any needed from the submodules.
"""
return defaultdict(list)
def get_exposed_url_endpoints(self):
"""
Returns:
list: a list of url endpoints exposed to the client.
"""
return []
@property
def messages(self):
res = self.get_own_messages()
for module in self.submodules:
res.update(module.messages)
return res
@property
def menu_items(self):
menu_items = self.get_own_menuitems()
for module in self.submodules:
for key, value in module.menu_items.items():
menu_items[key].extend(value)
menu_items = dict((key, sorted(value, key=attrgetter('priority')))
for key, value in menu_items.items())
return menu_items
@property
def exposed_endpoints(self):
res = self.get_exposed_url_endpoints()
for module in self.submodules:
res += module.exposed_endpoints
return res
IS_WIN = (os.name == 'nt')
sys_encoding = sys.getdefaultencoding()
if not sys_encoding or sys_encoding == 'ascii':
# Fall back to 'utf-8', if we couldn't determine the default encoding,
# or 'ascii'.
sys_encoding = 'utf-8'
fs_encoding = sys.getfilesystemencoding()
if not fs_encoding or fs_encoding == 'ascii':
# Fall back to 'utf-8', if we couldn't determine the file-system encoding,
# or 'ascii'.
fs_encoding = 'utf-8'
def u_encode(_s, _encoding=sys_encoding):
return _s
def file_quote(_p):
return _p
if IS_WIN:
import ctypes
from ctypes import wintypes
def env(name):
if name in os.environ:
return os.environ[name]
return None
_GetShortPathNameW = ctypes.windll.kernel32.GetShortPathNameW
_GetShortPathNameW.argtypes = [
wintypes.LPCWSTR, wintypes.LPWSTR, wintypes.DWORD
]
_GetShortPathNameW.restype = wintypes.DWORD
def fs_short_path(_path):
"""
Gets the short path name of a given long path.
http://stackoverflow.com/a/23598461/200291
"""
buf_size = len(_path)
while True:
res = ctypes.create_unicode_buffer(buf_size)
# Note:- _GetShortPathNameW may return empty value
# if directory doesn't exist.
needed = _GetShortPathNameW(_path, res, buf_size)
if buf_size >= needed:
return res.value
else:
buf_size += needed
def document_dir():
CSIDL_PERSONAL = 5 # My Documents
SHGFP_TYPE_CURRENT = 0 # Get current, not default value
buf = ctypes.create_unicode_buffer(wintypes.MAX_PATH)
ctypes.windll.shell32.SHGetFolderPathW(
None, CSIDL_PERSONAL, None, SHGFP_TYPE_CURRENT, buf
)
return buf.value
else:
def env(name):
if name in os.environ:
return os.environ[name]
return None
def fs_short_path(_path):
return _path
def document_dir():
return os.path.realpath(os.path.expanduser('~/'))
# A single SQL identifier, quoted, with every embedded double quote doubled.
_QUOTED_IDENT = re.compile(r'"(?:[^"]|"")*"\Z')
def unquote_ident(value):
"""
Reverse the quoting that Driver.qtIdent() and the server's own
quote_ident() apply to an identifier.
Catalogue functions such as pg_get_indexdef() return an identifier quoted
only when it needs to be, with any embedded double quote doubled, so a
column named 'col"x' arrives as '"col""x"'. Stripping the outer quotes
alone would leave that doubled quote behind.
Anything that is not a single quoted identifier, an unquoted name or an
expression such as '(a || b)', is returned unchanged.
:param value: identifier as returned by the server
:return: the identifier as the user typed it
"""
if value and _QUOTED_IDENT.match(value):
return value[1:-1].replace('""', '"')
return value
def get_directory_and_file_name(drivefilepath):
"""
Returns directory name if specified and file name
:param drivefilepath: file path like '<shared_drive_name>:/filename' or
'/filename'
:return: directory name and file name
"""
dir_name = ''
file_name = drivefilepath
if config.SHARED_STORAGE:
shared_dirs = [sdir['name'] + ':' for sdir in config.SHARED_STORAGE]
if len(re.findall(r"(?=(" + '|'.join(shared_dirs) + r"))",
drivefilepath)) > 0:
dir_file_paths = drivefilepath.split(':/')
dir_name = dir_file_paths[0]
file_name = dir_file_paths[1]
return dir_name, file_name
def get_complete_file_path(file, validate=True, for_write=False):
"""
Args:
file: File returned by file manager
validate: if True, return None unless the path names an existing
file; pass False for a directory, or for a file that may not
exist yet
for_write: set True when libpq will write the file (e.g.
sslkeylogfile) rather than read it. A write target must
additionally honour a shared storage directory that is
read-only for non-administrators, and must resolve its short
path on Windows even though the file does not exist yet.
Returns:
Full path for the file
Raises:
PermissionError: in server mode, if the path resolves to somewhere
outside the user's storage directory (or the named shared
storage directory), or names a read-only shared storage
directory when for_write is set
"""
if not file:
return None
# If desktop mode
if current_app.PGADMIN_RUNTIME or not current_app.config['SERVER_MODE']:
if not validate:
return file
return file if os.path.isfile(file) else None
orig_file = file
# get dir name and file name
dir_name, file = get_directory_and_file_name(file)
storage_dir = get_storage_directory(shared_storage=dir_name) if dir_name \
else get_storage_directory()
# Without a storage directory to anchor it, the path would be used
# exactly as given, so refuse it rather than let it name any file the
# pgAdmin service account can read.
if not storage_dir:
raise PermissionError(
gettext("Access denied ({0}): the storage directory it refers "
"to could not be found.").format(orig_file))
# A write target (e.g. sslkeylogfile) must honour a shared storage
# directory that is read-only for non-administrators; get_storage_
# directory() itself does not enforce that flag, so a non-admin could
# otherwise create or append a file there.
if for_write and dir_name:
shared_dir = next(
(sdir for sdir in current_app.config['SHARED_STORAGE']
if sdir['name'] == dir_name), None)
if shared_dir and shared_dir['restricted_access'] and \
not current_user.has_role("Administrator"):
raise PermissionError(
gettext("Access denied ({0}): the shared storage directory "
"it names is read-only.").format(orig_file))
file = os.path.join(
storage_dir,
file.lstrip('/').lstrip('\\')
)
# The path is the user's to choose, and is used for things like the
# password file and SSL key libpq connects with, so it must not climb
# out of their storage directory with "..", an absolute path or a
# symbolic link. This mirrors Filemanager.check_access_permission().
try:
Path(os.path.realpath(file)).relative_to(
os.path.realpath(storage_dir))
except ValueError:
current_app.logger.warning(
'Refusing the path %r, which resolves outside the storage '
'directory.', orig_file)
raise PermissionError(
gettext("Access denied ({0}): the path resolves outside your "
"storage directory, or outside the shared storage "
"directory it names.").format(orig_file))
if IS_WIN:
file = file.replace('\\', '/')
short_path = fs_short_path(file)
# fs_short_path() returns an empty string on Windows when the file
# does not exist yet. For a write target (sslkeylogfile) that is
# the normal case, so fall back to the short path of the parent
# directory joined with the file name, instead of silently
# dropping the path.
if for_write and short_path == '':
base_name = os.path.basename(file)
dir_short_path = fs_short_path(os.path.dirname(file))
if dir_short_path != '':
short_path = os.path.join(dir_short_path, base_name)
file = short_path
if validate:
return file if os.path.isfile(file) else None
else:
return file
# A path that libpq can never open: a name below a regular file fails with
# ENOTDIR, which libpq treats exactly as it does a missing file, and no
# user can create a directory in place of the file to change that. This
# relies on __file__ being a regular file; under zipimport or a frozen build
# the open fails with ENOTDIR or ENOENT instead, which is still safe.
NO_CLIENT_FILE = os.path.join(os.path.abspath(__file__), 'no-client-file')
def client_files_isolated():
"""
Whether connections are made on behalf of a user who must not use the
client files of the account pgAdmin runs as, which is so in server
mode. The test is the one get_complete_file_path() uses.
PGADMIN_RUNTIME is set by the entry points that know they run as the
desktop runtime. When it is missing (as in the setup.py CLI commands)
this is not the desktop runtime, so server mode still isolates: the
check fails closed.
"""
return not (getattr(current_app, 'PGADMIN_RUNTIME', False) or
not current_app.config['SERVER_MODE'])
def isolate_client_credential_files(params, names=None):
"""
In server mode, set each client credential file parameter that has no
value to NO_CLIENT_FILE, so that libpq does not fall back to the
service account's own ~/.pgpass or client certificate and key.
Args:
params: a dict of connection parameters, updated in place
names: a dict mapping each parameter to the key to set in params,
such as its environment variable; the parameter names
themselves by default
Returns:
params
"""
if client_files_isolated():
for param in CONNECTION_PARAM_CREDENTIAL_FILES:
key = names[param] if names else param
if not params.get(key):
params[key] = NO_CLIENT_FILE
return params
class ServiceNotAllowed(PermissionError):
"""A connection service that may not be used in server mode."""
def service_allowed(service):
"""
Whether a server may connect using the named libpq service.
The service is looked up in the service file of the account pgAdmin
runs as, whose entries can hold passwords and other credentials, so in
server mode only the services the administrator has listed in
SERVER_MODE_ALLOWED_SERVICES may be used. Any service may be used in
desktop mode.
"""
if not service or not client_files_isolated():
return True
allowed = current_app.config.get('SERVER_MODE_ALLOWED_SERVICES') or []
if isinstance(allowed, str):
# A lone string would otherwise allow any substring of it.
allowed = [allowed]
return service in allowed
def check_service_allowed(service):
"""
Raise ServiceNotAllowed, with a message for the user and administrator,
if a server may not connect using the named libpq service.
"""
if not service_allowed(service):
raise ServiceNotAllowed(gettext(
'The service "{0}" cannot be used in server mode because it is '
'not listed in SERVER_MODE_ALLOWED_SERVICES. Ask the pgAdmin '
'administrator to add it to that setting in the pgAdmin '
'configuration, or clear the service in the server\'s '
'properties.').format(service))
def check_connection_params_allowed(params):
"""
Raise ServiceNotAllowed if a server's connection parameters, as a dict,
name a service that may not be used, or, in server mode, a service file:
either would let the user read entries from a service file that is not
theirs.
"""
if not params or not isinstance(params, dict):
return
check_service_allowed(params.get('service'))
if params.get('servicefile') and client_files_isolated():
raise ServiceNotAllowed(gettext(
'The servicefile connection parameter cannot be used in server '
'mode.'))
# Variables the account pgAdmin runs as may have set that libpq would read
# for every connection made without a value of its own: PGPASSWORD supplies
# a password, and PGSERVICE selects a service file entry, which can hold
# one. PGSERVICEFILE and PGSYSCONFDIR are kept, because they locate the
# services listed in SERVER_MODE_ALLOWED_SERVICES, and only those can be
# selected.
INHERITED_LIBPQ_CREDENTIAL_VARS = ('PGPASSWORD', 'PGSERVICE')
def remove_inherited_libpq_credentials(logger=None):
"""
Remove INHERITED_LIBPQ_CREDENTIAL_VARS from pgAdmin's own environment,
so that in server mode neither libpq nor any process pgAdmin starts can
use them on behalf of a user.
Returns:
the names of the variables removed
"""
removed = [var for var in INHERITED_LIBPQ_CREDENTIAL_VARS
if os.environ.pop(var, None) is not None]
if removed and logger is not None:
logger.warning(
'Ignoring %s in the environment: in server mode, connections '
'must not use the credentials of the account pgAdmin runs as.',
', '.join(removed))
return removed
def connection_param_bool_to_int(value):
"""
Convert a boolean connection parameter to the 0 or 1 that libpq expects.
The server dialog stores these as booleans, but a servers.json import
or a direct API call stores whatever it is given, so the strings
"false", "0" and the like must not be treated as true merely for being
non-empty strings.
"""
if isinstance(value, str):
return 0 if value.strip().lower() in \
('', '0', 'false', 'f', 'no', 'n', 'off') else 1
return 1 if value else 0
@lru_cache(maxsize=1)
def get_libpq_connection_keywords():
"""
Return the set of connection-parameter keywords recognised by the
libpq that psycopg is linked against.
This is read from libpq itself rather than hard-coded, so it always
matches the installed version; a keyword unknown to this libpq is not
treated as valid, since make_conninfo() would reject it anyway.
"""
from psycopg.pq import Conninfo
return frozenset(
defn.keyword.decode() for defn in Conninfo.get_defaults()
)
def is_valid_connection_param_key(key):
"""
Return True only if key is an exact libpq connection keyword.
psycopg's make_conninfo() quotes parameter values but not their keys,
so a key that smuggles its own "keyword=value" pairs (for example via
an '=' sign or embedded/leading/trailing whitespace) would reach libpq
as extra, unconfined connection directions. Accepting only exact libpq
keywords closes that injection.
"""
return isinstance(key, str) and key in get_libpq_connection_keywords()
def get_invalid_connection_param_key(connection_params):
"""
Return the first key in connection_params that is not a valid libpq
keyword, or None if every key is valid (or there are no params).
Callers use this to refuse a server whose connection parameters carry
an injected key, before it is saved or used to build a connection
string.
"""
if not connection_params or not isinstance(connection_params, dict):
return None
for key in connection_params:
if not is_valid_connection_param_key(key):
return key
return None
def connection_params_to_env(connection_params):
"""
Translate the connection parameters configured against a server into the
libpq environment variables that carry them, so that a utility such as
pg_dump or psql connects the same way the server dialog says it should.
Only the parameters libpq actually reads from the environment can be
passed this way; anything else in connection_params is dropped.
libpq ranks the environment below a pg_service.conf entry, so in server
mode the credential files are also passed in the utility's --dbname
connection string (see utility_conninfo()); that copy is what beats a
service file entry, and these variables are kept as a second layer.
Args:
connection_params: the server's connection parameters, as a dict
Returns:
A dict of environment variables and their values
"""
env = dict()
if not connection_params or not isinstance(connection_params, dict):
connection_params = dict()
# In server mode a file is passed on even if it does not exist yet,
# rather than dropped, because libpq would then use its default file in
# the service account's home directory instead. The path is still
# confined to the user's storage directory.
validate = not client_files_isolated()
for param, value in connection_params.items():
env_var = CONNECTION_PARAM_ENV_VARS.get(param, None)
if env_var is None or value is None or value == '':
continue
if param in CONNECTION_PARAM_DIR_PATHS:
# A directory, which the file check in get_complete_file_path()
# would reject, so resolve it unvalidated and check it here.
value = get_complete_file_path(value, validate=False)
value = value if value and (
not validate or os.path.isdir(value)) else ''
elif param in CONNECTION_PARAM_FILE_PATHS or \
(param == 'sslrootcert' and value != 'system'):
# An unresolvable path is passed on as an empty value, which
# libpq treats as unset, so it uses its default file; in server
# mode the credential files are set to NO_CLIENT_FILE below.
value = get_complete_file_path(value, validate=validate)
value = '' if value is None else value
elif param in CONNECTION_PARAM_BOOLEANS:
value = connection_param_bool_to_int(value)
env[env_var] = str(value)
return isolate_client_credential_files(
env, names=CONNECTION_PARAM_ENV_VARS)
def _fd_above_stdio(fd):
"""Move `fd` to 3 or above, so it cannot collide with a child's stdio."""
if fd >= 3:
return fd
import fcntl
# On failure the caller still owns, and closes, the original.
new_fd = fcntl.fcntl(fd, fcntl.F_DUPFD_CLOEXEC, 3)
os.close(fd)
return new_fd
def _dev_fd_reopens():
"""True where opening /dev/fd/N gives a new open file description for
the same file (Linux), rather than a dup() of descriptor N that shares
its file offset (macOS and the BSDs)."""
return sys.platform.startswith('linux')
def open_utility_file(path, for_write, is_directory=False, root=None):
"""Open a user's file for an external utility (pg_dump, pg_restore,
psql) without following a symbolic link below the storage directory.
The path has already been checked against the user's storage directory
`root` (by default the one the file manager is using), but the utility
would open it by name later, so a symbolic link swapped in at the leaf
or a parent directory in between would redirect it outside. In server
mode, pgAdmin therefore opens the file itself, walking down from the
storage directory without following symbolic links (see
`_open_beneath()`), and the utility is given /dev/fd/N, inherited
through the background process (see BatchProcess.start()). Only a
regular file is accepted: the open is non-blocking so a FIFO cannot hang
the request.
A directory (directory-format backup/restore) cannot be handed over that
way, as the utility creates and opens the files inside it by name. It is
only checked here, in the same way, not to be or lie below a symbolic
link (and created 0o700 for a backup, as pg_dump would). Someone who can
write to the storage directory as pgAdmin's own OS user can still plant
symbolic links inside it.
Returns (path for the utility, descriptor or None). The caller must
close the descriptor once the process has been started. In desktop mode,
or where O_NOFOLLOW is unavailable, returns (path, None) unchanged; off
Linux, the file is checked as above but (path, None) is still returned.
"""
from pgadmin.misc.file_manager import _refuses_leaf_symlinks, \
_open_beneath, _open_parent_beneath, _openat_nofollow
if not _refuses_leaf_symlinks():
return path, None
if root is None:
root = get_file_manager_storage_dir()
if is_directory:
dir_fd, name = _open_parent_beneath(root, path)
try:
if for_write:
try:
os.mkdir(name, 0o700, dir_fd=dir_fd)
except FileExistsError:
pass
os.close(_openat_nofollow(
dir_fd, name, os.O_RDONLY | os.O_DIRECTORY, 0, path))
finally:
os.close(dir_fd)
return path, None
flags = os.O_NONBLOCK
if for_write:
# O_TRUNC is applied only after the hard-link check below, so a
# second name for a file outside the storage directory is never
# emptied before we have refused it.
flags |= os.O_WRONLY | os.O_CREAT
else:
flags |= os.O_RDONLY
fd = _open_beneath(root, path, flags, 0o600)
try:
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode):
raise OSError(errno.EINVAL,
gettext("Not a regular file"), path)
# A hard link is an ordinary file O_NOFOLLOW cannot detect; refuse a
# second name for the same inode, which could be a file outside the
# storage directory. st_nlink == 0 means the file was unlinked after
# the open, which is refused too.
if st.st_nlink > 1:
raise OSError(errno.EPERM, gettext(
"Refusing a file with multiple hard links"), path)
if st.st_nlink == 0:
raise OSError(errno.EPERM, gettext(
"Refusing a file that was unlinked after opening"), path)
if for_write:
os.ftruncate(fd, 0)
if not _dev_fd_reopens():
# Elsewhere, opening /dev/fd/N dup()s the descriptor, so the
# utility would share its file offset, which pg_restore cannot
# cope with (custom and tar archives are opened more than once
# and seeked, and parallel workers reopen them). The open above
# still refuses a symbolic link already present and creates or
# truncates the file safely, but the utility then reopens it by
# name, so a symbolic or hard link swapped in afterwards is
# followed.
os.close(fd)
return path, None
os.set_blocking(fd, True)
fd = _fd_above_stdio(fd)
except BaseException:
try:
os.close(fd)
except OSError:
pass
raise
return '/dev/fd/{0}'.format(fd), fd
def open_utility_file_or_error(path, for_write, is_directory=False):
"""Call open_utility_file(), turning a failure into a bad request.
Returns (path for the utility, descriptor or None, error response or
None). The error never includes the file name, as that would reveal the
absolute path of the user's storage directory on the server.
"""
from pgadmin.utils.ajax import bad_request
try:
utility_file, fd = open_utility_file(
path, for_write, is_directory=is_directory)
except OSError as e:
if _is_refused_leaf_symlink(e):
return None, None, bad_request(errormsg=gettext(
"Refusing to use a symbolic link."))
return None, None, bad_request(errormsg=e.strerror or str(e))
return utility_file, fd, None
def get_file_manager_storage_dir():
"""
Returns:
The storage directory the user's file manager is currently using
(their own, or the shared storage last selected), or None in
desktop mode. Raises PermissionError for a restricted shared
storage the user may not use.
"""
try:
last_storage = Preferences.module('file_manager').preference(
'last_storage').get()
except Exception:
last_storage = MY_STORAGE
if last_storage != MY_STORAGE:
sel_dir_list = [sdir for sdir in current_app.config['SHARED_STORAGE']
if sdir['name'] == last_storage]
selected_dir = sel_dir_list[0] if len(
sel_dir_list) == 1 else None
if selected_dir and selected_dir['restricted_access'] and \
not current_user.has_role("Administrator"):
raise PermissionError(ACCESS_DENIED_MESSAGE)
return get_storage_directory(shared_storage=last_storage)
return get_storage_directory()
def filename_with_file_manager_path(_file, create_file=False,
skip_permission_check=False):
"""
Args:
_file: File name returned from client file manager
create_file: Set flag to False when file creation doesn't require
skip_permission_check:
Returns:
Filename to use for backup with full path taken from preference
"""
# get dir name and file name
_dir_name, _file = get_directory_and_file_name(_file)
# retrieve storage directory path
try:
storage_dir = get_file_manager_storage_dir()
except PermissionError:
return make_json_response(success=0,
errormsg=ACCESS_DENIED_MESSAGE,
info='ACCESS_DENIED',
status=403)
from pgadmin.misc.file_manager import Filemanager
checked_path = Filemanager.check_access_permission(
storage_dir, _file, skip_permission_check)
if storage_dir:
# Use the path that was checked: joining storage_dir and _file any
# other way can name a different file.
_file = checked_path
elif not os.path.isabs(_file):
_file = os.path.join(document_dir(), _file)
def short_filepath(file=_file):
short_path = fs_short_path(file)
# fs_short_path() function may return empty path on Windows
# if directory doesn't exists. In that case we strip the last path
# component and get the short path.
if os.name == 'nt' and short_path == '':
base_name = os.path.basename(file)
dir_name = os.path.dirname(file)
dir_short_path = fs_short_path(dir_name)
if dir_short_path == '' and file != "":
short_path = os.path.join(short_filepath(dir_name), base_name)
else:
short_path = os.path.join(dir_short_path, base_name)
return short_path
if create_file:
# Touch the file to get the short path of the file on windows. In
# server mode, don't follow a symbolic link below the storage
# directory: that would create the file wherever it points.
from pgadmin.misc.file_manager import _refuses_leaf_symlinks, \
_open_beneath
if _refuses_leaf_symlinks():
os.close(_open_beneath(
storage_dir, _file,
os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_NONBLOCK,
0o600))
return short_filepath()
with open(_file, 'a'):
return short_filepath()
return short_filepath()
def _normalised_path(file):
"""Resolve a path the way the utilities will be run from it: '..'
segments are collapsed as text first (abspath), then symlinks are
followed (realpath), and case is folded where the platform's
normcase does so (Windows).
"""
return Path(os.path.normcase(os.path.realpath(os.path.abspath(file))))
def _reserved_storage_dirs():
"""Directories an ordinary, non-admin pgAdmin user can write files
into: the per-user STORAGE_DIR, and every configured SHARED_STORAGE
entry (whether or not it is marked restricted_access, since a
restricted entry is still writable by an Administrator, and the
binary-path guard has to hold for every account, not just
non-admins). None of these may ever be trusted as a source of
pg_dump/pg_restore/psql/pg_dumpall binaries (CWE-426).
"""
dirs = []
# An empty STORAGE_DIR means the whole filesystem is exposed to users
# by design; realpath('') would resolve to the current working
# directory, so it must not be treated as a reserved directory.
if config.STORAGE_DIR:
dirs.append(_normalised_path(config.STORAGE_DIR))
for entry in getattr(config, 'SHARED_STORAGE', None) or []:
path = entry.get('path') if isinstance(entry, dict) else None
if path:
dirs.append(_normalised_path(path))
return dirs
def is_reserved_storage_path(file):
"""Return True if `file` is, or is nested under, one of the
directories returned by _reserved_storage_dirs().
'$DIR' is expanded first, as the utilities are run from the expanded
path, then the path is resolved so '..' segments, symlinks and case
differences cannot be used to reach a reserved directory by a
different spelling.
"""
# An embedded NUL is not a usable path (and some Python versions
# raise ValueError on it): refuse it rather than guess.
if '\x00' in file:
return True
target = _normalised_path(replace_binary_path(file))
reserved_dirs = _reserved_storage_dirs()
if any(reserved == target or reserved in target.parents
for reserved in reserved_dirs):
return True
# normcase() does not fold case on macOS, whose filesystem is
# case-insensitive by default, so also compare the directories
# themselves: any existing ancestor of the target that is the same
# directory as a reserved one makes the target reserved.
for candidate in (target, *target.parents):
for reserved in reserved_dirs:
try:
if os.path.samefile(candidate, reserved):
return True
except OSError:
continue
return False
def does_utility_exist(file):
"""
This function will check the utility file exists on given path.
:return:
"""
error_msg = None
if file is None:
error_msg = gettext("Utility file not found. Please correct the Binary"
" Path in the Preferences")
return error_msg
if is_reserved_storage_path(file):
error_msg = gettext("Please correct the Binary Path in the "
"Preferences. pgAdmin storage and shared "
"storage directories cannot be a utility "
"binary directory.")
elif not os.path.exists(file):
error_msg = gettext("'%s' file not found. Please correct the Binary"
" Path in the Preferences" % file)
return error_msg
def get_server(sid, only_owned=False):
"""Fetch a server by ID with access check.
Delegates to server_access.get_server(). Kept here for backward
compatibility — existing callers import from pgadmin.utils.
"""
from pgadmin.utils.server_access import get_server as _get_server
return _get_server(sid, only_owned=only_owned)
def get_binary_path_versions(binary_path: str) -> dict:
ret = {}
# Never run utilities from a directory users can write to (CWE-426).
if is_reserved_storage_path(binary_path):
current_app.logger.warning('Reserved storage path rejected as '
'binary path.')
return {utility: None for utility in UTILITIES_ARRAY}
binary_path = os.path.abspath(
replace_binary_path(binary_path)
)
for utility in UTILITIES_ARRAY:
ret[utility] = None
full_path = os.path.join(binary_path,
(utility if os.name != 'nt' else
(utility + '.exe')))
try:
# if path doesn't exist raise exception
if not os.path.isdir(binary_path):
current_app.logger.warning('Invalid binary path.')
raise FileNotFoundError()
# Get the output of the '--version' command
cmd = subprocess.run(
[full_path, '--version'],
shell=False,
capture_output=True,
text=True
)
if cmd.returncode == 0:
ret[utility] = cmd.stdout.split(") ", 1)[1].strip()
except Exception as _:
continue
return ret
def set_binary_path(binary_path, bin_paths, server_type,
version_number=None, set_as_default=False,
is_fixed_path=False):
"""
This function is used to iterate through the utilities and set the
default binary path.
"""
path_with_dir = binary_path if "$DIR" in binary_path else None
binary_versions = get_binary_path_versions(binary_path)
for utility, version in binary_versions.items():
version_number = version if version_number is None else version_number
# version will be None if binary not present
version_number = version_number or ''
if version_number.find('.'):
version_number = version_number.split('.', 1)[0]
try:
# Get the paths array based on server type
if 'pg_bin_paths' in bin_paths or 'as_bin_paths' in bin_paths:
paths_array = bin_paths['pg_bin_paths']
if server_type == 'ppas':
paths_array = bin_paths['as_bin_paths']
else:
paths_array = bin_paths
for path in paths_array:
if path['version'].find(version_number) == 0 and \
path['binaryPath'] is None:
path['binaryPath'] = path_with_dir \
if path_with_dir is not None else binary_path
if set_as_default:
path['isDefault'] = True
# Whether the fixed path in the config file exists or not
path['isFixed'] = is_fixed_path
break
break
except Exception:
continue
def replace_binary_path(binary_path):
"""
This function is used to check if $DIR is present in
the binary path. If it is there then replace it with
module.
"""
if "$DIR" in binary_path:
# When running as an WSGI application, we will not find the
# '__file__' attribute for the '__main__' module.
main_module_file = getattr(
sys.modules['__main__'], '__file__', None
)
if main_module_file is not None:
binary_path = binary_path.replace(
"$DIR", os.path.dirname(main_module_file)
)
return binary_path
def add_value(attr_dict, key, value):
"""Add a value to the attribute dict if non-empty.
Args:
attr_dict (dict): The dictionary to add the values to
key (str): The key for the new value
value (str): The value to add
Returns:
The updated attribute dictionary
"""
if value != "" and value is not None:
attr_dict[key] = value
return attr_dict
def dump_database_servers(output_file, selected_servers,
dump_user=current_user, from_setup=False,
auth_source=INTERNAL):
"""Dump the server groups and servers.
"""
user = _does_user_exist(dump_user, from_setup, auth_source)
if user is None:
return False, USER_NOT_FOUND % dump_user
user_id = user.id
# Dict to collect the output
object_dict = {}
# Counters
servers_dumped = 0
# Dump servers
servers = Server.query.filter_by(user_id=user_id, is_adhoc=0).all()
server_dict = {}
for server in servers:
if selected_servers is None or (
isinstance(selected_servers, list) and len(selected_servers) == 0)\
or str(server.id) in selected_servers\
or server.id in selected_servers:
# Get the group name
group_name = ServerGroup.query.filter_by(
user_id=user_id, id=server.servergroup_id).first().name
attr_dict = {}
add_value(attr_dict, "Name", server.name)
add_value(attr_dict, "Group", group_name)
add_value(attr_dict, "Host", server.host)
add_value(attr_dict, "Port", server.port)
add_value(attr_dict, "MaintenanceDB", server.maintenance_db)
add_value(attr_dict, "Username", server.username)
add_value(attr_dict, "Role", server.role)
add_value(attr_dict, "Comment", server.comment)
add_value(attr_dict, "Shared", server.shared)
add_value(attr_dict, "SharedUsername", server.shared_username)
add_value(attr_dict, "DBRestriction", server.db_res)
add_value(attr_dict, "DBRestrictionType", server.db_res_type)
add_value(attr_dict, "BGColor", server.bgcolor)
add_value(attr_dict, "FGColor", server.fgcolor)
add_value(attr_dict, "Service", server.service)
add_value(attr_dict, "UseSSHTunnel", server.use_ssh_tunnel)
add_value(attr_dict, "TunnelHost", server.tunnel_host)
add_value(attr_dict, "TunnelPort", server.tunnel_port)
add_value(attr_dict, "TunnelUsername", server.tunnel_username)
add_value(attr_dict, "TunnelAuthentication",
server.tunnel_authentication)
add_value(attr_dict, "TunnelIdentityFile",
server.tunnel_identity_file)
add_value(attr_dict, "TunnelPromptPassword",
server.tunnel_prompt_password)
add_value(attr_dict, "TunnelKeepAlive",
server.tunnel_keep_alive)
add_value(attr_dict, "KerberosAuthentication",
server.kerberos_conn),
add_value(attr_dict, "ConnectionParameters",
server.connection_params)
add_value(attr_dict, "Tags", server.tags)
add_value(attr_dict, "PrepareThreshold",
server.prepare_threshold)
add_value(attr_dict, "PostConnectionSQL",
server.post_connection_sql)
if not current_app.config['SERVER_MODE']:
add_value(attr_dict, "PasswordExecCommand",
server.passexec_cmd)
add_value(attr_dict, "PasswordExecExpiration",
server.passexec_expiration)
elif server.passexec_name:
# Server mode never exports free-text commands, only
# the name of an entry in SERVER_PASSEXEC_COMMANDS.
add_value(attr_dict, "PasswordExecCommand",
server.passexec_name)
add_value(attr_dict, "PasswordExecExpiration",
server.passexec_expiration)
servers_dumped = servers_dumped + 1
server_dict[servers_dumped] = attr_dict
object_dict["Servers"] = server_dict
try:
if from_setup:
file_path = unquote(output_file)
else:
file_path = filename_with_file_manager_path(unquote(output_file))
except Exception as e:
return _handle_error(str(e), from_setup)
# write to file
file_content = json.dumps(object_dict, indent=4)
error_str = "Error: {0}"
try:
if from_setup:
with open(file_path, 'w') as output_file:
output_file.write(file_content)
else:
# The path was validated against the user's storage directory
# above, so open it without following a symbolic link swapped
# in at the leaf or a parent directory since that check.
from pgadmin.misc.file_manager import _open_upload_target
with _open_upload_target(
file_path, get_file_manager_storage_dir()) as output_file:
output_file.write(file_content.encode('utf-8'))
except IOError as e:
if not from_setup and _is_refused_leaf_symlink(e):
return _handle_error(gettext(
"Refusing to write through a symbolic link."), from_setup)
err_msg = error_str.format(e.strerror)
return _handle_error(err_msg, from_setup)
except Exception as e:
# A non-OSError has no strerror; fall back to its string form so this
# never raises AttributeError and returns a 500.
current_app.logger.exception(e)
err_msg = error_str.format(getattr(e, 'strerror', None) or str(e))
return _handle_error(err_msg, from_setup)
msg = gettext("Configuration for %s servers dumped to %s" %
(servers_dumped, file_path))
print(msg)
return True, msg
def validate_json_data(data, is_admin):
"""
Used internally by load_servers to validate servers data.
:param data: servers data
:param is_admin:
:return: error message if any
"""
# Loop through the servers...
if "Servers" not in data:
return gettext("'Servers' attribute not found in the specified file.")
# Shared servers can only be imported by administrators. Prune them
# before validating, so that they are dropped even when the
# validation of the remaining servers fails.
if not is_admin:
skip_servers = [server for server, obj in data["Servers"].items()
if obj.get("Shared") is True]
for server in skip_servers:
print("Won't import the server '%s' as it is shared " %
data["Servers"][server].get("Name", server))
del data["Servers"][server]
for server in data["Servers"]:
obj = data["Servers"][server]
# Only the JSON value true means shared, anything else is not.
is_shared = obj.get("Shared") is True
def check_attrib(attrib):
if attrib not in obj:
return gettext("'%s' attribute not found for server '%s'" %
(attrib, server))
return None
def check_is_integer(value):
if not isinstance(value, int):
return gettext("Port must be integer for server '%s'" % server)
return None
for attrib in ("Group", "Name"):
errmsg = check_attrib(attrib)
if errmsg:
return errmsg
is_service_attrib_available = obj.get("Service", None) is not None
if not is_service_attrib_available:
errmsg = check_attrib("Port")
if errmsg:
return errmsg
errmsg = check_is_integer(obj["Port"])
if errmsg:
return errmsg
if is_shared:
# Shared servers may carry either the owner's username
# or a per-user override, so accept either attribute.
# Check the values rather than merely the keys: the
# loader falls back to SharedUsername when Username is
# absent, so an empty or null SharedUsername would
# otherwise be stored as the server's username.
if not obj.get("Username") and not obj.get("SharedUsername"):
return gettext(
"'Username' or 'SharedUsername' attribute not "
"found for server '%s'" % server
)
else:
if not obj.get("Username"):
return gettext(
"'Username' attribute not found for server '%s'" %
server
)
errmsg = check_attrib("MaintenanceDB")
if errmsg:
return errmsg
if "Host" not in obj and not is_service_attrib_available:
return gettext("'Host' or 'Service' attribute not "
"found for server '%s'" % server)
conn_params = obj.get("ConnectionParameters", None)
conn_params = dict(conn_params) \
if isinstance(conn_params, dict) else {}
if is_service_attrib_available:
conn_params['service'] = obj["Service"]
try:
check_connection_params_allowed(conn_params)
except ServiceNotAllowed as e:
return gettext("Server '%s': %s") % (server, e)
return None
def check_non_admin_shared_servers(logger, cli_mode=False):
"""Server mode only: log a warning for each server that is shared by a
user who is not an administrator. Only administrators can share a
server now, but servers shared before that restriction stay shared
until they are un-shared. Read-only, and it never raises, so it cannot
block the startup. Credentials and host details are not logged.
Returns the number of servers found."""
if not config.SERVER_MODE or cli_mode:
return 0
try:
rows = db.session.query(Server.id, Server.name, Server.user_id) \
.filter(Server.shared.is_(True)).all()
found = 0
owner_is_admin = {}
for server_id, name, user_id in rows:
if user_id not in owner_is_admin:
owner = db.session.get(User, user_id)
owner_is_admin[user_id] = owner is not None and \
owner.has_role('Administrator')
if owner_is_admin[user_id]:
continue
found += 1
logger.warning(
"Server %s ('%s') is shared by user %s, who is not an "
"administrator. The owner can un-share it with a PUT of "
"\"shared\": false through the API, or an administrator "
"can change its 'shared' column in the configuration "
"database.", server_id, name, user_id)
return found
except Exception as e:
try:
logger.warning("Unable to check for servers shared by "
"non-administrators: %s", e)
except Exception:
pass
return 0
def _load_server_passexec(new_server, obj):
"""Apply PasswordExecCommand from an import file in server mode,
where it must be the name of a configured command. Anything else
is ignored with a warning that names the server only, as the text
could be a secret."""
from pgadmin.utils.passexec import get_server_passexec_commands
name = obj.get("PasswordExecCommand", None)
if not name:
return
if isinstance(name, str) and name in get_server_passexec_commands():
new_server.passexec_name = name
new_server.passexec_expiration = obj.get(
"PasswordExecExpiration", None)
else:
print("Ignoring the password exec command for server '%s' as it "
"is not the name of a command in SERVER_PASSEXEC_COMMANDS." %
obj.get("Name"))
def load_database_servers(input_file, selected_servers,
load_user=current_user, from_setup=False,
auth_source=INTERNAL, replace=False):
"""Load server groups and servers.
:param replace: clear the user's existing servers once the file has
been validated, and just before the servers are loaded.
"""
user = _does_user_exist(load_user, from_setup, auth_source)
if user is None:
return False, USER_NOT_FOUND % load_user
# generate full path of file
try:
if from_setup:
file_path = unquote(input_file)
else:
file_path = filename_with_file_manager_path(unquote(input_file))
except Exception as e:
return _handle_error(str(e), from_setup)
try:
if from_setup:
with open(file_path) as f:
data = json.load(f)
else:
# As for the export, don't follow a symbolic link swapped in
# since the path was validated.
from pgadmin.misc.file_manager import _open_read_target
with _open_read_target(
file_path, get_file_manager_storage_dir()) as f:
data = json.load(f)
except (json.decoder.JSONDecodeError, UnicodeDecodeError) as e:
# The absolute path is shown only for the setup.py CLI, where the
# administrator named the file; in server mode it would reveal the
# user's storage directory location on the server.
if from_setup:
return _handle_error(gettext("Error parsing input file %s: %s" %
(file_path, e)), from_setup)
return _handle_error(gettext("Error parsing the selected file: %s" %
e), from_setup)
except OSError as e:
if not from_setup and _is_refused_leaf_symlink(e):
return _handle_error(gettext(
"Refusing to read through a symbolic link."), from_setup)
if from_setup:
return _handle_error(
gettext("Error reading input file %s: [%d] %s" %
(file_path, e.errno, e.strerror)), from_setup)
return _handle_error(gettext("Error reading the selected file: "
"[%d] %s" % (e.errno, e.strerror)), from_setup)
except Exception as e:
# A non-OSError (e.g. UnicodeDecodeError from a non-UTF-8 file) has no
# errno/strerror, so don't assume them here.
current_app.logger.exception(e)
if from_setup:
return _handle_error(
gettext("Error reading input file %s: %s" %
(file_path, e)), from_setup)
return _handle_error(
gettext("Unable to read the selected file."), from_setup)
f.close()
user_id = user.id
# Counters
groups_added = 0
servers_added = 0
# Get the server groups
groups = ServerGroup.query.filter_by(user_id=user_id)
# Validate server data
is_admin = user.has_role("Administrator")
error_msg = validate_json_data(data, is_admin)
if error_msg is not None:
if from_setup:
print(ADD_SERVERS_MSG % (groups_added, servers_added))
return _handle_error(error_msg, from_setup)
if replace:
# Clear the existing servers only once the file is known to be
# valid, so that a refused file does not lose the user's servers.
result = clear_database_servers(load_user, from_setup, auth_source)
if result is not None and result is not True:
return result
for server in data["Servers"]:
if selected_servers is None or str(server) in selected_servers:
obj = data["Servers"][server]
# Defence in depth: only administrators can import shared
# servers. Checked before the group is created, so that no
# empty group is left behind.
if obj.get("Shared") is True and not is_admin:
print("Won't import the server '%s' as it is shared " %
obj.get("Name", server))
continue
# Get the group. Create if necessary
group_id = next(
(g.id for g in groups if g.name == obj["Group"]), -1)
if group_id == -1:
new_group = ServerGroup()
new_group.name = obj["Group"]
new_group.user_id = user_id
db.session.add(new_group)
try:
db.session.commit()
except Exception as e:
if from_setup:
print(ADD_SERVERS_MSG % (groups_added, servers_added))
return _handle_error(
gettext("Error creating server group '%s': %s" %
(new_group.name, e)), from_setup)
group_id = new_group.id
groups_added = groups_added + 1
groups = ServerGroup.query.filter_by(user_id=user_id)
# Only the JSON value true means shared, as in validation.
is_shared = obj.get("Shared") is True
username = obj.get("Username", None)
shared_username = obj.get("SharedUsername", None)
if is_shared and username is None:
username = shared_username
# Create the server
new_server = Server()
new_server.name = obj["Name"]
new_server.servergroup_id = group_id
new_server.user_id = user_id
new_server.maintenance_db = obj["MaintenanceDB"]
new_server.host = obj.get("Host", None)
new_server.port = obj.get("Port", None)
new_server.username = username
new_server.role = obj.get("Role", None)
new_server.comment = obj.get("Comment", None)
new_server.db_res = obj.get("DBRestriction", None)
new_server.db_res_type = obj.get("DBRestrictionType", None)
if 'ConnectionParameters' in obj:
new_server.connection_params = \
obj.get("ConnectionParameters", None)
else:
# JSON file format is old before introduction of the
# connection parameters.
conn_param = dict()
for item in ['HostAddr', 'SSLMode', 'PassFile', 'SSLCert',
'SSLKey', 'SSLRootCert', 'SSLCrl', 'Timeout',
'SSLCompression']:
if item in obj:
key = item.lower()
if item == 'Timeout':
key = 'connect_timeout'
conn_param[key] = obj.get(item)
new_server.connection_params = conn_param
# Refuse a key that is not an exact libpq keyword:
# make_conninfo() quotes values but not keys, so a key carrying
# its own "keyword=value" pairs would reach libpq as extra
# connection directions.
invalid_key = get_invalid_connection_param_key(
new_server.connection_params)
if invalid_key is not None:
if from_setup:
print(ADD_SERVERS_MSG % (groups_added, servers_added))
return _handle_error(
gettext(
"Invalid connection parameter '%s' for server '%s'" %
(invalid_key, new_server.name)), from_setup)
new_server.bgcolor = obj.get("BGColor", None)
new_server.fgcolor = obj.get("FGColor", None)
new_server.service = obj.get("Service", None)
new_server.use_ssh_tunnel = obj.get("UseSSHTunnel", None)
new_server.tunnel_host = obj.get("TunnelHost", None)
new_server.tunnel_port = obj.get("TunnelPort", None)
new_server.tunnel_username = obj.get("TunnelUsername", None)
new_server.tunnel_authentication = \
obj.get("TunnelAuthentication", None)
new_server.tunnel_identity_file = \
obj.get("TunnelIdentityFile", None)
new_server.tunnel_prompt_password = \
obj.get("TunnelPromptPassword", 0)
new_server.tunnel_keep_alive = \
obj.get("TunnelKeepAlive", None)
new_server.shared = is_shared and is_admin
new_server.shared_username = \
shared_username if new_server.shared else None
new_server.kerberos_conn = obj.get("KerberosAuthentication", None)
new_server.tags = obj.get("Tags", None)
new_server.prepare_threshold = obj.get("PrepareThreshold", None)
new_server.post_connection_sql = obj.get("PostConnectionSQL", None)
if not current_app.config['SERVER_MODE']:
new_server.passexec_cmd = obj.get("PasswordExecCommand", None)
new_server.passexec_expiration = obj.get(
"PasswordExecExpiration", None)
else:
_load_server_passexec(new_server, obj)
db.session.add(new_server)
try:
db.session.commit()
except Exception as e:
if from_setup:
print(ADD_SERVERS_MSG % (groups_added, servers_added))
return _handle_error(gettext("Error creating server '%s': %s" %
(new_server.name, e)), from_setup)
servers_added = servers_added + 1
msg = ADD_SERVERS_MSG % (groups_added, servers_added)
print(msg)
return True, msg
def clear_database_servers(load_user=current_user, from_setup=False,
auth_source=INTERNAL):
"""Clear groups and servers configurations.
"""
user = _does_user_exist(load_user, from_setup, auth_source)
if user is None:
return False
user_id = user.id
# Remove all servers
servers = Server.query.filter_by(user_id=user_id)
for server in servers:
db.session.delete(server)
# Remove all servergroups except for the first
# This matches the UI behavior in
# web/pgadmin/browser/server_groups/__init__.py#delete
# TODO: Investigate if we can skip the first with an `offset(1)`
groups = ServerGroup.query.filter_by(user_id=user_id).order_by("id")
default_sg = groups.first()
for group in groups:
if group.id != default_sg.id:
db.session.delete(group)
try:
db.session.commit()
except Exception as e:
error_msg = \
gettext("Error clearing server configuration with error (%s)" %
str(e))
if from_setup:
print(error_msg)
sys.exit(1)
return False, error_msg
def _does_user_exist(user, from_setup, auth_source=INTERNAL):
"""
This function will check user is exist or not. If exist then return
"""
if isinstance(user, User):
auth_source = user.auth_source
user = user.username
new_user = User.query.filter_by(username=user,
auth_source=auth_source).first()
if new_user is None:
print(USER_NOT_FOUND % user)
if from_setup:
sys.exit(1)
return new_user
def _is_refused_leaf_symlink(e):
"""True if `e` is the kernel refusing to follow a symbolic link because
the file was opened with O_NOFOLLOW (ELOOP, or EMLINK on some BSDs),
rather than a genuine filesystem error."""
from pgadmin.misc.file_manager import _refuses_leaf_symlinks, \
_is_nofollow_refusal
return _refuses_leaf_symlinks() and _is_nofollow_refusal(e)
def _handle_error(error_msg, from_setup):
"""
This function is used to print the error msg and exit from app if
called from setup.py
"""
if from_setup:
print(error_msg)
sys.exit(1)
return False, error_msg
# Shortcut configuration for Accesskey
ACCESSKEY_FIELDS = [
{
'name': 'key',
'type': 'keyCode',
'label': gettext('Key')
}
]
# Shortcut configuration
SHORTCUT_FIELDS = [
{
'name': 'key',
'type': 'keyCode',
'label': gettext('Key')
},
{
'name': 'shift',
'type': 'checkbox',
'label': gettext('Shift')
},
{
'name': 'control',
'type': 'checkbox',
'label': gettext('Ctrl')
},
{
'name': 'alt',
'type': 'checkbox',
'label': gettext('Alt/Option')
}
]
class KeyManager:
def __init__(self):
self.users = dict()
self.lock = Lock()
@login_required
def get(self):
user = self.users.get(current_user.id, None)
if user is not None:
return user.get('key', None)
@login_required
def set(self, _key, _new_login=True):
with self.lock:
user = self.users.get(current_user.id, None)
if user is None:
self.users[current_user.id] = dict(
session_count=1, key=_key)
else:
if _new_login:
user['session_count'] += 1
user['key'] = _key
@login_required
def reset(self):
with self.lock:
user = self.users.get(current_user.id, None)
if user is not None:
# This will not decrement if session expired
user['session_count'] -= 1
if user['session_count'] == 0:
del self.users[current_user.id]
@login_required
def hard_reset(self):
with self.lock:
user = self.users.get(current_user.id, None)
if user is not None:
del self.users[current_user.id]
def get_safe_post_login_redirect():
allow_list = [
url_for('browser.index')
]
if "SCRIPT_NAME" in os.environ and os.environ["SCRIPT_NAME"]:
allow_list.append(os.environ["SCRIPT_NAME"])
url = get_post_login_redirect()
for item in allow_list:
if url.startswith(item):
return url
return url_for('browser.index')
def get_safe_post_logout_redirect():
allow_list = [
url_for('security.login')
]
if "SCRIPT_NAME" in os.environ and os.environ["SCRIPT_NAME"]:
allow_list.append(os.environ["SCRIPT_NAME"])
url = get_post_logout_redirect()
for item in allow_list:
if url.startswith(item):
return url
return url_for('security.login')
def check_extension_exists(conn, extension_name):
sql = (
"SELECT 1 FROM pg_catalog.pg_extension "
"WHERE extname = %s"
)
status, res = conn.execute_scalar(sql, [extension_name])
if status:
if res:
return status, True
else:
return status, False
else:
# If the query fails, we assume the extension does not exist
return status, res
def database_conninfo(database):
"""
Build the value to pass to a utility's --dbname option so that it
connects to the named database.
A bare --dbname value containing "=" (or a postgresql:// URI) is expanded
by libpq into a connection string, which would let a user redirect the
connection, and the credentials exported with it, to another server.
Passing a conninfo string in which the name is the quoted value of the
dbname keyword avoids that, since libpq never expands a dbname given
inside a connection string. Unlike the PGDATABASE environment variable,
it also takes precedence over a dbname set in the server's service file.
:param database: Name of the database to connect to.
:return: A connection string such as "dbname='my db'".
"""
# Imported here rather than at module level, because pgadmin.utils is
# also imported where libpq is unavailable, such as the documentation
# build, and importing psycopg needs libpq.
from psycopg.conninfo import make_conninfo
return make_conninfo(dbname=database)
def utility_connection_params(manager, server):
"""
The connection parameters a utility connects with: the manager's copy,
which reflects the live connection, else the server's. This is the
selection BatchProcess.set_env_variables() makes for the environment, so
that utility_conninfo() and the environment never name different files.
:param manager: The connection manager, or None.
:param server: The server row, or None.
:return: The connection parameters dict, or None.
"""
if manager and manager.connection_params:
return manager.connection_params
if server and getattr(server, 'connection_params', None):
return server.connection_params
return None
def utility_conninfo(database, connection_params):
"""
The --dbname value for a utility: the database name (if given) plus, in
server mode, passfile/sslcert/sslkey (the user's own file or
NO_CLIENT_FILE), which libpq ranks above a pg_service.conf entry; the
PG* environment variables alone are ranked below it.
:param database: Name of the database to connect to, or None.
:param connection_params: The server's connection parameters.
:return: A connection string such as "dbname='my db' passfile='...'".
"""
params = {} if database is None else {'dbname': database}
if client_files_isolated():
try:
env = connection_params_to_env(connection_params)
except PermissionError:
# A path escaping the storage directory: set_env_variables()
# raises this again, inside the caller's error handling.
env = {}
for param in CONNECTION_PARAM_CREDENTIAL_FILES:
params[param] = \
env.get(CONNECTION_PARAM_ENV_VARS[param]) or NO_CLIENT_FILE
# See database_conninfo() for why this is imported here.
from psycopg.conninfo import make_conninfo
return make_conninfo(**params)