mirror of
https://github.com/pgadmin-org/pgadmin4.git
synced 2026-11-01 15:53:03 -06:00
Removes the EDB BigAnimal cloud-deployment integration from the Cloud Wizard. AWS RDS, Azure Database, and Google Cloud SQL remain as the supported deployment targets. Covers the backend blueprint and pgacloud provider, the frontend wizard steps/components/constants/icons, the documentation page and screenshots, and stray BigAnimal references in docstrings. The BigAnimal-only "Cluster Type" wizard step is removed and the remaining steps renumber from 0-5 to 0-4. As a side effect, the Azure cluster-name availability check (previously dead code at activeStep == 2, skipped past by the now-removed step) is now exercised. Two latent bugs in that check are fixed at the same time: it inspected res.data.success although checkClusternameAvailbility already resolves res.data, so a name collision was never reported; and it passed an Error object to gettext(), which cannot render it. It now uses res.success and surfaces error.message with a readable fallback. A 9.16 Housekeeping release-note entry is added documenting the removal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
568 lines
20 KiB
Python
568 lines
20 KiB
Python
##########################################################################
|
|
#
|
|
# pgAdmin 4 - PostgreSQL Tools
|
|
#
|
|
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
|
|
# This software is released under the PostgreSQL Licence
|
|
#
|
|
##########################################################################
|
|
|
|
"""
|
|
Implements the server-side session management.
|
|
|
|
Credit/Reference: http://flask.pocoo.org/snippets/109/
|
|
|
|
Modified to support both Python 2.6+ & Python 3.x
|
|
"""
|
|
|
|
import base64
|
|
import datetime
|
|
import hmac
|
|
import hashlib
|
|
import logging
|
|
import os
|
|
import pickle
|
|
import secrets
|
|
import string
|
|
import time
|
|
import config
|
|
from uuid import uuid4
|
|
from threading import Lock
|
|
from flask import current_app, request, flash, redirect, has_request_context
|
|
from flask_login import login_url
|
|
|
|
from collections import OrderedDict
|
|
from cryptography.fernet import Fernet, InvalidToken
|
|
from cryptography.hazmat.primitives import hashes
|
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
from itsdangerous import signer
|
|
|
|
from flask.sessions import SessionInterface, SessionMixin
|
|
from werkzeug.datastructures import CallbackDict
|
|
from werkzeug.security import safe_join
|
|
from werkzeug.exceptions import InternalServerError
|
|
|
|
from pgadmin.utils.ajax import make_json_response
|
|
|
|
|
|
def _calc_hmac(body, secret):
|
|
return base64.b64encode(
|
|
hmac.new(
|
|
secret.encode(), body.encode(), hashlib.sha256
|
|
).digest()
|
|
).decode()
|
|
|
|
|
|
# File-HMAC: protects on-disk session files against tampering before
|
|
# deserialization. SHA-256 is hard-coded (not driven by SESSION_DIGEST_METHOD)
|
|
# so the header length is a fixed, format-stable constant.
|
|
_FILE_HMAC = hashlib.sha256
|
|
_HMAC_HEX_LEN = _FILE_HMAC().digest_size * 2 # 64 for SHA-256
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _compute_file_hmac(secret, body):
|
|
"""Compute the hex-encoded HMAC over a session-file body.
|
|
|
|
Flask's SECRET_KEY may be str or bytes; both are accepted.
|
|
"""
|
|
key = secret.encode() if isinstance(secret, str) else secret
|
|
return hmac.new(key, body, _FILE_HMAC).hexdigest().encode()
|
|
|
|
|
|
# HKDF salt + info for the session-body Fernet key. Both fixed and version-
|
|
# tagged so a future format change can derive a fresh key without reusing
|
|
# bytes that may already be in flight on disk.
|
|
_SESSION_FERNET_SALT = b'pga-session-body-v1'
|
|
_SESSION_FERNET_INFO = b'pgadmin session body encryption v1'
|
|
|
|
|
|
def _derive_session_fernet(secret):
|
|
"""Derive a Fernet instance from `SECRET_KEY` for session-body
|
|
confidentiality.
|
|
|
|
Layer 1 of the on-disk session protection. The HMAC header (added in
|
|
PR 1) protects integrity of the on-disk bytes; this Fernet layer
|
|
additionally protects confidentiality so that a leak of `sessions/`
|
|
files alone (without the SECRET_KEY in `pgadmin4.db`) does not
|
|
expose OAuth tokens, cloud credentials, MFA OTPs, `pass_enc_key`
|
|
(the KEK that decrypts saved server passwords), or any other
|
|
sensitive material the application places in the session.
|
|
|
|
Caveat (real, not theoretical): SECRET_KEY currently lives in
|
|
`pgadmin4.db` in the same DATA_DIR. A leak that includes BOTH
|
|
`sessions/` and `pgadmin4.db` recovers the derived Fernet key and
|
|
can decrypt session bodies. Closing this gap requires moving
|
|
SECRET_KEY out of DATA_DIR (e.g., into the OS keychain via
|
|
`USE_OS_SECRET_STORAGE`); tracked as Layer 2 follow-up.
|
|
|
|
HKDF with a fixed salt and info string is used so the derivation is
|
|
deterministic across processes (multiple workers must produce the
|
|
same key) and so a future format change can swap in a new
|
|
salt/info without compromising existing files.
|
|
"""
|
|
key_material = secret.encode() if isinstance(secret, str) else secret
|
|
derived = HKDF(
|
|
algorithm=hashes.SHA256(),
|
|
length=32,
|
|
salt=_SESSION_FERNET_SALT,
|
|
info=_SESSION_FERNET_INFO,
|
|
).derive(key_material)
|
|
return Fernet(base64.urlsafe_b64encode(derived))
|
|
|
|
|
|
def _open_session_file(path):
|
|
"""Open a session file for writing with mode 0o600 (owner-only).
|
|
|
|
Session files contain OAuth access/refresh tokens, AWS/Google/Azure
|
|
cloud credentials, the Kerberos cache path, MFA OTP material,
|
|
and `pass_enc_key` (the symmetric KEK used to decrypt the user's saved
|
|
Postgres server passwords). The default `open(path, 'wb')` uses the
|
|
process umask, which on most systems leaves files world-readable
|
|
(0o644). Force 0o600 so that even if the directory permissions are
|
|
misconfigured (e.g., a volume mount in a container with shared uids),
|
|
individual session files remain owner-only.
|
|
"""
|
|
fd = os.open(
|
|
path,
|
|
os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
|
|
0o600,
|
|
)
|
|
return os.fdopen(fd, 'wb')
|
|
|
|
|
|
sess_lock = Lock()
|
|
LAST_CHECK_SESSION_FILES = None
|
|
|
|
|
|
class ManagedSession(CallbackDict, SessionMixin):
|
|
def __init__(self, initial=None, sid=None, new=False, randval=None,
|
|
hmac_digest=None):
|
|
def on_update(self):
|
|
self.modified = True
|
|
|
|
CallbackDict.__init__(self, initial, on_update)
|
|
self.sid = sid
|
|
self.new = new
|
|
self.modified = False
|
|
self.randval = randval
|
|
self.last_write = None
|
|
self.force_write = False
|
|
self.hmac_digest = hmac_digest
|
|
self.permanent = True
|
|
|
|
def sign(self, secret):
|
|
if not self.hmac_digest:
|
|
population = string.ascii_lowercase + string.digits
|
|
|
|
self.randval = ''.join(
|
|
secrets.choice(population) for i in range(20))
|
|
self.hmac_digest = _calc_hmac(
|
|
'%s:%s' % (self.sid, self.randval), secret)
|
|
|
|
|
|
class SessionManager():
|
|
def new_session(self):
|
|
'Create a new session'
|
|
raise NotImplementedError
|
|
|
|
def exists(self, sid):
|
|
'Does the given session-id exist?'
|
|
raise NotImplementedError
|
|
|
|
def remove(self, sid):
|
|
'Remove the session'
|
|
raise NotImplementedError
|
|
|
|
def get(self, sid, digest):
|
|
'Retrieve a managed session by session-id, checking the HMAC digest'
|
|
raise NotImplementedError
|
|
|
|
def put(self, session):
|
|
'Store a managed session'
|
|
raise NotImplementedError
|
|
|
|
|
|
class CachingSessionManager(SessionManager):
|
|
def __init__(self, parent, num_to_store, skip_paths=None):
|
|
self.parent = parent
|
|
self.num_to_store = num_to_store
|
|
self._cache = OrderedDict()
|
|
self.skip_paths = [] if skip_paths is None else skip_paths
|
|
|
|
def _normalize(self):
|
|
if len(self._cache) > self.num_to_store:
|
|
# Flush 20% of the cache
|
|
with sess_lock:
|
|
while len(self._cache) > (self.num_to_store * 0.8):
|
|
self._cache.popitem(False)
|
|
|
|
def is_session_ready(self, _session):
|
|
if not has_request_context() or _session is None:
|
|
return False
|
|
|
|
# Session _id returns the str object
|
|
# or None if it hasn't been set yet.
|
|
try:
|
|
return _session['_id'] is not None
|
|
except (AssertionError, RuntimeError, KeyError, TypeError):
|
|
return False
|
|
|
|
def new_session(self):
|
|
session = self.parent.new_session()
|
|
|
|
# Do not store the session if skip paths
|
|
for sp in self.skip_paths:
|
|
if request.path.startswith(sp):
|
|
return session
|
|
|
|
with sess_lock:
|
|
self._cache[session.sid] = session
|
|
self._normalize()
|
|
|
|
return session
|
|
|
|
def remove(self, sid):
|
|
with sess_lock:
|
|
self.parent.remove(sid)
|
|
if sid in self._cache:
|
|
del self._cache[sid]
|
|
|
|
def exists(self, sid):
|
|
with sess_lock:
|
|
if sid in self._cache:
|
|
return True
|
|
return self.parent.exists(sid)
|
|
|
|
def get(self, sid, digest):
|
|
session = None
|
|
with (sess_lock):
|
|
if sid in self._cache:
|
|
session = self._cache[sid]
|
|
if self.is_session_ready(session) and\
|
|
session.hmac_digest != digest:
|
|
session = None
|
|
|
|
# reset order in Dict
|
|
del self._cache[sid]
|
|
|
|
if not self.is_session_ready(session):
|
|
session = self.parent.get(sid, digest)
|
|
|
|
# Do not store the session if skip paths
|
|
for sp in self.skip_paths:
|
|
if request.path.startswith(sp):
|
|
return session
|
|
|
|
self._cache[sid] = session
|
|
self._normalize()
|
|
|
|
return session
|
|
|
|
def put(self, session):
|
|
with sess_lock:
|
|
self.parent.put(session)
|
|
|
|
# Do not store the session if skip paths
|
|
for sp in self.skip_paths:
|
|
if request.path.startswith(sp):
|
|
return
|
|
|
|
if session.sid in self._cache:
|
|
try:
|
|
del self._cache[session.sid]
|
|
except Exception:
|
|
pass
|
|
|
|
self._cache[session.sid] = session
|
|
self._normalize()
|
|
|
|
|
|
class FileBackedSessionManager(SessionManager):
|
|
|
|
def __init__(self, path, secret, disk_write_delay, skip_paths=None):
|
|
if not secret:
|
|
# File-HMAC integrity collapses without a secret. Use raise, not
|
|
# assert: -O strips assertions in production.
|
|
raise RuntimeError("SECRET_KEY must be non-empty")
|
|
self.path = path
|
|
self.secret = secret
|
|
self.disk_write_delay = disk_write_delay
|
|
if not os.path.exists(self.path):
|
|
os.makedirs(self.path)
|
|
self.skip_paths = [] if skip_paths is None else skip_paths
|
|
|
|
def exists(self, sid):
|
|
fname = safe_join(self.path, sid)
|
|
return fname is not None and os.path.exists(fname)
|
|
|
|
def remove(self, sid):
|
|
fname = safe_join(self.path, sid)
|
|
if fname is not None and os.path.exists(fname):
|
|
os.unlink(fname)
|
|
|
|
def new_session(self):
|
|
sid = str(uuid4())
|
|
fname = safe_join(self.path, sid)
|
|
|
|
while fname is not None and os.path.exists(fname):
|
|
sid = str(uuid4())
|
|
fname = safe_join(self.path, sid)
|
|
|
|
# Do not store the session if skip paths
|
|
for sp in self.skip_paths:
|
|
if request.path.startswith(sp):
|
|
return ManagedSession(sid=sid)
|
|
|
|
if fname is None:
|
|
raise InternalServerError('Failed to create new session')
|
|
|
|
# touch the file with mode 0o600 — see _open_session_file rationale.
|
|
with _open_session_file(fname):
|
|
return ManagedSession(sid=sid)
|
|
|
|
return ManagedSession(sid=sid)
|
|
|
|
def get(self, sid, digest):
|
|
"""Retrieve a managed session by session-id, verifying integrity
|
|
and decrypting the body.
|
|
|
|
File format:
|
|
+-------------------------------------------------------+
|
|
| _HMAC_HEX_LEN bytes : hex HMAC over the ciphertext |
|
|
+-------------------------------------------------------+
|
|
| N bytes : Fernet(pickle((randval, digest, data))) |
|
|
+-------------------------------------------------------+
|
|
|
|
The HMAC is verified against the ciphertext BEFORE Fernet decrypt
|
|
and BEFORE pickle.loads, so a malicious file dropped in the
|
|
sessions directory cannot trigger arbitrary code execution via
|
|
__reduce__ during deserialization (encrypt-then-MAC; pickle.loads
|
|
only ever sees Fernet-decrypted plaintext from a server-written
|
|
file).
|
|
"""
|
|
fname = safe_join(self.path, sid)
|
|
if fname is None or not os.path.exists(fname):
|
|
return self.new_session()
|
|
|
|
try:
|
|
with open(fname, 'rb') as f:
|
|
header = f.read(_HMAC_HEX_LEN)
|
|
body = f.read()
|
|
if len(header) != _HMAC_HEX_LEN or len(body) == 0:
|
|
# 0-byte placeholder from new_session(), or pre-fix legacy
|
|
# file shorter than the header. Silently invalidate; one-time
|
|
# re-login on upgrade is acceptable and expected.
|
|
return self.new_session()
|
|
expected = _compute_file_hmac(self.secret, body)
|
|
if not hmac.compare_digest(header, expected):
|
|
logger.warning(
|
|
"session file rejected: bad file-HMAC for sid=%s",
|
|
sid[:8] if sid else '<empty>',
|
|
)
|
|
return self.new_session()
|
|
# Body integrity verified — decrypt before deserializing.
|
|
try:
|
|
plaintext = _derive_session_fernet(
|
|
self.secret).decrypt(body)
|
|
except InvalidToken:
|
|
# Pre-Layer-1 (HMAC-only) session files: HMAC is still a
|
|
# valid HMAC over the legacy plain-pickle body, but
|
|
# Fernet.decrypt on raw pickle bytes raises InvalidToken.
|
|
# Silently invalidate; users see a one-time re-login on
|
|
# the upgrade that introduced this layer.
|
|
logger.warning(
|
|
"session file rejected: legacy unencrypted body for "
|
|
"sid=%s", sid[:8] if sid else '<empty>',
|
|
)
|
|
return self.new_session()
|
|
randval, hmac_digest, data = pickle.loads(plaintext)
|
|
except (pickle.UnpicklingError, EOFError, OSError, MemoryError,
|
|
ValueError, TypeError):
|
|
# Narrow catch: silently invalidate corrupted/unreadable files.
|
|
# Programming errors (AttributeError, NameError, etc.) propagate
|
|
# so they surface in tests rather than being masked.
|
|
return self.new_session()
|
|
|
|
if not data:
|
|
return self.new_session()
|
|
|
|
# Cookie-binding check: the cookie's digest must match the file's
|
|
# stored digest. Prevents using one session's file with another's
|
|
# cookie even when both are server-written.
|
|
if hmac_digest != digest:
|
|
return self.new_session()
|
|
|
|
return ManagedSession(
|
|
data, sid=sid, randval=randval, hmac_digest=hmac_digest
|
|
)
|
|
|
|
def put(self, session):
|
|
"""Store a managed session"""
|
|
current_time = time.time()
|
|
if not session.hmac_digest:
|
|
session.sign(self.secret)
|
|
elif not session.force_write and session.last_write is not None and \
|
|
(current_time - float(session.last_write)) < \
|
|
self.disk_write_delay:
|
|
return
|
|
|
|
session.last_write = current_time
|
|
session.force_write = False
|
|
|
|
# Do not store the session if skip paths
|
|
for sp in self.skip_paths:
|
|
if request.path.startswith(sp):
|
|
return
|
|
|
|
fname = safe_join(self.path, session.sid)
|
|
|
|
if fname is None:
|
|
raise InternalServerError('Failed to update the session')
|
|
|
|
plaintext = pickle.dumps(
|
|
(session.randval, session.hmac_digest, dict(session)), -1
|
|
)
|
|
# Encrypt-then-MAC: Fernet wrap first, then HMAC over the
|
|
# ciphertext. pickle.loads on a tampered or unauthenticated body
|
|
# is therefore unreachable on the read path.
|
|
body = _derive_session_fernet(self.secret).encrypt(plaintext)
|
|
header = _compute_file_hmac(self.secret, body)
|
|
with _open_session_file(fname) as f:
|
|
f.write(header)
|
|
f.write(body)
|
|
|
|
|
|
class ManagedSessionInterface(SessionInterface):
|
|
def __init__(self, manager):
|
|
self.manager = manager
|
|
signer.Signer.default_digest_method = \
|
|
eval(config.SESSION_DIGEST_METHOD)
|
|
|
|
def open_session(self, app, request):
|
|
cookie_val = request.cookies.get(app.config['SESSION_COOKIE_NAME'])
|
|
|
|
if not cookie_val or '!' not in cookie_val:
|
|
return self.manager.new_session()
|
|
|
|
sid, digest = cookie_val.split('!', 1)
|
|
|
|
if self.manager.exists(sid):
|
|
return self.manager.get(sid, digest)
|
|
|
|
return self.manager.new_session()
|
|
|
|
def save_session(self, app, session, response):
|
|
domain = self.get_cookie_domain(app)
|
|
if not session:
|
|
self.manager.remove(session.sid)
|
|
if session.modified:
|
|
response.delete_cookie(app.config['SESSION_COOKIE_NAME'],
|
|
domain=domain)
|
|
return
|
|
|
|
if not session.modified:
|
|
# No need to save an unaltered session
|
|
# TODO: put logic here to test if the cookie is older than N days,
|
|
# if so, update the expiration date
|
|
return
|
|
|
|
self.manager.put(session)
|
|
session.modified = False
|
|
|
|
cookie_exp = self.get_expiration_time(app, session)
|
|
response.set_cookie(
|
|
app.config['SESSION_COOKIE_NAME'],
|
|
'%s!%s' % (session.sid, session.hmac_digest),
|
|
expires=cookie_exp,
|
|
path=config.SESSION_COOKIE_PATH,
|
|
secure=config.SESSION_COOKIE_SECURE,
|
|
httponly=config.SESSION_COOKIE_HTTPONLY,
|
|
samesite=config.SESSION_COOKIE_SAMESITE,
|
|
domain=domain
|
|
)
|
|
|
|
|
|
def create_session_interface(app, skip_paths=[]):
|
|
return ManagedSessionInterface(
|
|
CachingSessionManager(
|
|
FileBackedSessionManager(
|
|
app.config['SESSION_DB_PATH'],
|
|
app.config['SECRET_KEY'],
|
|
app.config.get('PGADMIN_SESSION_DISK_WRITE_DELAY', 10),
|
|
skip_paths
|
|
),
|
|
1000,
|
|
skip_paths
|
|
))
|
|
|
|
|
|
def pga_unauthorised():
|
|
|
|
lm = current_app.login_manager
|
|
login_message = None
|
|
|
|
if lm.login_message:
|
|
if lm.localize_callback is not None:
|
|
login_message = lm.localize_callback(lm.login_message)
|
|
else:
|
|
login_message = lm.login_message
|
|
|
|
if not lm.login_view:
|
|
# Only 401 is not enough to distinguish pgAdmin login is required.
|
|
# There are other cases when we return 401. For eg. wrong password
|
|
# supplied while connecting to server.
|
|
# So send additional 'info' message.
|
|
return make_json_response(
|
|
status=401,
|
|
success=0,
|
|
errormsg=login_message,
|
|
info='PGADMIN_LOGIN_REQUIRED'
|
|
)
|
|
|
|
# flash messages are only required if the request was from a
|
|
# security page, otherwise it will be redirected to login page
|
|
# anyway
|
|
if login_message and 'security' in request.endpoint:
|
|
flash(login_message, category=lm.login_message_category)
|
|
|
|
return redirect(login_url(lm.login_view, request.url))
|
|
|
|
|
|
def cleanup_session_files():
|
|
"""
|
|
This function will iterate through session directory and check the last
|
|
modified time, if it older than (session expiration time + 1) days then
|
|
delete that file.
|
|
"""
|
|
iterate_session_files = False
|
|
|
|
global LAST_CHECK_SESSION_FILES
|
|
if LAST_CHECK_SESSION_FILES is None or \
|
|
datetime.datetime.now() >= LAST_CHECK_SESSION_FILES + \
|
|
datetime.timedelta(hours=config.CHECK_SESSION_FILES_INTERVAL):
|
|
iterate_session_files = True
|
|
LAST_CHECK_SESSION_FILES = datetime.datetime.now()
|
|
|
|
if iterate_session_files:
|
|
for root, dirs, files in os.walk(
|
|
current_app.config['SESSION_DB_PATH']):
|
|
for file_name in files:
|
|
absolute_file_name = os.path.join(root, file_name)
|
|
st = os.stat(absolute_file_name)
|
|
|
|
# Get the last modified time of the session file
|
|
last_modified_time = \
|
|
datetime.datetime.fromtimestamp(st.st_mtime)
|
|
|
|
# Calculate session file expiry time.
|
|
file_expiration_time = \
|
|
last_modified_time + \
|
|
current_app.permanent_session_lifetime + \
|
|
datetime.timedelta(days=1)
|
|
|
|
if file_expiration_time <= datetime.datetime.now() and \
|
|
os.path.exists(absolute_file_name):
|
|
os.unlink(absolute_file_name)
|