Skip to content
30 changes: 27 additions & 3 deletions web/pgadmin/browser/server_groups/servers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1825,7 +1825,11 @@ def connect(self, gid, sid, is_qt=False, server=None):
)
except Exception as e:
return self.get_response_for_password(
server, 401, not server.save_password, prompt_tunnel_password,
server, 401,
not server.save_password or self._saved_password_discarded(
conn),
prompt_tunnel_password or
self._saved_tunnel_password_discarded(conn),
getattr(e, 'message', str(e)))

if not status:
Expand All @@ -1837,8 +1841,12 @@ def connect(self, gid, sid, is_qt=False, server=None):
return internal_server_error(errmsg)

return self.get_response_for_password(
server, 401, not server.save_password,
prompt_tunnel_password, errmsg)
server, 401,
not server.save_password or self._saved_password_discarded(
conn),
prompt_tunnel_password or
self._saved_tunnel_password_discarded(conn),
errmsg)
else:
if save_password and config.ALLOW_SAVE_PASSWORD:
try:
Expand Down Expand Up @@ -2271,6 +2279,22 @@ def check_pgpass(self, gid, sid):
)
return internal_server_error(errormsg=str(e))

@staticmethod
def _saved_password_discarded(conn):
"""
Whether the saved server password was undecryptable and dropped, in
which case the user must be prompted even if a password is "saved".
"""
return bool(getattr(conn, 'saved_password_discarded', False))

@staticmethod
def _saved_tunnel_password_discarded(conn):
"""
Whether the saved SSH tunnel password was undecryptable and dropped.
"""
manager = getattr(conn, 'manager', None)
return bool(getattr(manager, 'saved_tunnel_password_discarded', False))

def get_response_for_password(self, server, status, prompt_password=False,
prompt_tunnel_password=False, errmsg=None):

Expand Down
9 changes: 9 additions & 0 deletions web/pgadmin/utils/crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"""This File Provides Cryptography."""

import base64
import binascii
import hashlib
import os

Expand Down Expand Up @@ -44,6 +45,14 @@ def encrypt(plaintext, key):
encryptor.finalize())


# Exceptions raised by decrypt() (or by decoding its result) when the
# ciphertext is malformed or was encrypted with a different key. Anything else
# (e.g. a TypeError from a missing key) is a programming error and should not
# be swallowed. UnicodeDecodeError and binascii.Error are ValueError
# subclasses, they are listed for clarity.
DECRYPT_ERRORS = (UnicodeDecodeError, binascii.Error, ValueError)


def decrypt(ciphertext, key):
"""
Decrypt the AES encrypted string.
Expand Down
84 changes: 71 additions & 13 deletions web/pgadmin/utils/driver/psycopg3/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from flask import g, current_app
from flask_babel import gettext
from flask_security import current_user
from pgadmin.utils.crypto import decrypt
from pgadmin.utils.crypto import decrypt, DECRYPT_ERRORS
from psycopg._encodings import py_codecs as encodings

import config
Expand Down Expand Up @@ -409,6 +409,8 @@ def __init__(self, manager, conn_id, db, **kwargs):
self.wasConnected = False
# This flag indicates the connection reconnecting status.
self.reconnecting = False
# Set when the saved password could not be decrypted and was dropped
self.saved_password_discarded = False
self.use_binary_placeholder = use_binary_placeholder
self.array_to_string = array_to_string
self.qtLiteral = get_driver(config.PG_DEFAULT_DRIVER).qtLiteral
Expand Down Expand Up @@ -468,26 +470,52 @@ def _check_user_password(self, kwargs):
return password, encpass, is_update_password

def _decode_password(self, encpass, manager, password, crypt_key):
"""
Decrypt the saved password.

