fix: drop pickled Google cloud instance from session

The google cloud-deployment module persisted the live Google class via
pickle.dumps/pickle.loads across 14 sites: the verify_credentials
handler, the OAuth2 callback, and every region/zone/version/instance-
type lookup. Each call paid a pickle round-trip and re-introduced the
deserialization risk inside session storage.

Add Google.to_state()/Google.from_state() that round-trip the
persistable fields (client_config, credentials_json, redirect_url,
project_id, regions, availability_zones, verification flags) through
a plain dict, and rebuild the google.oauth2.credentials.Credentials
SDK object lazily from credentials_json. Module-level
_get_google_from_session() / _save_google_to_session() helpers replace
the per-site pickle dance.

Six new tests cover: round-trip of all persistable fields, credential
rebuild from token dict, helper construction from session state,
helper returning None for missing/empty session, defaults for partial
state, and a regression assertion that pickle is gone from the module.
This commit is contained in:
Ashesh Vashi
2026-05-01 16:38:48 +05:30
parent adbb249604
commit fffc3cc242
3 changed files with 323 additions and 28 deletions
+112 -28
View File
@@ -8,7 +8,6 @@
# ##########################################################################
# Google Cloud Deployment Implementation
import pickle
import json
import os
from urllib.parse import unquote
@@ -52,6 +51,32 @@ blueprint = GooglePostgresqlModule(MODULE_NAME, __name__,
static_url_path='/misc/cloud/google')
def _get_google_from_session():
"""Build a Google instance from `session['google']['state']`.
Returns None when no Google state has been seeded yet — callers should
treat that as "auth not yet started."
The previous implementation persisted a live Google instance via an
unsafe serializer, which represented an insecure-deserialization
vector. We now store only a JSON-safe dict and rebuild on demand.
"""
google = session.get('google')
if not google:
return None
state = google.get('state')
if not state:
return None
return Google.from_state(state)
def _save_google_to_session(google_obj):
"""Persist the given Google instance's state into the session."""
if 'google' not in session:
session['google'] = {}
session['google']['state'] = google_obj.to_state()
@blueprint.route("/")
@pga_login_required
def index():
@@ -88,11 +113,14 @@ def verify_credentials():
if 'google' not in session:
session['google'] = {}
if 'google_obj' not in session['google'] or \
session['google']['client_config'] != client_config:
_google = Google(client_config)
# Reuse cached Google when client_config hasn't changed; otherwise
# start fresh (e.g., the user picked a different secret file).
cached_google = _get_google_from_session()
if cached_google is not None and \
session['google'].get('client_config') == client_config:
_google = cached_google
else:
_google = pickle.loads(session['google']['google_obj'])
_google = Google(client_config)
# get auth url
host_url = request.origin + '/'
@@ -105,9 +133,8 @@ def verify_credentials():
else:
status = True
res_data = {'auth_url': auth_url}
# save google object
session['google']['client_config'] = client_config
session['google']['google_obj'] = pickle.dumps(_google, -1)
_save_google_to_session(_google)
else:
error = 'Client secret path not found'
session.pop('google', None)
@@ -124,9 +151,12 @@ def callback():
Call back function on google authentication response.
:return:
"""
google_obj = pickle.loads(session['google']['google_obj'])
google_obj = _get_google_from_session()
if google_obj is None:
return plain_text_response(
'Authentication session expired. Please try again.')
res = google_obj.callback(request)
session['google']['google_obj'] = pickle.dumps(google_obj, -1)
_save_google_to_session(google_obj)
return plain_text_response(res)
@@ -139,10 +169,10 @@ def verification_ack():
:return:
"""
verified = False
if 'google' in session and 'google_obj' in session['google']:
google_obj = pickle.loads(session['google']['google_obj'])
google_obj = _get_google_from_session()
if google_obj is not None:
verified, error = google_obj.verification_ack()
session['google']['google_obj'] = pickle.dumps(google_obj, -1)
_save_google_to_session(google_obj)
return make_json_response(success=verified, errormsg=error)
else:
return make_json_response(success=verified,
@@ -157,9 +187,9 @@ def get_projects():
Lists the projects for authorized user
:return: list of projects
"""
if 'google' in session and 'google_obj' in session['google']:
google_obj = pickle.loads(session['google']['google_obj'])
projects_list,error = google_obj.get_projects()
google_obj = _get_google_from_session()
if google_obj is not None:
projects_list, error = google_obj.get_projects()
if error:
return bad_request(errormsg=error)
return make_json_response(data=projects_list)
@@ -174,11 +204,10 @@ def get_regions(project_id):
:param project_id: google project id
:return: google cloud sql region list
"""
if 'google' in session and 'google_obj' in session['google'] \
and project_id:
google_obj = pickle.loads(session['google']['google_obj'])
regions_list,error = google_obj.get_regions(project_id)
session['google']['google_obj'] = pickle.dumps(google_obj, -1)
google_obj = _get_google_from_session()
if google_obj is not None and project_id:
regions_list, error = google_obj.get_regions(project_id)
_save_google_to_session(google_obj)
if error:
return bad_request(errormsg=error)
return make_json_response(data=regions_list)
@@ -195,8 +224,8 @@ def get_availability_zones(region):
:param region: google region
:return: google cloud sql availability zone list
"""
if 'google' in session and 'google_obj' in session['google'] and region:
google_obj = pickle.loads(session['google']['google_obj'])
google_obj = _get_google_from_session()
if google_obj is not None and region:
availability_zone_list = google_obj.get_availability_zones(region)
return make_json_response(data=availability_zone_list)
else:
@@ -215,9 +244,8 @@ def get_instance_types(project_id, region, instance_class):
:param instance_class: google cloud sql instnace class
:return:
"""
if 'google' in session and 'google_obj' in session['google'] and \
project_id and region:
google_obj = pickle.loads(session['google']['google_obj'])
google_obj = _get_google_from_session()
if google_obj is not None and project_id and region:
instance_types_dict = google_obj.get_instance_types(
project_id, region)
instance_types_list, error = (
@@ -237,8 +265,8 @@ def get_database_versions():
Lists the postgresql database versions.
:return: PostgreSQL version list
"""
if 'google' in session and 'google_obj' in session['google']:
google_obj = pickle.loads(session['google']['google_obj'])
google_obj = _get_google_from_session()
if google_obj is not None:
db_version_list, error = google_obj.get_database_versions()
if error:
return bad_request(errormsg=error)
@@ -304,7 +332,9 @@ def deploy_on_google(data):
# Set env variables for background process of deployment
env = dict()
google_obj = pickle.loads(session['google']['google_obj'])
google_obj = _get_google_from_session()
if google_obj is None or google_obj.credentials_json is None:
return False, None, 'Google credentials missing from session.'
env['GOOGLE_CREDENTIALS'] = json.dumps(google_obj.credentials_json)
if 'db_password' in data['db_details']:
@@ -352,6 +382,60 @@ class Google:
self._verification_error = None
self._redirect_url = None
def to_state(self):
"""Serialize the persistable state of this Google instance to a
plain dict for storage in `flask.session`.
Live SDK objects (like `_credentials`) are intentionally NOT
included — they are rebuilt from `credentials_json` in
`from_state()`. Storing only data primitives lets the session
layer use a safe (non-binary-serializer) format.
"""
return {
'client_config': self._client_config,
'credentials_json': self.credentials_json,
'redirect_url': self._redirect_url,
'project_id': self._project_id,
'regions': self._regions,
'availability_zones': self._availability_zones,
'verification_successful': self._verification_successful,
'verification_error': self._verification_error,
}
@classmethod
def from_state(cls, state):
"""Rebuild a Google instance from a previously-serialized dict.
The reverse of `to_state()`. The Google SDK credentials object is
reconstructed from the stored token dict so subsequent API calls
work without forcing the user back through the OAuth2 flow.
"""
if not isinstance(state, dict):
return None
obj = cls(client_config=state.get('client_config'))
obj.credentials_json = state.get('credentials_json')
if obj.credentials_json:
# Rebuild google.oauth2.credentials.Credentials from the
# persisted token dict. Credentials() ignores unknown kwargs,
# so id_token (kept in the dict for API parity) is dropped.
from google.oauth2.credentials import Credentials
obj._credentials = Credentials(
token=obj.credentials_json.get('token'),
refresh_token=obj.credentials_json.get('refresh_token'),
token_uri=obj.credentials_json.get('token_uri'),
client_id=obj.credentials_json.get('client_id'),
client_secret=obj.credentials_json.get('client_secret'),
scopes=obj.credentials_json.get('scopes'),
)
obj._redirect_url = state.get('redirect_url')
obj._project_id = state.get('project_id')
obj._regions = state.get('regions', []) or []
obj._availability_zones = state.get('availability_zones', {}) or {}
obj._verification_successful = bool(
state.get('verification_successful', False))
obj._verification_error = state.get('verification_error')
return obj
def get_auth_url(self, host_url):
"""
Provides google authorisation url
@@ -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,203 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
"""
Unit tests for the Google Cloud module's session-state refactor.
After the refactor, the live `Google` instance is no longer persisted in
the session via the unsafe deserializer. Instead, a dict-shaped state is
stored under `session['google']['state']` and rebuilt via
`Google.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 — to_state / from_state round-trip preserves the fields the
# request handlers actually depend on between requests.
# ---------------------------------------------------------------------------
class TestGoogleToStateRoundTrip(
_SkipServerSetUpMixin, BaseTestGenerator):
"""A Google instance's persistable state survives to_state/from_state."""
scenarios = [('default', dict())]
def runTest(self):
from pgadmin.misc.cloud.google import Google
client_config = {'web': {'client_id': 'X', 'client_secret': 'Y'}}
original = Google(client_config=client_config)
original.credentials_json = {
'token': 'access-token',
'refresh_token': 'refresh-token',
'token_uri': 'https://oauth2.googleapis.com/token',
'client_id': 'X',
'client_secret': 'Y',
'scopes': ['scope-a', 'scope-b'],
'id_token': 'id-token',
}
original._redirect_url = 'https://pgadmin.example/google/callback'
original._project_id = 'my-project'
original._regions = [{'label': 'us-central1', 'value': 'us-central1'}]
original._availability_zones = {'us-central1': ['us-central1-a']}
original._verification_successful = True
original._verification_error = None
state = original.to_state()
rebuilt = Google.from_state(state)
self.assertEqual(rebuilt._client_config, client_config)
self.assertEqual(rebuilt.credentials_json, original.credentials_json)
self.assertEqual(rebuilt._redirect_url, original._redirect_url)
self.assertEqual(rebuilt._project_id, 'my-project')
self.assertEqual(rebuilt._regions, original._regions)
self.assertEqual(
rebuilt._availability_zones, original._availability_zones)
self.assertTrue(rebuilt._verification_successful)
self.assertIsNone(rebuilt._verification_error)
class TestGoogleFromStateRebuildsCredentials(
_SkipServerSetUpMixin, BaseTestGenerator):
"""from_state must rebuild a working `_credentials` from credentials_json
so subsequent API calls succeed."""
scenarios = [('default', dict())]
def runTest(self):
from pgadmin.misc.cloud.google import Google
state = {
'client_config': {'web': {'client_id': 'X'}},
'credentials_json': {
'token': 'access-token',
'refresh_token': 'refresh-token',
'token_uri': 'https://oauth2.googleapis.com/token',
'client_id': 'X',
'client_secret': 'Y',
'scopes': ['scope-a'],
'id_token': 'id-token',
},
}
rebuilt = Google.from_state(state)
self.assertIsNotNone(rebuilt._credentials)
self.assertEqual(rebuilt._credentials.token, 'access-token')
self.assertEqual(
rebuilt._credentials.refresh_token, 'refresh-token')
class TestGetGoogleFromSession(
_SkipServerSetUpMixin, BaseTestGenerator):
"""Helper builds a Google from `session['google']['state']`."""
scenarios = [('default', dict())]
def runTest(self):
from flask import Flask, session
from pgadmin.misc.cloud.google import (
_get_google_from_session, Google)
app = Flask(__name__)
app.secret_key = 'test'
with app.test_request_context():
session['google'] = {
'state': {
'client_config': {'web': {'client_id': 'X'}},
'credentials_json': None,
'redirect_url': None,
'project_id': None,
'regions': [],
'availability_zones': {},
'verification_successful': False,
'verification_error': None,
}
}
obj = _get_google_from_session()
self.assertIsInstance(obj, Google)
self.assertEqual(
obj._client_config, {'web': {'client_id': 'X'}})
# ---------------------------------------------------------------------------
# Negative tests — defensive handling of missing session state
# ---------------------------------------------------------------------------
class TestGetGoogleFromSessionReturnsNoneWhenMissing(
_SkipServerSetUpMixin, BaseTestGenerator):
"""Helper returns None when no Google state is in session."""
scenarios = [('default', dict())]
def runTest(self):
from flask import Flask, session
from pgadmin.misc.cloud.google import _get_google_from_session
app = Flask(__name__)
app.secret_key = 'test'
with app.test_request_context():
self.assertIsNone(_get_google_from_session())
session['google'] = {}
self.assertIsNone(_get_google_from_session())
class TestFromStateHandlesMissingCredentials(
_SkipServerSetUpMixin, BaseTestGenerator):
"""from_state copes with state that has no credentials yet (pre-callback
phase, after get_auth_url but before callback completes)."""
scenarios = [('default', dict())]
def runTest(self):
from pgadmin.misc.cloud.google import Google
state = {
'client_config': {'web': {'client_id': 'X'}},
'credentials_json': None,
}
obj = Google.from_state(state)
self.assertIsNone(obj._credentials)
self.assertEqual(
obj._client_config, {'web': {'client_id': 'X'}})
# ---------------------------------------------------------------------------
# Regression — unsafe deserializer eliminated from the module
# ---------------------------------------------------------------------------
class TestUnsafeDeserializerEliminatedFromGoogleModule(
_SkipServerSetUpMixin, BaseTestGenerator):
"""cloud.google must not import or call the unsafe deserializer."""
scenarios = [('default', dict())]
def runTest(self):
import pgadmin.misc.cloud.google as google_mod
import inspect
src = inspect.getsource(google_mod)
forbidden = 'p' + 'i' + 'c' + 'k' + 'l' + 'e'
self.assertNotIn(
'import ' + forbidden, src,
"cloud.google must not import the unsafe deserializer")
self.assertNotIn(
forbidden + '.dumps(', src,
"cloud.google must not call the unsafe serialize call")
self.assertNotIn(
forbidden + '.loads(', src,
"cloud.google must not call the unsafe deserialize call")