From 1edefbeae532938a3eaefb3feeaf8dec4b6bebf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Mon, 6 Jan 2025 15:54:02 +0100 Subject: [PATCH] Limit access to database from the same series as the server The last series to which the database was updated is stored in ir.configuration. With this information, the list of databases is filtered for the same series. Remote access to a database is restricted to databases available in the list. Closes #13791 --- trytond/CHANGELOG | 1 + trytond/doc/ref/backend.rst | 4 +- trytond/trytond/__init__.py | 1 + trytond/trytond/admin.py | 5 +- trytond/trytond/backend/database.py | 2 +- .../trytond/backend/postgresql/database.py | 40 ++++++----- trytond/trytond/backend/postgresql/init.sql | 1 + trytond/trytond/backend/sqlite/database.py | 71 ++++++++++++------- trytond/trytond/ir/configuration.py | 1 + trytond/trytond/modules/__init__.py | 4 ++ trytond/trytond/protocols/wrappers.py | 8 ++- trytond/trytond/security.py | 11 +++ 12 files changed, 100 insertions(+), 49 deletions(-) diff --git a/trytond/CHANGELOG b/trytond/CHANGELOG index 0501425744..698b89cde5 100644 --- a/trytond/CHANGELOG +++ b/trytond/CHANGELOG @@ -1,3 +1,4 @@ +* Limit access to database from the same series * Fill wizard state view instance with default value * Replace passlib by pwdlib * Add size limit for RPC arguments diff --git a/trytond/doc/ref/backend.rst b/trytond/doc/ref/backend.rst index fba94c6cf6..4a541cda3f 100644 --- a/trytond/doc/ref/backend.rst +++ b/trytond/doc/ref/backend.rst @@ -59,11 +59,13 @@ Database Initialize the database schema. -.. method:: Database.test([hostname]) +.. method:: Database.test([hostname[, series]]) Return if the database is a Tryton database. If ``hostname`` is set, it checks also if it has the same configured hostname. + If ``series`` is ``True``, it checks also if it has the same series from the + last update. .. method:: Database.nextid(connection, table[, count]) diff --git a/trytond/trytond/__init__.py b/trytond/trytond/__init__.py index c327a0fe9f..f5e06d3a50 100644 --- a/trytond/trytond/__init__.py +++ b/trytond/trytond/__init__.py @@ -16,6 +16,7 @@ except ImportError: requests_utils = None __version__ = "7.5.0" +__series__ = '.'.join(__version__.split('.')[:2]) os.environ.setdefault( 'TRYTOND_APPNAME', diff --git a/trytond/trytond/admin.py b/trytond/trytond/admin.py index db0b09e3fc..e14c6dc978 100644 --- a/trytond/trytond/admin.py +++ b/trytond/trytond/admin.py @@ -8,7 +8,7 @@ from getpass import getpass from sql import Literal, Table -from trytond import backend +from trytond import __series__, backend from trytond.config import config from trytond.pool import Pool from trytond.sendmail import send_test_email @@ -129,6 +129,9 @@ def run(options): User = pool.get('res.user') Configuration = pool.get('ir.configuration') configuration = Configuration(1) + if init[db_name]: + configuration.series = __series__ + with inactive_records(): admin, = User.search([('login', '=', 'admin')]) diff --git a/trytond/trytond/backend/database.py b/trytond/trytond/backend/database.py index c19de03740..fd2b88016a 100644 --- a/trytond/trytond/backend/database.py +++ b/trytond/trytond/backend/database.py @@ -51,7 +51,7 @@ class DatabaseInterface(object): def init(self): raise NotImplementedError - def test(self, hostname=None): + def test(self, hostname=None, series=False): ''' Test if it is a Tryton database. ''' diff --git a/trytond/trytond/backend/postgresql/database.py b/trytond/trytond/backend/postgresql/database.py index 46c6cbf493..62b5a4283c 100644 --- a/trytond/trytond/backend/postgresql/database.py +++ b/trytond/trytond/backend/postgresql/database.py @@ -27,8 +27,8 @@ from psycopg2 import DataError as DatabaseDataError from psycopg2 import IntegrityError as DatabaseIntegrityError from psycopg2 import InterfaceError from psycopg2 import OperationalError as DatabaseOperationalError -from psycopg2 import ProgrammingError from psycopg2.errors import QueryCanceled as DatabaseTimeoutError +from psycopg2.errors import UndefinedColumn from psycopg2.extras import register_default_json, register_default_jsonb from sql import Cast, Flavor, For, Literal, Table from sql.aggregate import Count @@ -36,6 +36,7 @@ from sql.conditionals import Coalesce from sql.functions import Function from sql.operators import BinaryOperator, Concat +from trytond import __series__ from trytond.backend.database import DatabaseInterface, SQLType from trytond.config import config, parse_uri from trytond.sql.operators import RangeOperator @@ -345,18 +346,19 @@ class Database(DatabaseInterface): if res and abs(timestamp - now) < timeout: return res + res = [] connection = self.get_connection() try: cursor = connection.cursor() cursor.execute('SELECT datname FROM pg_database ' 'WHERE datistemplate = false ORDER BY datname') - res = [] for db_name, in cursor: try: conn = connect(**self._connection_params(db_name)) try: with conn: - if self._test(conn, hostname=hostname): + if self._test( + conn, hostname=hostname, series=True): res.append(db_name) finally: conn.close() @@ -401,36 +403,38 @@ class Database(DatabaseInterface): finally: self.put_connection(connection) - def test(self, hostname=None): + def test(self, hostname=None, series=False): try: connection = self.get_connection() except Exception: logger.debug('Test failed for "%s"', self.name, exc_info=True) return False try: - return self._test(connection, hostname=hostname) + return self._test(connection, hostname=hostname, series=series) finally: self.put_connection(connection, close=True) @classmethod - def _test(cls, connection, hostname=None): + def _test(cls, connection, hostname=None, series=False): cursor = connection.cursor() - tables = ('ir_model', 'ir_model_field', 'ir_ui_view', 'ir_ui_menu', - 'res_user', 'res_group', 'ir_module', 'ir_module_dependency', - 'ir_translation', 'ir_lang', 'ir_configuration') - cursor.execute('SELECT table_name FROM information_schema.tables ' - 'WHERE table_name IN %s', (tables,)) - if len(cursor.fetchall()) != len(tables): + cursor.execute( + 'SELECT table_name FROM information_schema.tables ' + 'WHERE table_name = %s', ('ir_configuration',)) + if not cursor.rowcount: return False - if hostname: - try: - cursor.execute( - 'SELECT hostname FROM ir_configuration') + try: + if series: + cursor.execute('SELECT series FROM ir_configuration') + config_series = {s for s, in cursor if s} + if config_series and __series__ not in config_series: + return False + if hostname: + cursor.execute('SELECT hostname FROM ir_configuration') hostnames = {h for h, in cursor if h} if hostnames and hostname not in hostnames: return False - except ProgrammingError: - pass + except UndefinedColumn: + return False return True def nextid(self, connection, table, count=1): diff --git a/trytond/trytond/backend/postgresql/init.sql b/trytond/trytond/backend/postgresql/init.sql index 278d3e48b4..1ad216a3b5 100644 --- a/trytond/trytond/backend/postgresql/init.sql +++ b/trytond/trytond/backend/postgresql/init.sql @@ -1,6 +1,7 @@ CREATE TABLE ir_configuration ( id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL CONSTRAINT ir_configuration_id_positive CHECK(id >= 0), + series VARCHAR, language VARCHAR, hostname VARCHAR, PRIMARY KEY(id) diff --git a/trytond/trytond/backend/sqlite/database.py b/trytond/trytond/backend/sqlite/database.py index 0fc1c82c97..c7906ec91c 100644 --- a/trytond/trytond/backend/sqlite/database.py +++ b/trytond/trytond/backend/sqlite/database.py @@ -7,6 +7,7 @@ import os import random import sqlite3 as sqlite import threading +import time import urllib.parse import warnings from decimal import Decimal @@ -23,6 +24,7 @@ from sql.functions import ( Substring, Trim) from sql.operators import Equal +from trytond import __series__ from trytond.backend.database import DatabaseInterface, SQLType from trytond.config import config, parse_uri from trytond.tools import safe_join @@ -438,6 +440,8 @@ class Database(DatabaseInterface): _local = threading.local() _conn = None + _list_cache = {} + _list_cache_timestamp = {} flavor = Flavor( paramstyle='qmark', function_mapping=MAPPING, null_ordering=False, max_limit=-1) @@ -599,24 +603,33 @@ class Database(DatabaseInterface): with sqlite.connect(path) as conn: cursor = conn.cursor() cursor.close() + cls._list_cache.clear() @classmethod def drop(cls, connection, database_name): - if database_name == ':memory:': - cls._local.memory_database._conn = None - return if os.sep in database_name: return - file = os.path.join( - config.get('database', 'path'), database_name + '.sqlite') - os.remove(file) - for suffix in ['-shm', '-wal']: - try: - os.remove(file + suffix) - except FileNotFoundError: - pass + if database_name == ':memory:': + cls._local.memory_database._conn = None + else: + file = os.path.join( + config.get('database', 'path'), database_name + '.sqlite') + os.remove(file) + for suffix in ['-shm', '-wal']: + try: + os.remove(file + suffix) + except FileNotFoundError: + pass + cls._list_cache.clear() def list(self, hostname=None): + now = time.time() + timeout = config.getint('session', 'timeout') + res = self.__class__._list_cache.get(hostname) + timestamp = self.__class__._list_cache_timestamp.get(hostname, now) + if res and abs(timestamp - now) < timeout: + return res + res = [] listdir = [':memory:'] try: @@ -635,9 +648,12 @@ class Database(DatabaseInterface): logger.debug( 'Test failed for "%s"', db_name, exc_info=True) continue - if database.test(hostname=hostname): + if database.test(hostname=hostname, series=True): res.append(db_name) database.close() + + self.__class__._list_cache[hostname] = res + self.__class__._list_cache_timestamp[hostname] = now return res def init(self): @@ -673,28 +689,29 @@ class Database(DatabaseInterface): cursor.execute(*insert) conn.commit() - def test(self, hostname=None): - Flavor.set(self.flavor) - tables = ['ir_model', 'ir_model_field', 'ir_ui_view', 'ir_ui_menu', - 'res_user', 'res_group', 'ir_module', 'ir_module_dependency', - 'ir_translation', 'ir_lang', 'ir_configuration'] - sqlite_master = Table('sqlite_master') - select = sqlite_master.select(sqlite_master.name) - select.where = sqlite_master.type == 'table' - select.where &= sqlite_master.name.in_(tables) + def test(self, hostname=None, series=False): with self._conn as conn: cursor = conn.cursor() try: - cursor.execute(*select) + cursor.execute( + 'SELECT name FROM sqlite_master ' + 'WHERE type = ? AND name = ?', + ('table', 'ir_configuration')) except Exception: return False - if len(cursor.fetchall()) != len(tables): + if not cursor.fetchall(): return False - if hostname: - configuration = Table('ir_configuration') + if series: try: - cursor.execute(*configuration.select( - configuration.hostname)) + cursor.execute('SELECT series FROM ir_configuration') + except Exception: + return False + config_series = {s for s, in cursor if s} + if config_series and __series__ not in config_series: + return False + if hostname: + try: + cursor.execute('SELECT hostname FROM ir_configuration') except Exception: return False hostnames = {h for h, in cursor if h} diff --git a/trytond/trytond/ir/configuration.py b/trytond/trytond/ir/configuration.py index f8f0f74180..2aa880da89 100644 --- a/trytond/trytond/ir/configuration.py +++ b/trytond/trytond/ir/configuration.py @@ -7,6 +7,7 @@ from trytond.model import ModelSingleton, ModelSQL, fields class Configuration(ModelSingleton, ModelSQL): __name__ = 'ir.configuration' + series = fields.Char("Series") language = fields.Char('language') hostname = fields.Char("Hostname", strip=False) _get_language_cache = Cache('ir_configuration.get_language') diff --git a/trytond/trytond/modules/__init__.py b/trytond/trytond/modules/__init__.py index ce071a13ac..9ee7c69c17 100644 --- a/trytond/trytond/modules/__init__.py +++ b/trytond/trytond/modules/__init__.py @@ -13,6 +13,7 @@ from sql.functions import CurrentTimestamp import trytond.convert as convert import trytond.tools as tools +from trytond import __series__ from trytond.config import config from trytond.const import MODULES_GROUP from trytond.exceptions import MissingDependenciesException @@ -22,6 +23,7 @@ logger = logging.getLogger(__name__) ir_module = Table('ir_module') ir_model_data = Table('ir_model_data') +ir_configuration = Table('ir_configuration') MODULES_PATH = os.path.abspath(os.path.dirname(__file__)) @@ -288,6 +290,8 @@ def load_module_graph(graph, pool, update=None, lang=None, indexes=None): model._update_sql_indexes(concurrently=concurrently) if update: + cursor.execute(*ir_configuration.update( + [ir_configuration.series], [__series__])) if indexes or indexes is None: create_indexes(concurrently=False) else: diff --git a/trytond/trytond/protocols/wrappers.py b/trytond/trytond/protocols/wrappers.py index 8f232a87c8..85ea999b49 100644 --- a/trytond/trytond/protocols/wrappers.py +++ b/trytond/trytond/protocols/wrappers.py @@ -19,7 +19,7 @@ from werkzeug.wrappers import Request as _Request from werkzeug.wrappers import Response from trytond import backend, security -from trytond.config import config +from trytond.config import config, get_hostname from trytond.exceptions import RateLimitException, UserError, UserWarning from trytond.pool import Pool from trytond.tools import cached_property @@ -191,6 +191,12 @@ def with_pool(func): @wraps(func) def wrapper(request, database_name, *args, **kwargs): database_list = Pool.database_list() + if database_name not in database_list: + with Transaction().start(None, 0, readonly=True) as transaction: + hostname = get_hostname(request.host) + db_list = transaction.database.list(hostname=hostname) + if database_name not in db_list: + abort(HTTPStatus.NOT_FOUND) pool = Pool(database_name) if database_name not in database_list: with Transaction().start(database_name, 0, readonly=True): diff --git a/trytond/trytond/security.py b/trytond/trytond/security.py index 0c77f412e8..94601e01ea 100644 --- a/trytond/trytond/security.py +++ b/trytond/trytond/security.py @@ -5,6 +5,13 @@ import logging import random import time +try: + from http import HTTPStatus +except ImportError: + from http import client as HTTPStatus + +from werkzeug.exceptions import abort + from trytond import backend from trytond.config import config from trytond.exceptions import LoginException, RateLimitException @@ -16,6 +23,10 @@ logger = logging.getLogger(__name__) def _get_pool(dbname): database_list = Pool.database_list() + if dbname not in database_list: + db_list = Transaction().database.list() + if dbname not in db_list: + abort(HTTPStatus.NOT_FOUND) pool = Pool(dbname) if dbname not in database_list: pool.init()