Returns a tuple of (is_error, errmsg, password, discarded). 'discarded'
is True when the saved password could not be decrypted and was thrown
away; the caller must then drop its copy of the ciphertext too.
"""
if encpass:
# Fetch Logged in User Details.
user = User.query.filter_by(id=current_user.id).first()

if user is None:
return True, self.UNAUTHORIZED_REQUEST, password
return True, self.UNAUTHORIZED_REQUEST, password, False

try:
password = decrypt(encpass, crypt_key)
# password is in bytes, for python3 we need it in string
if isinstance(password, bytes):
password = password.decode()
except Exception as e:
manager.stop_ssh_tunnel()
current_app.logger.exception(e)
return True, \
_(
"Failed to decrypt the saved password.\nError: {0}"
).format(str(e)), password
return False, '', password
except DECRYPT_ERRORS as e:
# The saved password could not be decrypted. This happens
# when the stored ciphertext was encrypted with a different
# key (e.g. OIDC/OAuth2 logins where the derived encryption
# key changed between sessions), leaving un-decodable bytes
# (typically a "'utf-8' codec can't decode byte 0x.." error).
# Instead of failing every connection attempt permanently,
# discard the bad saved password and continue so the user is
# prompted for the password again.
current_app.logger.warning(
'Ignoring the saved password of the server (#{0}) as it '
'could not be decrypted. The user will be prompted for '
'the password. Error: {1}'.format(
getattr(manager, 'sid', None), str(e)),
exc_info=True
)
# Clear the cached ciphertext held by this connection and the
# manager. This only affects callers that reconnect without
# supplying a password (e.g. restoring connections); the main
# connect route reloads server.password from the
# configuration database through manager.update(), so the
# user is still prompted there.
self.password = None
manager.password = None
self.saved_password_discarded = True
return False, '', None, True
return False, '', password, False

def connect(self, **kwargs):
if self.conn:
Expand Down Expand Up @@ -530,11 +558,17 @@ def connect(self, **kwargs):
if not crypt_key_present:
raise CryptKeyMissing()

is_error, errmsg, password = self._decode_password(
self.saved_password_discarded = False
is_error, errmsg, password, discarded = self._decode_password(
encpass, manager, password, crypt_key)
if is_error:
return False, errmsg

if discarded:
# Don't let the stale ciphertext skip the passexec fallback, or
# be written back to the manager after a successful connection.
encpass = None

# If no password credential is found then connect request might come
# from Query tool, ViewData grid, debugger, etc. In that case, fall
# back to using the password returned from manager.passexec.
Expand Down Expand Up @@ -622,6 +656,10 @@ async def connectdbserver():
msg=msg
)
)
if discarded:
msg = gettext(
"The saved password could not be decrypted; please enter "
"it again.\n{0}").format(msg)
return False, msg

# Overwrite connection notice attr to support
Expand Down Expand Up @@ -1765,7 +1803,18 @@ def _decrypt_password(self, manager):
if not crypt_key_present:
return False, crypt_key, password

password = decrypt(password, crypt_key).decode()
try:
password = decrypt(password, crypt_key).decode()
except DECRYPT_ERRORS as e:
current_app.logger.warning(
'Ignoring the saved password of the server (#{0}) as it '
'could not be decrypted. Error: {1}'.format(
getattr(manager, 'sid', None), str(e)),
exc_info=True
)
# Recoverable: let reset() go on and try to connect without
# the password (e.g. through a passfile).
return False, '', None
return True, '', password

def reset(self):
Expand Down Expand Up @@ -2003,7 +2052,16 @@ def cancel_transaction(self, conn_id, did=None):
crypt_key_present, crypt_key = get_crypt_key()
if not crypt_key_present:
return False, crypt_key
password = decrypt(password, crypt_key).decode()
try:
password = decrypt(password, crypt_key).decode()
except DECRYPT_ERRORS as e:
current_app.logger.warning(
'Ignoring the saved password of the server (#{0}) '
'as it could not be decrypted. Error: {1}'.format(
self.manager.sid, str(e)),
exc_info=True
)
password = None

try:
with ConnectionLocker(self.manager.kerberos_conn):
Expand Down
35 changes: 29 additions & 6 deletions web/pgadmin/utils/driver/psycopg3/server_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

from pgadmin.utils import get_complete_file_path, \
connection_param_bool_to_int
from pgadmin.utils.crypto import decrypt
from pgadmin.utils.crypto import decrypt, DECRYPT_ERRORS
from pgadmin.utils.master_password import process_masterpass_disabled
from .connection import Connection
from pgadmin.model import Server, User
Expand Down Expand Up @@ -71,6 +71,7 @@ def update(self, server):
self.server_cls = None
self.password = None
self.tunnel_password = None
self.saved_tunnel_password_discarded = False

self.sid = server.id
self.host = server.host
Expand Down Expand Up @@ -559,7 +560,19 @@ def export_password_env(self, env):
crypt_key_present, crypt_key = get_crypt_key()
if not crypt_key_present:
return False, crypt_key
password = decrypt(self.password, crypt_key).decode()
try:
password = decrypt(self.password, crypt_key).decode()
except DECRYPT_ERRORS as e:
current_app.logger.warning(
'Ignoring the saved password of the server (#{0}) as it '
'could not be decrypted. Error: {1}'.format(
self.sid, str(e)),
exc_info=True
)
# Fall back to passexec, as when no password is saved.
if self.passexec:
os.environ[str(env)] = self.passexec.get()
return
os.environ[str(env)] = password
elif self.passexec:
password = self.passexec.get()
Expand Down Expand Up @@ -587,10 +600,19 @@ def create_ssh_tunnel(self, tunnel_password):
# password is in bytes, for python3 we need it in string
if isinstance(tunnel_password, bytes):
tunnel_password = tunnel_password.decode()
except Exception as e:
current_app.logger.exception(e)
return False, gettext("Failed to decrypt the SSH tunnel "
"password.\nError: {0}").format(str(e))
except DECRYPT_ERRORS as e:
# Same treatment as the server password: discard the
# undecryptable password and carry on, so that the user is
# prompted for it again when the tunnel fails to authenticate.
current_app.logger.warning(
'Ignoring the saved SSH tunnel password of the server '
'(#{0}) as it could not be decrypted. Error: {1}'.format(
self.sid, str(e)),
exc_info=True
)
self.tunnel_password = None
self.saved_tunnel_password_discarded = True
tunnel_password = None

try:
# If authentication method is 1 then it uses identity file
Expand Down Expand Up @@ -635,6 +657,7 @@ def create_ssh_tunnel(self, tunnel_password):
# flag tunnel threads in daemon mode to fix hang issue.
self.tunnel_object.daemon_forward_servers = True
self.tunnel_object.start()
self.saved_tunnel_password_discarded = False
self.tunnel_created = True
except (BaseSSHTunnelForwarderError, ValueError) as e:
# sshtunnel raises a bare ValueError rather than one of its own
Expand Down
64 changes: 64 additions & 0 deletions web/pgadmin/utils/driver/psycopg3/tests/test_decode_password.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################

from unittest.mock import MagicMock, patch

from pgadmin.utils.driver.psycopg3.connection import Connection
from pgadmin.utils.route import BaseTestGenerator

MODULE = 'pgadmin.utils.driver.psycopg3.connection'


class TestDecodePassword(BaseTestGenerator):
"""Saved password decryption failures must be non-fatal."""

scenarios = [
('Undecryptable ciphertext is discarded', dict(
side_effect=UnicodeDecodeError('utf-8', b'\xff', 0, 1, 'bad'),
discarded=True)),
('Valid ciphertext is decrypted', dict(
return_value=b'secret', discarded=False, expected='secret')),
('Unexpected errors still propagate', dict(
side_effect=TypeError('no key'), raises=True)),
]

def runTest(self):
conn = Connection.__new__(Connection)
conn.password = 'enc'
conn.saved_password_discarded = False
manager = MagicMock(sid=1, password='enc')

user = MagicMock()
dec = MagicMock()
# Explicit replacement objects: patching with the default MagicMock
# would make mock inspect the flask proxies outside an app context.
with patch(MODULE + '.User', user), \
patch(MODULE + '.current_user', MagicMock()), \
patch(MODULE + '.current_app', MagicMock()), \
patch(MODULE + '.decrypt', dec):
user.query.filter_by.return_value.first.return_value = object()
if hasattr(self, 'side_effect'):
dec.side_effect = self.side_effect
else:
dec.return_value = self.return_value

if getattr(self, 'raises', False):
with self.assertRaises(TypeError):
conn._decode_password('enc', manager, None, 'key')
return

result = conn._decode_password('enc', manager, None, 'key')

if self.discarded:
self.assertEqual(result, (False, '', None, True))
self.assertIsNone(conn.password)
self.assertIsNone(manager.password)
self.assertTrue(conn.saved_password_discarded)
else:
self.assertEqual(result, (False, '', self.expected, False))
Loading