mirror of
https://github.com/pgadmin-org/pgadmin4.git
synced 2026-11-01 15:53:03 -06:00
In server mode, users choose a password exec command by name from SERVER_PASSEXEC_COMMANDS rather than entering a command line. Existing commands are converted on upgrade once the allowlist is configured; if it is empty the conversion is postponed and a warning is logged.
379 lines
16 KiB
Python
379 lines
16 KiB
Python
##########################################################################
|
|
#
|
|
# pgAdmin 4 - PostgreSQL Tools
|
|
#
|
|
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
|
|
# This software is released under the PostgreSQL License
|
|
#
|
|
##########################################################################
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
from datetime import datetime, timedelta, timezone
|
|
from threading import Lock
|
|
|
|
from flask import current_app
|
|
from flask_security import current_user
|
|
|
|
import config
|
|
from pgadmin.utils.driver import get_driver
|
|
|
|
PASSEXEC_INHERIT = '__inherit__'
|
|
PASSEXEC_NONE = '__none__'
|
|
|
|
|
|
def _iter_server_passexec_entries():
|
|
"""Yield (name, argv_or_None, reason) for each configured entry."""
|
|
cmds = getattr(config, 'SERVER_PASSEXEC_COMMANDS', None) or {}
|
|
if not isinstance(cmds, dict):
|
|
yield None, None, 'SERVER_PASSEXEC_COMMANDS must be a dict'
|
|
return
|
|
for name, argv in cmds.items():
|
|
if not isinstance(name, str) or not name.strip():
|
|
yield name, None, 'name must be a non-empty string'
|
|
elif name.startswith('__'):
|
|
yield name, None, 'names starting with "__" are reserved'
|
|
elif not isinstance(argv, (list, tuple)) or not argv or \
|
|
not all(isinstance(a, str) for a in argv):
|
|
yield name, None, 'value must be a non-empty list of strings'
|
|
else:
|
|
yield name, list(argv), None
|
|
|
|
|
|
def get_server_passexec_commands():
|
|
"""Return the valid entries of SERVER_PASSEXEC_COMMANDS as
|
|
{name: argv}, with fresh lists so callers cannot alter config."""
|
|
return {name: argv for name, argv, reason
|
|
in _iter_server_passexec_entries() if reason is None}
|
|
|
|
|
|
def normalise_passexec_name(value, is_non_owner):
|
|
"""Map the name sent by the API to the value stored in the database.
|
|
|
|
Owner (Server row): None, '' and '__none__' store None (no command);
|
|
a configured name stores itself. '__inherit__' is meaningless for an
|
|
owner. Non-owner (SharedServer row): '__inherit__' stores None,
|
|
None, '' and '__none__' store '' (explicitly no command), and a
|
|
configured name stores itself. Anything else raises ValueError."""
|
|
if value is None or value == '' or value == PASSEXEC_NONE:
|
|
return '' if is_non_owner else None
|
|
if value == PASSEXEC_INHERIT:
|
|
if is_non_owner:
|
|
return None
|
|
raise ValueError('Inheriting is only available for shared servers.')
|
|
if not isinstance(value, str) or \
|
|
value not in get_server_passexec_commands():
|
|
raise ValueError('Unknown password exec command.')
|
|
return value
|
|
|
|
|
|
def passexec_name_for_api(stored, is_non_owner):
|
|
"""The inverse of normalise_passexec_name, for the properties API."""
|
|
if is_non_owner:
|
|
if stored is None:
|
|
return PASSEXEC_INHERIT
|
|
return stored or PASSEXEC_NONE
|
|
return stored or PASSEXEC_NONE
|
|
|
|
|
|
def check_server_passexec_config(logger):
|
|
"""Log configuration problems once, at startup."""
|
|
for name, _argv, reason in _iter_server_passexec_entries():
|
|
if reason is not None:
|
|
logger.error('Ignoring SERVER_PASSEXEC_COMMANDS entry %r: %s',
|
|
name, reason)
|
|
extra = getattr(config, 'SERVER_PASSEXEC_ENV_PASSTHROUGH', None) or []
|
|
if not isinstance(extra, (list, tuple)) or \
|
|
not all(isinstance(n, str) for n in extra):
|
|
logger.error('SERVER_PASSEXEC_ENV_PASSTHROUGH must be a list of '
|
|
'environment variable names; ignoring any entry that '
|
|
'is not a string.')
|
|
if getattr(config, 'ENABLE_SERVER_PASS_EXEC_CMD', False):
|
|
logger.warning(
|
|
'ENABLE_SERVER_PASS_EXEC_CMD is deprecated and no longer has '
|
|
'any effect; define the permitted commands in '
|
|
'SERVER_PASSEXEC_COMMANDS instead.')
|
|
|
|
|
|
def convert_legacy_server_passexec(logger):
|
|
"""Server mode only: convert free-text passexec_cmd values that
|
|
exactly match a configured command into its name, and clear every
|
|
passexec_cmd, since free-text commands no longer run in server mode.
|
|
Command text is never logged because it may contain secrets.
|
|
Returns the number of rows changed.
|
|
|
|
The conversion is irreversible, so it does not run until at least
|
|
one command is configured: with an empty SERVER_PASSEXEC_COMMANDS
|
|
nothing could match and every command would be cleared. Until then
|
|
the legacy values stay in the database, where nothing in server
|
|
mode reads, runs, returns or exports them, and a warning is logged
|
|
at each start so the administrator knows to configure the setting
|
|
before they are converted."""
|
|
from pgadmin.model import db, Server, SharedServer
|
|
by_cmd = {' '.join(argv): name
|
|
for name, argv in get_server_passexec_commands().items()}
|
|
if not by_cmd:
|
|
for model in (Server, SharedServer):
|
|
pending = model.query.filter(
|
|
model.passexec_cmd.isnot(None),
|
|
model.passexec_cmd != '').count()
|
|
if pending:
|
|
logger.warning(
|
|
'SERVER_PASSEXEC_COMMANDS is not configured, but %s '
|
|
'row(s) in the %s table still hold a password exec '
|
|
'command from before the upgrade. They are not run in '
|
|
'server mode. Configure SERVER_PASSEXEC_COMMANDS '
|
|
'before the next start: commands that match a '
|
|
'configured entry are then converted to its name, '
|
|
'and the rest are cleared.',
|
|
pending, model.__tablename__)
|
|
return 0
|
|
changed = 0
|
|
for model in (Server, SharedServer):
|
|
# Normalise empty strings quietly; they carry no command.
|
|
model.query.filter(model.passexec_cmd == '').update(
|
|
{model.passexec_cmd: None}, synchronize_session=False)
|
|
rows = model.query.filter(model.passexec_cmd.isnot(None)).all()
|
|
for row in rows:
|
|
name = by_cmd.get(row.passexec_cmd.strip())
|
|
if name is not None:
|
|
row.passexec_name = name
|
|
else:
|
|
logger.warning(
|
|
'Cleared the password exec command on %s %s (user %s): '
|
|
'free-text commands are not run in server mode and it '
|
|
'does not match any SERVER_PASSEXEC_COMMANDS entry.',
|
|
model.__tablename__, row.id, row.user_id)
|
|
row.passexec_cmd = None
|
|
changed += 1
|
|
db.session.commit()
|
|
return changed
|
|
|
|
|
|
# Variables copied from pgAdmin's environment into a server-mode password
|
|
# exec command's environment. Everything else is withheld, because the
|
|
# process environment can hold secrets such as another user's PGPASSWORD
|
|
# (set by export_password_env) or the container's setup password.
|
|
PASSEXEC_BASE_ENV = ('PATH', 'HOME', 'LANG', 'LANGUAGE', 'TZ', 'TMPDIR',
|
|
'TMP', 'TEMP', 'USER', 'LOGNAME')
|
|
PASSEXEC_WINDOWS_ENV = ('SYSTEMROOT', 'SYSTEMDRIVE', 'PATHEXT', 'COMSPEC',
|
|
'WINDIR')
|
|
|
|
|
|
def server_passexec_base_env():
|
|
"""Return the reduced environment for a server-mode password exec
|
|
command: the base allowlist, any LC_* variable, the Windows system
|
|
variables on Windows, and the names in
|
|
SERVER_PASSEXEC_ENV_PASSTHROUGH, each copied only if it is set."""
|
|
names = set(PASSEXEC_BASE_ENV)
|
|
if os.name == 'nt':
|
|
names.update(PASSEXEC_WINDOWS_ENV)
|
|
extra = getattr(config, 'SERVER_PASSEXEC_ENV_PASSTHROUGH', None) or []
|
|
if isinstance(extra, (list, tuple)):
|
|
names.update(n for n in extra if isinstance(n, str))
|
|
if os.name == 'nt':
|
|
# os.environ upper-cases its keys on Windows.
|
|
names = {n.upper() for n in names}
|
|
return {k: v for k, v in os.environ.items()
|
|
if k in names or k.startswith('LC_')}
|
|
|
|
|
|
def server_passexec_startup(app, cli_mode):
|
|
"""Run the server-mode password exec checks when the app starts.
|
|
|
|
The legacy conversion is skipped in CLI mode: setup.py does not set
|
|
SERVER_MODE, so it would otherwise clear the free-text commands in a
|
|
desktop configuration database passed with --sqlite-path."""
|
|
if not config.SERVER_MODE:
|
|
return
|
|
check_server_passexec_config(app.logger)
|
|
if not cli_mode:
|
|
convert_legacy_server_passexec(app.logger)
|
|
|
|
|
|
class PasswordExec:
|
|
|
|
def __init__(self, cmd, host, port, username, expiration_seconds=None,
|
|
timeout=60):
|
|
# One lock per instance, so a slow command only delays callers
|
|
# waiting for the same cached password, not every user.
|
|
self.lock = Lock()
|
|
self.host = host
|
|
self.port = port
|
|
self.username = username
|
|
self.cmd = cmd
|
|
self.expiration_seconds = int(expiration_seconds) \
|
|
if expiration_seconds is not None else None
|
|
self.timeout = int(timeout)
|
|
self.password = None
|
|
self.last_result = None
|
|
|
|
def get(self):
|
|
if config.SERVER_MODE:
|
|
# Arbitrary shell execution on server is a security risk
|
|
raise NotImplementedError('Passexec not available in server mode')
|
|
driver = get_driver(config.PG_DEFAULT_DRIVER)
|
|
self.cmd = str(self.cmd)
|
|
self.cmd = self.cmd.replace('%HOSTNAME%', self.host or '')
|
|
self.cmd = self.cmd.replace(
|
|
'%PORT%', str(self.port) if self.port is not None else '')
|
|
self.cmd = self.cmd.replace(
|
|
'%USERNAME%',
|
|
driver.qtIdent(None, self.username) if self.username else '')
|
|
return self._get_cached(lambda: subprocess.run(
|
|
self.cmd,
|
|
shell=True,
|
|
timeout=self.timeout,
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
), skip=not self.cmd)
|
|
|
|
def _get_cached(self, run, skip=False):
|
|
"""Return the cached password, calling run() when it is missing
|
|
or expired. If skip is true and a run would be needed, return
|
|
None instead."""
|
|
with self.lock:
|
|
if not self.password or self.is_expired():
|
|
if skip:
|
|
return None
|
|
current_app.logger.info('Calling passexec')
|
|
now = datetime.now(timezone.utc)
|
|
try:
|
|
p = run()
|
|
except subprocess.CalledProcessError as e:
|
|
if e.stderr:
|
|
self.create_logger().error(e.stderr)
|
|
raise
|
|
|
|
current_app.logger.info('Passexec completed successfully')
|
|
self.last_result = now
|
|
self.password = p.stdout.strip()
|
|
return self.password
|
|
|
|
def is_expired(self):
|
|
if self.expiration_seconds is None:
|
|
return False
|
|
return self.last_result is not None and\
|
|
datetime.now(timezone.utc) - self.last_result \
|
|
>= timedelta(seconds=self.expiration_seconds)
|
|
|
|
def create_logger(self):
|
|
logger = logging.getLogger('passexec')
|
|
for h in current_app.logger.handlers:
|
|
logger.addHandler(h)
|
|
return logger
|
|
|
|
|
|
class ServerPasswordExec(PasswordExec):
|
|
"""Runs an administrator-defined command without a shell, passing
|
|
connection details in environment variables so that no value can be
|
|
interpreted as part of the command."""
|
|
|
|
def __init__(self, name, argv, host, port, username, database,
|
|
pgadmin_user, auth_source, expiration_seconds=None,
|
|
timeout=60):
|
|
super().__init__(None, host, port, username, expiration_seconds,
|
|
timeout)
|
|
self.name = name
|
|
self.argv = list(argv)
|
|
self.env = server_passexec_base_env()
|
|
self.env.update({
|
|
'PGADMIN_PASSEXEC_HOST': host or '',
|
|
'PGADMIN_PASSEXEC_PORT': str(port) if port is not None else '',
|
|
'PGADMIN_PASSEXEC_USERNAME': username or '',
|
|
'PGADMIN_PASSEXEC_DATABASE': database or '',
|
|
'PGADMIN_PASSEXEC_PGADMIN_USER': pgadmin_user or '',
|
|
'PGADMIN_PASSEXEC_AUTH_SOURCE': auth_source or '',
|
|
})
|
|
|
|
def get(self):
|
|
return self._get_cached(self._run)
|
|
|
|
def _run(self):
|
|
"""Run the command, replacing any failure with an exception that
|
|
names only the command. The subprocess exceptions include the
|
|
full argument list in their text, and that may contain secrets,
|
|
so it must reach neither the user nor the log."""
|
|
logger = self.create_logger()
|
|
try:
|
|
return subprocess.run(
|
|
self.argv, shell=False, env=self.env, timeout=self.timeout,
|
|
capture_output=True, text=True, check=True)
|
|
except subprocess.CalledProcessError as e:
|
|
logger.error('Password exec command %r exited with status %s.',
|
|
self.name, e.returncode)
|
|
self._log_stderr(logger, e.stderr)
|
|
except subprocess.TimeoutExpired as e:
|
|
logger.error('Password exec command %r timed out after %s '
|
|
'seconds.', self.name, self.timeout)
|
|
self._log_stderr(logger, e.stderr)
|
|
except OSError as e:
|
|
logger.error('Password exec command %r could not be run '
|
|
'(errno %s: %s).', self.name, e.errno, e.strerror)
|
|
raise Exception(
|
|
"Password exec command '{0}' failed.".format(self.name)) \
|
|
from None
|
|
|
|
def _log_stderr(self, logger, stderr):
|
|
if isinstance(stderr, bytes):
|
|
stderr = stderr.decode('utf-8', 'replace')
|
|
if stderr:
|
|
logger.error('Password exec command %r stderr: %s',
|
|
self.name, stderr)
|
|
|
|
|
|
def resolve_server_passexec(server, user):
|
|
"""Work out which named command applies to this server for this
|
|
pgAdmin user. Returns (name, expiration, db_username) or None.
|
|
|
|
A non-owner of a shared server inherits the owner's command unless
|
|
their SharedServer row says otherwise (NULL inherits, '' is none,
|
|
anything else is their own choice). The owner's values are read from
|
|
the Server row's passexec fields, which get_shared_server_properties
|
|
deliberately leaves alone."""
|
|
from pgadmin.model import SharedServer
|
|
if server.shared and server.user_id != user.id:
|
|
ss = SharedServer.query.filter_by(osid=server.id,
|
|
user_id=user.id).first()
|
|
db_user = ss.username if ss else server.shared_username
|
|
own = ss.passexec_name if ss else None
|
|
if own == '':
|
|
return None
|
|
if own is not None:
|
|
return own, ss.passexec_expiration, db_user
|
|
if not server.passexec_name:
|
|
return None
|
|
return server.passexec_name, server.passexec_expiration, db_user
|
|
if not server.passexec_name:
|
|
return None
|
|
return server.passexec_name, server.passexec_expiration, server.username
|
|
|
|
|
|
def build_passexec(server):
|
|
"""Build the password exec object for a ServerManager, or None."""
|
|
if not config.SERVER_MODE:
|
|
if not server.passexec_cmd:
|
|
return None
|
|
return PasswordExec(server.passexec_cmd, server.host, server.port,
|
|
server.username, server.passexec_expiration)
|
|
commands = get_server_passexec_commands()
|
|
if not commands:
|
|
return None
|
|
if not current_user or not current_user.is_authenticated:
|
|
return None
|
|
resolved = resolve_server_passexec(server, current_user)
|
|
if resolved is None:
|
|
return None
|
|
name, expiration, db_user = resolved
|
|
argv = commands.get(name)
|
|
if argv is None:
|
|
current_app.logger.warning(
|
|
'Server %s uses password exec command %r, which is not in '
|
|
'SERVER_PASSEXEC_COMMANDS; ignoring it.', server.id, name)
|
|
return None
|
|
return ServerPasswordExec(
|
|
name, argv, server.host, server.port, db_user,
|
|
server.maintenance_db, current_user.username,
|
|
getattr(current_user, 'auth_source', None), expiration)
|