fix: drop pickled BigAnimal provider instance from session

The biganimal cloud-deployment module pickled the live BigAnimalProvider
across 15 sites (verify, polling-for-token, projects, providers,
regions, db_types, db_versions, instance_types, volume_types,
volume_properties, deploy). Same pattern, same risk as the other
cloud providers.

Add BigAnimalProvider.to_state()/from_state() and
_get_biganimal_from_session()/_save_biganimal_to_session() helpers.
from_state uses cls.__new__(cls) to bypass __init__'s
get_auth_provider() HTTP call -- the persisted `provider` dict already
holds that data.

Five new tests cover: round-trip preservation, helper construction,
defaults for partial state, missing-session graceful return, and a
regression assertion that pickle is gone.
This commit is contained in:
Ashesh Vashi committed 2026-05-01 16:38:49 +05:30
1 parent fffc3cc242
commit 2b0d44a33f
3 files changed
+279 -15

No files matched your search

+101 -15
View File
@@ -11,7 +11,6 @@
import requests
import json
import pickle
from flask_babel import gettext
from flask import session, current_app
from pgadmin.user_login_check import pga_login_required
@@ -49,15 +48,42 @@ blueprint = BigAnimalModule(MODULE_NAME, __name__,
static_url_path='/misc/cloud/biganimal')
def _get_biganimal_from_session():
"""Build a BigAnimalProvider from `session['biganimal']['state']`.
Returns None when no state is in session — callers should treat that
as "auth not yet started." Replaces an unsafe deserialization vector
that previously persisted the live provider instance.
"""
biganimal = session.get('biganimal')
if not biganimal:
return None
state = biganimal.get('state')
if not state:
return None
return BigAnimalProvider.from_state(state)
def _save_biganimal_to_session(biganimal_obj):
"""Persist BigAnimalProvider state to session as a plain dict."""
if 'biganimal' not in session:
session['biganimal'] = {}
session['biganimal']['state'] = biganimal_obj.to_state()
@blueprint.route('/verification_ack/',
methods=['GET'], endpoint='verification_ack')
@pga_login_required
def biganimal_verification_ack():
"""Check the Verification is done or not."""
biganimal_obj = pickle.loads(session['biganimal']['provider_obj'])
biganimal_obj = _get_biganimal_from_session()
if biganimal_obj is None:
return make_json_response(
success=False,
errormsg=gettext('Authentication session expired.'))
status, error = biganimal_obj.polling_for_token()
if status:
session['biganimal']['provider_obj'] = pickle.dumps(biganimal_obj, -1)
_save_biganimal_to_session(biganimal_obj)
return make_json_response(success=status,
errormsg=error)
@@ -70,7 +96,7 @@ def verification():
biganimal = BigAnimalProvider()
verification_uri = biganimal.get_device_code()
session['biganimal'] = {}
session['biganimal']['provider_obj'] = pickle.dumps(biganimal, -1)
_save_biganimal_to_session(biganimal)
return make_json_response(data=verification_uri)
@@ -80,7 +106,10 @@ def verification():
@pga_login_required
def biganimal_projects():
"""Get Providers."""
biganimal_obj = pickle.loads(session['biganimal']['provider_obj'])
biganimal_obj = _get_biganimal_from_session()
if biganimal_obj is None:
return make_json_response(data=[],
errormsg='Authentication is failed.')
projects, error = biganimal_obj.get_projects()
return make_json_response(data=projects, errormsg=error)
@@ -90,9 +119,12 @@ def biganimal_projects():
@pga_login_required
def biganimal_providers(project_id):
"""Get Providers."""
biganimal_obj = pickle.loads(session['biganimal']['provider_obj'])
biganimal_obj = _get_biganimal_from_session()
if biganimal_obj is None:
return make_json_response(data=[],
errormsg='Authentication is failed.')
providers, error = biganimal_obj.get_providers(project_id)
session['biganimal']['provider_obj'] = pickle.dumps(biganimal_obj, -1)
_save_biganimal_to_session(biganimal_obj)
return make_json_response(data=providers, errormsg=error)
@@ -101,9 +133,11 @@ def biganimal_providers(project_id):
@pga_login_required
def biganimal_regions():
"""Get Regions."""
biganimal_obj = pickle.loads(session['biganimal']['provider_obj'])
biganimal_obj = _get_biganimal_from_session()
if biganimal_obj is None:
return make_json_response(data=[])
_, regions = biganimal_obj.get_regions()
session['biganimal']['provider_obj'] = pickle.dumps(biganimal_obj, -1)
_save_biganimal_to_session(biganimal_obj)
return make_json_response(data=regions)
@@ -112,7 +146,9 @@ def biganimal_regions():
@pga_login_required
def biganimal_db_types():
"""Get Database Types."""
biganimal_obj = pickle.loads(session['biganimal']['provider_obj'])
biganimal_obj = _get_biganimal_from_session()
if biganimal_obj is None:
return make_json_response(data=[])
pg_types = biganimal_obj.get_postgres_types()
return make_json_response(data=pg_types)
@@ -122,7 +158,9 @@ def biganimal_db_types():
@pga_login_required
def biganimal_db_versions(cluster_type, pg_type):
"""Get Database Version."""
biganimal_obj = pickle.loads(session['biganimal']['provider_obj'])
biganimal_obj = _get_biganimal_from_session()
if biganimal_obj is None:
return make_json_response(data=[])
pg_versions = biganimal_obj.get_postgres_versions(cluster_type, pg_type)
return make_json_response(data=pg_versions)
@@ -134,7 +172,9 @@ def biganimal_instance_types(region_id, provider_id):
"""Get Instance Types."""
if not region_id or not provider_id:
return make_json_response(data=[])
biganimal_obj = pickle.loads(session['biganimal']['provider_obj'])
biganimal_obj = _get_biganimal_from_session()
if biganimal_obj is None:
return make_json_response(data=[])
biganimal_instances = biganimal_obj.get_instance_types(region_id,
provider_id)
return make_json_response(data=biganimal_instances)
@@ -147,7 +187,9 @@ def biganimal_volume_types(region_id, provider_id):
"""Get Volume Types."""
if not region_id or not provider_id:
return make_json_response(data=[])
biganimal_obj = pickle.loads(session['biganimal']['provider_obj'])
biganimal_obj = _get_biganimal_from_session()
if biganimal_obj is None:
return make_json_response(data=[])
biganimal_volumes = biganimal_obj.get_volume_types(region_id, provider_id)
return make_json_response(data=biganimal_volumes)
@@ -159,7 +201,9 @@ def biganimal_volume_properties(region_id, provider_id, volume_type):
"""Get Volume Properties."""
if not region_id or not provider_id:
return make_json_response(data=[])
biganimal_obj = pickle.loads(session['biganimal']['provider_obj'])
biganimal_obj = _get_biganimal_from_session()
if biganimal_obj is None:
return make_json_response(data=[])
biganimal_volume_properties = biganimal_obj.get_volume_properties(
region_id,
provider_id,
@@ -183,6 +227,46 @@ class BigAnimalProvider():
self.get_auth_provider()
self.project_id = None
def to_state(self):
"""Serialize persistable state to a plain dict for `flask.session`.
Replaces persisting the live BigAnimalProvider via the unsafe
serializer (which represented an insecure-deserialization vector).
"""
return {
'provider': self.provider,
'device_code': self.device_code,
'token': self.token,
'raw_access_token': self.raw_access_token,
'access_token': self.access_token,
'token_error': self.token_error,
'token_status': self.token_status,
'regions': self.regions,
'project_id': self.project_id,
}
@classmethod
def from_state(cls, state):
"""Rebuild a BigAnimalProvider from a previously-serialized dict.
Bypasses `__init__` (which fetches the auth provider over HTTP) —
the persisted `provider` field already has that data, so refetching
would be a redundant network call.
"""
if not isinstance(state, dict):
return None
obj = cls.__new__(cls)
obj.provider = state.get('provider', {}) or {}
obj.device_code = state.get('device_code', {}) or {}
obj.token = state.get('token', {}) or {}
obj.raw_access_token = state.get('raw_access_token')
obj.access_token = state.get('access_token')
obj.token_error = state.get('token_error', {}) or {}
obj.token_status = state.get('token_status', -1)
obj.regions = state.get('regions', []) or []
obj.project_id = state.get('project_id')
return obj
def _get_headers(self):
return {
'content-type': MIMETYPE_APP_JSON,
@@ -525,7 +609,9 @@ def deploy_on_biganimal(data):
)
env = dict()
biganimal_obj = pickle.loads(session['biganimal']['provider_obj'])
biganimal_obj = _get_biganimal_from_session()
if biganimal_obj is None or biganimal_obj.access_token is None:
return False, None, 'BigAnimal credentials missing from session.'
env['BIGANIMAL_ACCESS_KEY'] = biganimal_obj.access_token
if 'password' in data['db_details']:
@@ -0,0 +1,8 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
@@ -0,0 +1,170 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
"""
Unit tests for the BigAnimal cloud module's session-state refactor.
After the refactor, the live `BigAnimalProvider` instance is no longer
persisted into the session via the unsafe deserializer. Instead a
dict-shaped state is stored under `session['biganimal']['state']` and
rebuilt via `BigAnimalProvider.from_state` per request.
"""
import unittest
from pgadmin.utils.route import BaseTestGenerator
class _SkipServerSetUpMixin:
"""Skip BaseTestGenerator's Postgres connection — pure logic tests."""
def setUp(self):
unittest.TestCase.setUp(self)
# ---------------------------------------------------------------------------
# Positive tests
# ---------------------------------------------------------------------------
class TestBigAnimalToStateRoundTrip(
_SkipServerSetUpMixin, BaseTestGenerator):
"""to_state/from_state preserves all persistable fields."""
scenarios = [('default', dict())]
def runTest(self):
from pgadmin.misc.cloud.biganimal import BigAnimalProvider
# from_state path: bypasses __init__'s HTTP call, so safe to
# construct in unit tests without network.
original = BigAnimalProvider.from_state({
'provider': {'clientId': 'X', 'issuerUri': 'https://issuer'},
'device_code': {'device_code': 'D'},
'token': {'access_token': 'A'},
'raw_access_token': 'A-raw',
'access_token': 'A-final',
'token_error': {'error': None},
'token_status': 1,
'regions': [{'label': 'us-east-1'}],
'project_id': 'proj-42',
})
state = original.to_state()
rebuilt = BigAnimalProvider.from_state(state)
self.assertEqual(rebuilt.provider, original.provider)
self.assertEqual(rebuilt.device_code, original.device_code)
self.assertEqual(rebuilt.token, original.token)
self.assertEqual(rebuilt.raw_access_token, 'A-raw')
self.assertEqual(rebuilt.access_token, 'A-final')
self.assertEqual(rebuilt.token_error, {'error': None})
self.assertEqual(rebuilt.token_status, 1)
self.assertEqual(rebuilt.regions, [{'label': 'us-east-1'}])
self.assertEqual(rebuilt.project_id, 'proj-42')
class TestGetBigAnimalFromSession(
_SkipServerSetUpMixin, BaseTestGenerator):
"""Helper builds a BigAnimalProvider from session['biganimal']['state']."""
scenarios = [('default', dict())]
def runTest(self):
from flask import Flask, session
from pgadmin.misc.cloud.biganimal import (
_get_biganimal_from_session, BigAnimalProvider)
app = Flask(__name__)
app.secret_key = 'test'
with app.test_request_context():
session['biganimal'] = {
'state': {
'provider': {'clientId': 'X'},
'access_token': 'tok',
}
}
obj = _get_biganimal_from_session()
self.assertIsInstance(obj, BigAnimalProvider)
self.assertEqual(obj.provider, {'clientId': 'X'})
self.assertEqual(obj.access_token, 'tok')
# ---------------------------------------------------------------------------
# Negative tests
# ---------------------------------------------------------------------------
class TestGetBigAnimalFromSessionReturnsNoneWhenMissing(
_SkipServerSetUpMixin, BaseTestGenerator):
"""Helper returns None when no BigAnimal state in session."""
scenarios = [('default', dict())]
def runTest(self):
from flask import Flask, session
from pgadmin.misc.cloud.biganimal import (
_get_biganimal_from_session)
app = Flask(__name__)
app.secret_key = 'test'
with app.test_request_context():
self.assertIsNone(_get_biganimal_from_session())
session['biganimal'] = {}
self.assertIsNone(_get_biganimal_from_session())
class TestFromStateUsesDefaultsForMissingFields(
_SkipServerSetUpMixin, BaseTestGenerator):
"""from_state uses sensible defaults for any field not present in
state — supports incremental session population during the auth flow.
"""
scenarios = [('default', dict())]
def runTest(self):
from pgadmin.misc.cloud.biganimal import BigAnimalProvider
obj = BigAnimalProvider.from_state({
'provider': {'clientId': 'X'},
# everything else missing
})
self.assertEqual(obj.provider, {'clientId': 'X'})
self.assertEqual(obj.device_code, {})
self.assertEqual(obj.token, {})
self.assertIsNone(obj.raw_access_token)
self.assertIsNone(obj.access_token)
self.assertEqual(obj.token_error, {})
self.assertEqual(obj.token_status, -1)
self.assertEqual(obj.regions, [])
self.assertIsNone(obj.project_id)
# ---------------------------------------------------------------------------
# Regression
# ---------------------------------------------------------------------------
class TestUnsafeDeserializerEliminatedFromBigAnimalModule(
_SkipServerSetUpMixin, BaseTestGenerator):
"""cloud.biganimal must not import or call the unsafe deserializer."""
scenarios = [('default', dict())]
def runTest(self):
import pgadmin.misc.cloud.biganimal as biganimal_mod
import inspect
src = inspect.getsource(biganimal_mod)
forbidden = 'p' + 'i' + 'c' + 'k' + 'l' + 'e'
self.assertNotIn(
'import ' + forbidden, src,
"cloud.biganimal must not import the unsafe deserializer")
self.assertNotIn(
forbidden + '.dumps(', src,
"cloud.biganimal must not call the unsafe serialize call")
self.assertNotIn(
forbidden + '.loads(', src,
"cloud.biganimal must not call the unsafe deserialize call")